Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ when the app is loaded through Qortium Home.
- Browse QDN services, names, and resources through deep-linkable routes.
- Search public QDN metadata with an optional service filter.
- Inspect resource metadata, status, and properties.
- Open apps, websites, media, and documents through the appropriate Home
action, with safe internal viewers for text, Markdown, code, CSV, JSON, and
images.
- Feature-detect Home's generic QDN resource viewer and use it for public
non-browser resources, while keeping APP/WEBSITE/GAME on Home's navigation
path and retaining the older media/document actions as compatibility
fallbacks.
- Use Home-provided ranged URLs for lazy inline image, audio, and video
previews. On older Home versions, bounded image/text previews and the
existing Open actions continue to work.
- Browse the individual files of a multi-file resource, with a deep-linkable
route per file (`#/detail/{service}/{name}/{identifier}/file/{path}`).
- View a published Git repository (bare or worktree layout) as a repository:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "qortium-explore",
"version": "1.4.9",
"version": "1.4.10",
"private": true,
"license": "0BSD",
"description": "Browse, search, inspect, and open public Qortium QDN resources.",
Expand Down
36 changes: 30 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { createTranslator } from './i18n';
import { detectGitRepositoryLayout } from './qdnGitRepository';
import { NameOwnerIdentity } from './NameOwnerIdentity';
import { hasHomeBridge, qdnRequest } from './qdnRequest';
import {
resourceBridgeCapabilities,
type ResourceBridgeCapabilities,
} from './resourceBridge';
import { loadResourceDetails } from './resourceDetails';
import { resourceFetchRequest, resourceFiles } from './resourceFiles';
import { isBrowserArchiveService, PUBLIC_QDN_SERVICES } from './services';
Expand Down Expand Up @@ -41,6 +45,8 @@ export function App() {
const [gitFallback, setGitFallback] = useState('');
const [previewing, setPreviewing] = useState(false); const [previewMessage, setPreviewMessage] = useState(''); const [previewFailure, setPreviewFailure] = useState('');
const [sourcePreviewSupported, setSourcePreviewSupported] = useState(false);
const [resourceBridge, setResourceBridge] = useState<ResourceBridgeCapabilities>({ resourceViewer: false, streamUrl: false });
const [resourceBridgeReady, setResourceBridgeReady] = useState(false);
const t = useMemo(() => createTranslator(display.language), [display.language]);
const navigate = (next: ExploreRoute) => { window.location.hash = hashForRoute(next); };
const replace = (next: ExploreRoute) => {
Expand All @@ -49,7 +55,21 @@ export function App() {
};
useEffect(() => { const onHash = () => setRoute(routeFromHash(window.location.hash)); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []);
useEffect(() => { applyDisplaySettings(display); const onMessage = (event: MessageEvent) => setDisplay(current => updateFromHostMessage(event.data, current) ?? current); window.addEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage); }, [display]);
useEffect(() => { if (!hasHomeBridge()) { setSourcePreviewSupported(false); return; } let active = true; void qdnRequest<unknown>({ action: 'SHOW_ACTIONS' }).then(actions => { if (active) setSourcePreviewSupported(supportsSourcePreview(actions)); }).catch(() => { if (active) setSourcePreviewSupported(false); }); return () => { active = false; }; }, []);
useEffect(() => {
let active = true;
void qdnRequest<unknown>({ action: 'SHOW_ACTIONS' }).then(actions => {
if (!active) return;
setSourcePreviewSupported(hasHomeBridge() && supportsSourcePreview(actions));
setResourceBridge(resourceBridgeCapabilities(actions));
setResourceBridgeReady(true);
}).catch(() => {
if (!active) return;
setSourcePreviewSupported(false);
setResourceBridge({ resourceViewer: false, streamUrl: false });
setResourceBridgeReady(true);
});
return () => { active = false; };
}, []);
useEffect(() => { const request = resourceQuery(route); if (!request) return; let active = true; setLoading(true); setFailure(''); void qdnRequest<unknown>(request).then(value => { if (!active) return; const nextResources = asResources(value); const detail = singleResourceDetailRoute(route, nextResources); if (detail) { replace(detail); return; } setResources(nextResources); }).catch(error => { if (active) setFailure(errorText(error)); }).finally(() => { if (active) setLoading(false); }); return () => { active = false; }; }, [route, refresh]);
useEffect(() => { setGitFallback(''); if (route.kind !== 'detail') { setDetails(null); return; } let active = true; setDetails(null); setFailure(''); const resource = { service: route.service, name: route.name, identifier: route.identifier }; void loadResourceDetails(qdnRequest, resource).then(value => { if (active) setDetails(value); }).catch(error => { if (active) setFailure(errorText(error)); }); return () => { active = false; }; }, [route]);
const folders = useMemo(() => route.kind === 'services' ? groupBy(resources, item => item.service) : route.kind === 'service' ? groupBy(resources, item => item.name) : route.kind === 'name-services' ? groupBy(resources, item => item.service) : [], [resources, route]);
Expand All @@ -61,16 +81,20 @@ export function App() {
const previewLocalFile = () => { setPreviewing(true); setPreviewMessage(''); setPreviewFailure(''); void previewQdnPublishSource(qdnRequest).then(result => setPreviewMessage(result.kind === 'canceled' ? t('preview.canceled') : t('preview.opened'))).catch(error => setPreviewFailure(errorText(error))).finally(() => setPreviewing(false)); };
const detailResource: QdnResource | null = route.kind === 'detail' ? { service: route.service, name: route.name, identifier: route.identifier } : null;
if (route.kind === 'detail' && detailResource) {
const file = typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined;
const mime = typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined;
const open = dispatchOpen(detailResource, { filename: file, mimeType: mime });
const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER';
const files = resourceFiles(details?.metadata);
const selected = files.includes(route.path ?? '') ? route.path : undefined;
const viewed = { ...detailResource, path: selected };
const file = selected || (typeof details?.properties?.filename === 'string' ? details.properties.filename : undefined);
const mime = selected ? undefined : typeof details?.properties?.mimeType === 'string' ? details.properties.mimeType : undefined;
const open = dispatchOpen(viewed, {
filename: file,
mimeType: mime,
resourceViewer: resourceBridge.resourceViewer,
});
const opensInHomeViewer = open.action === 'OPEN_QDN_DOCUMENT_VIEWER' || open.action === 'OPEN_QDN_MEDIA_PLAYER' || open.action === 'OPEN_QDN_RESOURCE_VIEWER';
const showFiles = files.length > 1;
const showGit = !selected && !gitFallback && !!detectGitRepositoryLayout(files);
return <main className="app"><header className="top"><div><h1>{t('app.title')}</h1><p>{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}</p><NameOwnerIdentity key={detailResource.name} language={display.language} name={detailResource.name} /></div><button onClick={() => history.back()}>{t('action.back')}</button></header><section className="detail"><div className="detail-actions">{open.action === 'INTERNAL_VIEWER' ? null : <button onClick={() => void qdnRequest(open)}>{t('action.open')}</button>}{isBrowserArchiveService(detailResource.service) ? <button onClick={() => void qdnRequest(dispatchOpen(detailResource, { newTab: true }))}>{t('action.openNewTab')}</button> : null}<button onClick={() => void qdnRequest({ action: 'SAVE_QDN_RESOURCE', ...detailResource, filename: file })}>{t('action.download')}</button></div>{failure ? <p className="error">{failure}</p> : null}<div className="detail-grid"><section><h2>{t('label.details')}</h2><dl><dt>{t('label.title')}</dt><dd>{String(details?.metadata?.title || '—')}</dd><dt>{t('label.description')}</dt><dd>{String(details?.metadata?.description || '—')}</dd><dt>{t('label.status')}</dt><dd>{String(details?.status?.status || '—')}</dd><dt>{t('column.size')}</dt><dd>{bytes(details?.status?.size)}</dd><dt>{t('column.updated')}</dt><dd>{date(details?.status?.updated)}</dd></dl>{showFiles ? <><h3>{t('label.files')} <small>{files.length.toLocaleString()}</small></h3><div className="file-list">{files.map(path => <button className={`file-row${path === selected ? ' file-row--active' : ''}`} key={path} type="button" onClick={() => navigate({ ...route, path })}>{path}</button>)}</div></> : null}<h3>{t('label.properties')}</h3><pre className="source">{JSON.stringify(details?.properties || {}, null, 2)}</pre></section><section><h2>{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}</h2>{selected ? <button className="file-clear" type="button" onClick={() => navigate({ ...route, path: undefined })}>{t('action.allFiles')}</button> : null}{gitFallback ? <p className="viewer-note">{gitFallback}</p> : null}{showGit ? <GitRepositoryViewer key={`${detailResource.service}/${detailResource.name}/${detailResource.identifier || ''}`} files={files} language={display.language} onFallback={setGitFallback} resource={detailResource} /> : showFiles && !selected ? <p className="viewer-note">{t('viewer.selectFile')}</p> : <ContentViewer key={selected ?? ''} resource={viewed} properties={details?.properties} binaryMessage={opensInHomeViewer ? t('viewer.openInHome') : undefined} />}</section></div></section></main>;
return <main className="app"><header className="top"><div><h1>{t('app.title')}</h1><p>{detailResource.service} / {detailResource.name} / {detailResource.identifier || 'default'}{selected ? ` / ${selected}` : ''}</p><NameOwnerIdentity key={detailResource.name} language={display.language} name={detailResource.name} /></div><button onClick={() => history.back()}>{t('action.back')}</button></header><section className="detail"><div className="detail-actions">{open.action === 'INTERNAL_VIEWER' ? null : <button onClick={() => void qdnRequest(open).catch(error => setFailure(errorText(error)))}>{t('action.open')}</button>}{isBrowserArchiveService(detailResource.service) ? <button onClick={() => void qdnRequest(dispatchOpen(detailResource, { newTab: true })).catch(error => setFailure(errorText(error)))}>{t('action.openNewTab')}</button> : null}<button onClick={() => void qdnRequest({ action: 'SAVE_QDN_RESOURCE', ...viewed, filename: file }).catch(error => setFailure(errorText(error)))}>{t('action.download')}</button></div>{failure ? <p className="error">{failure}</p> : null}<div className="detail-grid"><section><h2>{t('label.details')}</h2><dl><dt>{t('label.title')}</dt><dd>{String(details?.metadata?.title || '—')}</dd><dt>{t('label.description')}</dt><dd>{String(details?.metadata?.description || '—')}</dd><dt>{t('label.status')}</dt><dd>{String(details?.status?.status || '—')}</dd><dt>{t('column.size')}</dt><dd>{bytes(details?.status?.size)}</dd><dt>{t('column.updated')}</dt><dd>{date(details?.status?.updated)}</dd></dl>{showFiles ? <><h3>{t('label.files')} <small>{files.length.toLocaleString()}</small></h3><div className="file-list">{files.map(path => <button className={`file-row${path === selected ? ' file-row--active' : ''}`} key={path} type="button" onClick={() => navigate({ ...route, path })}>{path}</button>)}</div></> : null}<h3>{t('label.properties')}</h3><pre className="source">{JSON.stringify(details?.properties || {}, null, 2)}</pre></section><section><h2>{showGit ? t('git.title') : selected || (opensInHomeViewer ? t('viewer.preview') : t('viewer.source'))}</h2>{selected ? <button className="file-clear" type="button" onClick={() => navigate({ ...route, path: undefined })}>{t('action.allFiles')}</button> : null}{gitFallback ? <p className="viewer-note">{gitFallback}</p> : null}{showGit ? <GitRepositoryViewer key={`${detailResource.service}/${detailResource.name}/${detailResource.identifier || ''}`} files={files} language={display.language} onFallback={setGitFallback} resource={detailResource} /> : showFiles && !selected ? <p className="viewer-note">{t('viewer.selectFile')}</p> : <ContentViewer key={selected ?? ''} resource={viewed} properties={details?.properties} binaryMessage={opensInHomeViewer ? t('viewer.openInHome') : undefined} streamUrlSupported={resourceBridgeReady ? resourceBridge.streamUrl : null} />}</section></div></section></main>;
}
return <main className="app"><header className="top"><div><h1>{t('app.title')}</h1><p>{t('app.subtitle')} <small>{__APP_VERSION__}</small></p></div><div className="top-actions">{sourcePreviewSupported ? <button disabled={previewing} onClick={previewLocalFile}>{previewing ? t('preview.choosing') : t('action.preview')}</button> : null}<button disabled={loading} onClick={() => setRefresh(value => value + 1)}>{t('action.refresh')}</button></div></header>{previewMessage ? <p className="preview-status" aria-live="polite">{previewMessage}</p> : null}{previewFailure ? <p className="error" role="alert">{previewFailure}</p> : null}<section className="search"><input aria-label={t('field.query')} value={search} placeholder={t('field.query')} onChange={event => setSearch(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') doSearch(); }} /><select aria-label={t('field.service')} value={searchService} onChange={event => setSearchService(event.target.value)}><option value="">{t('field.service')}</option>{PUBLIC_QDN_SERVICES.map(service => <option key={service}>{service}</option>)}</select><button disabled={searchLoading} onClick={doSearch}>{t('action.search')}</button>{searchResults ? <button onClick={() => { setSearch(''); setSearchResults(null); }}>{t('action.back')}</button> : null}</section><p className="crumb">{route.kind === 'services' ? 'QDN' : route.kind === 'service' ? route.service : route.kind === 'name-services' ? route.name : `${route.service} / ${route.name}`}</p>{failure ? <section className="error-box"><strong>{t('error.coreOffline')}</strong><p>{failure}</p><button onClick={() => setRefresh(value => value + 1)}>{t('action.retry')}</button></section> : null}{loading && !resources.length ? <p className="loading">{t('loading')}</p> : null}{!searchResults && folders.length > 0 ? <section className="list"><div className="head"><span>{t('label.name')}</span><SortButton active={sort.key === 'count'} onClick={() => toggle('count')}>{t('column.count')}</SortButton><SortButton active={sort.key === 'updated'} onClick={() => toggle('updated')}>{t('column.updated')}</SortButton></div>{sortedFolders.map(row => <button className="row folder" key={row.name} onClick={() => navigate(route.kind === 'services' ? { kind: 'service', service: row.name } : route.kind === 'service' ? { kind: 'resources', service: route.service, name: row.name } : { kind: 'resources', service: row.name, name: route.name })}><span>▸ {row.name}</span><span>{row.count.toLocaleString()}</span><span>{date(row.updated)}</span></button>)}</section> : null}{(searchResults || route.kind === 'resources') && <section className="list"><div className="head resources"><span aria-hidden="true" /><SortButton active={sort.key === 'identifier'} onClick={() => toggle('identifier')}>{t('column.identifier')}</SortButton><SortButton active={sort.key === 'status'} onClick={() => toggle('status')}>{t('column.status')}</SortButton><SortButton active={sort.key === 'size'} onClick={() => toggle('size')}>{t('column.size')}</SortButton><SortButton active={sort.key === 'updated'} onClick={() => toggle('updated')}>{t('column.updated')}</SortButton></div>{sortedResources.map(resource => <button className="row resource" key={`${resource.service}/${resource.name}/${resource.identifier || ''}`} onClick={() => navigate(detailRoute(resource))}><Thumbnail resource={resource} /><span><strong>{resource.identifier || 'default'}</strong><small>{resource.service} · {resource.name}</small></span><span>{resource.status?.status || 'PUBLISHED'}</span><span>{bytes(resource.size)}</span><span>{date(updatedOf(resource))}</span></button>)}</section>}{!loading && !failure && ((searchResults && !searchResults.length) || (!searchResults && !folders.length && route.kind !== 'resources') || (route.kind === 'resources' && !resources.length)) ? <p className="empty">{searchResults ? t('empty.search') : t('empty.resources')}</p> : null}</main>;
}
31 changes: 31 additions & 0 deletions src/contentViewer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { classifyContent } from './contentViewer';

const resource = (service: string, path?: string) => ({
service,
name: 'Alice',
identifier: 'one',
path,
});

describe('content viewer media classification', () => {
it.each([
['AUDIO', 'audio'],
['VOICE', 'audio'],
['PODCAST', 'audio'],
['VIDEO', 'video'],
['IMAGE', 'image'],
] as const)('classifies %s by service as %s', (service, kind) => {
expect(classifyContent(resource(service))).toBe(kind);
});

it('classifies media inside generic file services by selected filename', () => {
expect(classifyContent(resource('FILES', 'media/song.opus'))).toBe('audio');
expect(classifyContent(resource('ATTACHMENT', 'media/movie.webm'))).toBe('video');
expect(classifyContent(resource('FILE'), { filename: 'cover.avif' })).toBe('image');
});

it('does not apply container MIME hints to an individually selected file', () => {
expect(classifyContent(resource('FILES', 'README.md'), { mimeType: 'video/mp4' })).toBe('markdown');
});
});
Loading