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
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.8",
"version": "1.4.9",
"private": true,
"license": "0BSD",
"description": "Browse, search, inspect, and open public Qortium QDN resources.",
Expand Down
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createTranslator } from './i18n';
import { detectGitRepositoryLayout } from './qdnGitRepository';
import { NameOwnerIdentity } from './NameOwnerIdentity';
import { hasHomeBridge, qdnRequest } from './qdnRequest';
import { loadResourceDetails } from './resourceDetails';
import { resourceFetchRequest, resourceFiles } from './resourceFiles';
import { isBrowserArchiveService, PUBLIC_QDN_SERVICES } from './services';
import { sortRows, type Sort, updatedOf } from './sort';
Expand Down Expand Up @@ -50,7 +51,7 @@ export function App() {
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(() => { 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; const resource = { service: route.service, name: route.name, identifier: route.identifier }; void Promise.all([qdnRequest<Record<string, unknown>>({ action: 'GET_QDN_RESOURCE_METADATA', ...resource }), qdnRequest<Record<string, unknown>>({ action: 'GET_QDN_RESOURCE_STATUS', ...resource }), qdnRequest<Record<string, unknown>>({ action: 'GET_QDN_RESOURCE_PROPERTIES', ...resource })]).then(([metadata, status, properties]) => { if (active) setDetails({ metadata, status: status as ResourceDetails['status'], properties }); }).catch(error => { if (active) setFailure(errorText(error)); }); return () => { active = false; }; }, [route]);
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]);
const shown = searchResults ?? (route.kind === 'resources' ? resources : []);
const sortedFolders = sortRows(folders, sort, (row, key) => key === 'count' ? row.count : key === 'updated' ? row.updated : row.name);
Expand All @@ -63,12 +64,13 @@ export function App() {
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 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 || 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} />}</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)}>{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>{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>;
}
20 changes: 14 additions & 6 deletions src/contentViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { previewCache, previewCacheKey } from './previewCache';
import { qdnRequest } from './qdnRequest';
import { resourceFetchRequest } from './resourceFiles';
import type { QdnResource } from './types';
Expand Down Expand Up @@ -43,26 +44,33 @@ function csvRows(text: string) { return text.split(/\r?\n/).filter(Boolean).map(
* text for every other kind, matching what ContentViewer fetches; the Git
* viewer feeds it bytes it read out of a repository instead.
*/
export function ContentPreview({ kind, data, resource, properties }: { kind: ContentKind; data: string; resource: QdnResource; properties?: Record<string, unknown> }) {
if (kind === 'binary') return <p className="viewer-note">This resource cannot be rendered safely in Explore. Use Download to save its original bytes.</p>;
export function ContentPreview({ kind, data, resource, properties, binaryMessage }: { kind: ContentKind; data: string; resource: QdnResource; properties?: Record<string, unknown>; binaryMessage?: string }) {
if (kind === 'binary') return <p className="viewer-note">{binaryMessage || 'This resource cannot be rendered safely in Explore. Use Download to save its original bytes.'}</p>;
if (kind === 'image') return <img className="content-image" alt={filename(resource, properties)} src={`data:${imageMimeType(resource, properties)};base64,${data}`} />;
if (kind === 'json') { try { return <pre className="source">{JSON.stringify(JSON.parse(data), null, 2)}</pre>; } catch { return <pre className="source">{data}</pre>; } }
if (kind === 'csv') { const rows = csvRows(data); return <div className="table-scroll"><table className="csv"><tbody>{rows.map((row, i) => <tr key={i}>{row.map((cell, j) => i === 0 ? <th key={j}>{cell}</th> : <td key={j}>{cell}</td>)}</tr>)}</tbody></table></div>; }
if (kind === 'markdown') return <article className="markdown">{data.split(/\r?\n/).map((line, index) => line.startsWith('### ') ? <h3 key={index}>{line.slice(4)}</h3> : line.startsWith('## ') ? <h2 key={index}>{line.slice(3)}</h2> : line.startsWith('# ') ? <h1 key={index}>{line.slice(2)}</h1> : <p key={index}>{line}</p>)}</article>;
return <pre className="source">{data}</pre>;
}

export function ContentViewer({ resource, properties }: { resource: QdnResource; properties?: Record<string, unknown> }) {
export function ContentViewer({ resource, properties, binaryMessage }: { resource: QdnResource; properties?: Record<string, unknown>; binaryMessage?: string }) {
const [state, setState] = useState<{ data?: string; error?: string; loading: boolean }>({ loading: true });
const kind = classifyContent(resource, properties);
const cacheKey = previewCacheKey(resource, kind);
useEffect(() => {
if (kind === 'binary') { setState({ loading: false }); return; }
const cached = previewCache.get(cacheKey);
if (cached !== undefined) { setState({ data: cached, loading: false }); return; }
let active = true;
setState({ loading: true });
void qdnRequest<unknown>(resourceFetchRequest(resource, { binary: kind === 'image', maxBytes: CONTENT_MAX_BYTES })).then(data => { if (active) setState({ data: toText(data), loading: false }); }).catch(error => { if (active) setState({ error: error instanceof Error ? error.message : 'Unable to fetch content.', loading: false }); });
void qdnRequest<unknown>(resourceFetchRequest(resource, { binary: kind === 'image', maxBytes: CONTENT_MAX_BYTES })).then(data => {
const text = toText(data);
previewCache.set(cacheKey, text);
if (active) setState({ data: text, loading: false });
}).catch(error => { if (active) setState({ error: error instanceof Error ? error.message : 'Unable to fetch content.', loading: false }); });
return () => { active = false; };
}, [kind, resource.identifier, resource.name, resource.path, resource.service]);
if (kind === 'binary') return <ContentPreview kind={kind} data="" resource={resource} properties={properties} />;
}, [cacheKey, kind, resource.identifier, resource.name, resource.path, resource.service]);
if (kind === 'binary') return <ContentPreview kind={kind} data="" resource={resource} properties={properties} binaryMessage={binaryMessage} />;
if (state.loading) return <p className="loading">Loading preview…</p>;
if (state.error) return <p className="error">{state.error}</p>;
return <ContentPreview kind={kind} data={state.data || ''} resource={resource} properties={properties} />;
Expand Down
3 changes: 2 additions & 1 deletion src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const EN_STRINGS = {
'label.status': 'Status', 'label.title': 'Title', 'label.type': 'Type', 'label.unknown': 'Unknown', 'loading': 'Loading…',
'search.help': 'Search titles, descriptions, and resource fields across public QDN.', 'thumbnail.placeholder': 'Preview unavailable',
'viewer.download': 'Download resource', 'viewer.binary': 'This resource cannot be rendered safely in Explore.',
'viewer.empty': 'This resource has no text content.', 'viewer.json': 'JSON', 'viewer.selectFile': 'Select a file to preview it here.', 'viewer.source': 'Source preview',
'viewer.empty': 'This resource has no text content.', 'viewer.json': 'JSON', 'viewer.openInHome': 'Use Open to preview this resource in Home’s built-in viewer, or Download to save its original bytes.',
'viewer.preview': 'Preview', 'viewer.selectFile': 'Select a file to preview it here.', 'viewer.source': 'Source preview',
'preview.canceled': 'File selection was canceled.', 'preview.choosing': 'Choosing file…', 'preview.opened': 'Preview opened in Home.',
} as const;
Loading