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
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.5",
"version": "1.4.6",
"private": true,
"license": "0BSD",
"description": "Browse, search, inspect, and open public Qortium QDN resources.",
Expand Down
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createTranslator } from './i18n';
import { NameOwnerIdentity } from './NameOwnerIdentity';
import { hasHomeBridge, qdnRequest } from './qdnRequest';
import { resourceFetchRequest, resourceFiles } from './resourceFiles';
import { PUBLIC_QDN_SERVICES } from './services';
import { isBrowserArchiveService, PUBLIC_QDN_SERVICES } from './services';
import { sortRows, type Sort, updatedOf } from './sort';
import { previewQdnPublishSource, supportsSourcePreview } from './sourcePreview';
import { mayFetchThumbnail, THUMBNAIL_MAX_BYTES } from './thumbnail';
Expand Down Expand Up @@ -64,7 +64,7 @@ export function App() {
const selected = files.includes(route.path ?? '') ? route.path : undefined;
const viewed = { ...detailResource, path: selected };
const showFiles = files.length > 1;
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>}{(detailResource.service === 'APP' || detailResource.service === 'WEBSITE') ? <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>{selected || t('viewer.source')}</h2>{selected ? <button className="file-clear" type="button" onClick={() => navigate({ ...route, path: undefined })}>{t('action.allFiles')}</button> : null}{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>{selected || t('viewer.source')}</h2>{selected ? <button className="file-clear" type="button" onClick={() => navigate({ ...route, path: undefined })}>{t('action.allFiles')}</button> : null}{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>{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>;
}
4 changes: 3 additions & 1 deletion src/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
import { dispatchOpen } from './dispatcher';
const resource = (service: string) => ({ service, name: 'Alice', identifier: 'one', path: 'file.txt' });
describe('open dispatcher', () => {
it.each(['APP', 'WEBSITE'])('routes %s to current QDN tab and optionally new tab', (service) => {
it.each(['APP', 'WEBSITE', 'GAME'])('routes %s to current QDN tab and optionally new tab', (service) => {
expect(dispatchOpen(resource(service)).action).toBe('OPEN_CURRENT_TAB');
expect(dispatchOpen(resource(service), { newTab: true }).action).toBe('OPEN_NEW_TAB');
});
it.each(['AUDIO', 'VOICE', 'PODCAST', 'VIDEO'])('routes %s to media player', (service) => expect(dispatchOpen(resource(service)).action).toBe('OPEN_QDN_MEDIA_PLAYER'));
it.each(['DOCUMENT', 'FILE', 'FILES', 'ATTACHMENT'])('routes %s to document viewer', (service) => expect(dispatchOpen(resource(service)).action).toBe('OPEN_QDN_DOCUMENT_VIEWER'));
it('keeps other services in Explore internal viewer', () => expect(dispatchOpen(resource('JSON')).action).toBe('INTERNAL_VIEWER'));
it('routes a lower-cased browser archive service too', () => expect(dispatchOpen(resource('game')).action).toBe('OPEN_CURRENT_TAB'));
it('addresses a GAME by its own service', () => expect(dispatchOpen(resource('GAME'))).toEqual({ action: 'OPEN_CURRENT_TAB', address: 'qdn://GAME/Alice/one' }));
});
3 changes: 2 additions & 1 deletion src/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isBrowserArchiveService } from './services';
import type { QdnResource } from './types';

export type OpenDispatch =
Expand All @@ -12,7 +13,7 @@ export function qdnUrl(resource: Pick<QdnResource, 'service' | 'name' | 'identif
}
export function dispatchOpen(resource: QdnResource, options: { newTab?: boolean; filename?: string; mimeType?: string } = {}): OpenDispatch {
const service = resource.service.toUpperCase();
if (service === 'APP' || service === 'WEBSITE') return { action: options.newTab ? 'OPEN_NEW_TAB' : 'OPEN_CURRENT_TAB', address: qdnUrl({ ...resource, service }) };
if (isBrowserArchiveService(service)) return { action: options.newTab ? 'OPEN_NEW_TAB' : 'OPEN_CURRENT_TAB', address: qdnUrl({ ...resource, service }) };
if (media.has(service)) return { action: 'OPEN_QDN_MEDIA_PLAYER', service, name: resource.name, identifier: resource.identifier, path: resource.path };
if (documents.has(service)) return { action: 'OPEN_QDN_DOCUMENT_VIEWER', service, name: resource.name, identifier: resource.identifier, path: resource.path, filename: options.filename, mimeType: options.mimeType };
return { action: 'INTERNAL_VIEWER' };
Expand Down
11 changes: 11 additions & 0 deletions src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ export const PUBLIC_QDN_SERVICES = [
export type PublicQdnService = (typeof PUBLIC_QDN_SERVICES)[number];
export const isPublicQdnService = (value: string): value is PublicQdnService =>
PUBLIC_QDN_SERVICES.includes(value as PublicQdnService);

// Services Home hosts as browser content instead of handing to a viewer or a
// download. Keep in parity with
// qortium-home/electron/qdn-browser-archive-services.ts — treating GAME
// differently from APP and WEBSITE is what left a published game downloadable
// but not openable.
export const BROWSER_ARCHIVE_SERVICES = ['APP', 'WEBSITE', 'GAME'] as const;

export type BrowserArchiveService = (typeof BROWSER_ARCHIVE_SERVICES)[number];
export const isBrowserArchiveService = (value: string): value is BrowserArchiveService =>
BROWSER_ARCHIVE_SERVICES.includes(value.toUpperCase() as BrowserArchiveService);