web: browse the catalogue as a tree of folders

The library page was a flat wall of thumbnails. It now reads like the admin
pictures tab: a folder tree on the left, the picked node's frame on the stage,
that node's frames in the strip below, and a chip pair to swap the stage for a
wall of every thumbnail in the node.

Folders are walked recursively (dot/@-prefixed names skipped, an unreadable
subfolder is dropped rather than failing the scan), so a roll with a dated
subfolder keeps both levels. Node counts include the subfolders underneath.
This commit is contained in:
2026-09-28 18:15:14 +07:00
parent 7ba1e64e7c
commit d1abda141a
5 changed files with 339 additions and 115 deletions
+54 -18
View File
@@ -90,7 +90,7 @@ const context = await browser.newContext();
// The folder the picker would have returned, plus the revival of its handles on
// the way back out of IndexedDB.
await context.addInitScript(
({ origin, names, folder }) => {
({ origin, names, subs, folder }) => {
const bytes = new Map();
const load = async (name) => {
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
@@ -103,20 +103,29 @@ await context.addInitScript(
});
return handle;
};
const dirHandle = () => {
const handle = { kind: 'directory', name: folder };
// The picked folder holds `names` at its top level and the subfolders in
// `subs` — a roll really is a folder of dated directories, which is what the
// tree column walks.
const dirHandle = (label, files, dirs) => {
const handle = { kind: 'directory', name: label };
Object.defineProperties(handle, {
values: { value: () => names.map(fileHandle)[Symbol.iterator]() },
values: {
value: () =>
[...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][
Symbol.iterator
](),
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return handle;
};
window.showDirectoryPicker = async () => dirHandle();
const root = () => dirHandle(folder, names, subs);
window.showDirectoryPicker = async () => root();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? dirHandle() : fileHandle(row.handle.name);
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
}
return row;
};
@@ -136,7 +145,7 @@ await context.addInitScript(
};
}
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG, RAW], folder: 'CheckRoll' }
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], subs: { 2026: [RAW] }, folder: 'CheckRoll' }
);
const page = await context.newPage();
@@ -169,10 +178,10 @@ const catalogue = () =>
// --- the run ----------------------------------------------------------------
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
const tiles = '[data-key^="lib-photo-"]';
await page.waitForSelector(tiles, { timeout: 120_000 });
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, tiles, { timeout: 120_000 });
step('both frames indexed as tiles', true, `2 tiles from ${JPG} + ${RAW}`);
const thumbs = '[data-key^="lib-thumb-"]';
await page.waitForSelector(thumbs, { timeout: 120_000 });
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 120_000 });
step('both frames indexed as thumbnails', true, `2 tiles from ${JPG} + ${RAW}`);
const indexed = await catalogue();
for (const name of [JPG, RAW]) {
@@ -180,11 +189,37 @@ for (const name of [JPG, RAW]) {
step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`);
}
// One tap on a tile hands the frame to the studio. The RAW is the harder half —
// it has to be read back off the (stand-in) handle and developed — so it is the
// one that gets opened.
// The tree: the picked folder, with the subfolder a frame sits in under it, each
// carrying the frames at or below it. The RAW is the nested one.
const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) =>
els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent])
);
step(
'tree lists the folder and its subfolder',
nodes.length === 2 && nodes[0][0] === 'lib-node-CheckRoll' && nodes[1][0] === 'lib-node-CheckRoll/2026',
JSON.stringify(nodes)
);
// Opening the subfolder redraws the strip and the frame above it.
await page.click('[data-key="lib-node-CheckRoll/2026"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 30_000 });
const staged = await page.locator('[data-key="lib-stage-meta"]').innerText();
step('the open subfolder draws its own strip and frame', /P1010256\.RW2/.test(staged), staged.replace(/\n/g, ' · '));
// The switch: the middle becomes every thumbnail of the open node at once.
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 });
const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key));
step('the grid view draws a card per frame', cards.length === 1, JSON.stringify(cards));
await page.click('[data-key="lib-view-preview"]');
await page.waitForSelector(thumbs, { timeout: 30_000 });
// One tap on a thumbnail raises the frame, and the button under it hands it to
// the studio. The RAW is the harder half — it has to be read back off the
// (stand-in) handle and developed — so it is the one that gets opened.
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
await page.click(`[data-key="lib-open-${rawId}"]`);
await page.click(`[data-key="lib-thumb-${rawId}"]`);
await page.click('[data-key="lib-open"]');
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
step('studio developed the frame from its handle', true, 'stage no longer on the drop zone');
@@ -215,9 +250,10 @@ for (let i = 0; i < 40 && !filed.length; i++) {
step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed));
await page.goto(`${base}/library`);
await page.waitForSelector(`[data-key="lib-photo-${rawId}"]`);
const meta = await page.locator(`[data-key="lib-photo-${rawId}"] .adm-meta`).innerText();
step('the tile says the frame is edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
await page.waitForSelector(`[data-key="lib-thumb-${rawId}"]`);
await page.click(`[data-key="lib-thumb-${rawId}"]`);
const meta = await page.locator('[data-key="lib-stage-meta"]').innerText();
step('the frame says it was edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
await browser.close();
sampleServer.close();
+241 -80
View File
@@ -16,21 +16,35 @@ import {
type ScanProgress,
} from './engine/library';
// The catalogue screen: folders from the visitor's own disk, a tile per frame,
// and a recipe remembered per frame. Nothing is uploaded — the page never holds
// more than the thumbnails, and the RAW itself is read only when a frame is
// opened in the studio (`/app?lib=<id>`).
// The catalogue screen, laid out like the admin's picture manager: the tree of
// folders and subfolders down the left, the frame that is up in the middle, and
// the open folder's strip of thumbnails across the bottom. The switch beside the
// tree title swaps the middle for every thumbnail of the open folder at once.
// Nothing is uploaded — the page never holds more than thumbnails, and the RAW
// itself is read only when a frame is opened in the studio (`/app?lib=<id>`).
//
// ponytail: no rating, no flags, no collections, no search. IndexedDB already
// holds the fields a few of those would need (folder, taken); add them once the
// catalogue is big enough that scrolling it stops working.
// ponytail: the tree does not collapse, and there is no search, rating or flag.
// A roll of date folders is three levels deep at most; add a collapsing node or
// a filter once the column stops being scrollable in practice.
interface Node {
// The tree's own key: a folder name, or `folder/sub/dir`.
key: string;
label: string;
depth: number;
count: number;
}
export function Library() {
const { t } = useI18n();
const [folders, setFolders] = useState<LibraryFolder[]>([]);
const [ready, setReady] = useState<Set<string>>(new Set());
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
const [edited, setEdited] = useState<Set<string>>(new Set());
const [pick, setPick] = useState<string | null>(null);
// Which tree node is open, which frame is up, and which of the two middle
// views (the frame, or every thumbnail of the open node) is on.
const [node, setNode] = useState<string | null>(null);
const [up, setUp] = useState<string | null>(null);
const [view, setView] = useState<'preview' | 'grid'>('preview');
const [progress, setProgress] = useState<ScanProgress | null>(null);
const [note, setNote] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -51,6 +65,7 @@ export function Library() {
const rows = await listFolders();
if (!alive) return;
setFolders(rows);
setNode((prev) => prev ?? rows[0]?.name ?? null);
const ok = new Set<string>();
for (const folder of rows) {
if (await ensurePermission(folder.handle)) ok.add(folder.name);
@@ -90,7 +105,7 @@ export function Library() {
if (!folder) return;
setFolders((prev) => [...prev.filter((f) => f.name !== folder.name), folder].sort((a, b) => a.name.localeCompare(b.name)));
setReady((prev) => new Set(prev).add(folder.name));
setPick(folder.name);
setNode(folder.name);
await scan(folder);
} catch (err) {
// Closing the picker is a "not now", not a failure worth a line of text.
@@ -114,11 +129,12 @@ export function Library() {
const drop = useCallback(
async (folder: LibraryFolder) => {
await removeFolder(folder.name);
setFolders((prev) => prev.filter((f) => f.name !== folder.name));
if (pick === folder.name) setPick(null);
const rest = folders.filter((f) => f.name !== folder.name);
setFolders(rest);
setNode(rest[0]?.name ?? null);
await reload();
},
[pick, reload]
[folders, reload]
);
// The frame goes to the studio through the address bar: the studio owns the
@@ -137,9 +153,69 @@ export function Library() {
window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`;
}, [t]);
const shown = useMemo(() => (pick ? photos.filter((p) => p.folder === pick) : photos), [photos, pick]);
const rootName = node ? node.split('/')[0] : null;
const selected = folders.find((f) => f.name === rootName) ?? null;
// The tree: one row per picked folder, then one per subfolder a frame sits in,
// indented by its depth, each carrying how many frames it holds at or under it.
const nodes = useMemo<Node[]>(() => {
const hits = new Map<string, number>();
const dirs = new Map<string, Set<string>>();
for (const photo of photos) {
hits.set(photo.folder, (hits.get(photo.folder) ?? 0) + 1);
if (!photo.dir) continue;
const set = dirs.get(photo.folder) ?? new Set<string>();
dirs.set(photo.folder, set);
let path = '';
for (const part of photo.dir.split('/')) {
path = path ? `${path}/${part}` : part;
set.add(path);
hits.set(`${photo.folder}/${path}`, (hits.get(`${photo.folder}/${path}`) ?? 0) + 1);
}
}
const out: Node[] = [];
for (const folder of folders) {
out.push({ key: folder.name, label: folder.name, depth: 0, count: hits.get(folder.name) ?? 0 });
// Path order puts a folder before its own children, which is the order the
// indented rows have to come in.
const own = [...(dirs.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b));
for (const dir of own) {
const parts = dir.split('/');
out.push({
key: `${folder.name}/${dir}`,
label: parts[parts.length - 1],
depth: parts.length,
count: hits.get(`${folder.name}/${dir}`) ?? 0,
});
}
}
return out;
}, [folders, photos]);
// What the open node draws: its own frames, and the ones in every subfolder
// under it — a roll's top level usually holds no file of its own.
const shown = useMemo(() => {
if (!rootName) return photos;
const dir = node === rootName ? '' : (node ?? '').slice(rootName.length + 1);
return photos.filter((p) => p.folder === rootName && (dir === '' || p.dir === dir || p.dir.startsWith(`${dir}/`)));
}, [node, photos, rootName]);
const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null;
const urls = useThumbUrls(shown);
// The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose
// preview the camera never wrote. A tile only carries a handler where a click
// on the picture means "open it" — in the strip the click belongs to the frame
// it raises, not to the studio.
const tile = (photo: LibraryPhoto, onPick?: () => void) =>
urls[photo.id] ? (
<img src={urls[photo.id]} alt="" loading="lazy" onClick={onPick} title={onPick ? t('lib.open') : undefined} />
) : (
<div className="lib-noimg" onClick={onPick} title={onPick ? t('lib.open') : undefined}>
RAW
</div>
);
return (
<div className="adm">
<header className="adm-bar">
@@ -179,82 +255,167 @@ export function Library() {
{folders.length === 0 ? <p className="hint adm-note">{t('lib.noFolders')}</p> : null}
<div className="chip-row" style={{ marginBottom: 12 }}>
{folders.length > 1 ? (
<button
type="button"
className={pick === null ? 'chip on' : 'chip'}
data-key="lib-folder-all"
onClick={() => setPick(null)}
>
{t('lib.all')}
</button>
) : null}
{folders.map((folder) => (
<span key={folder.name} className="chip-row">
{folders.length > 0 ? (
<div className="adm-col">
<h3 className="adm-row-title">
{t('lib.tree')}
<span className="adm-row-count">{shown.length}</span>
<span className="adm-spacer" />
{/* One folder, two ways to look at it: the frame on its own,
or the whole node as a wall of thumbnails. */}
<button
type="button"
className={pick === folder.name ? 'chip on' : 'chip'}
data-key={`lib-folder-${folder.name}`}
onClick={() => setPick(folder.name)}
className={`chip${view === 'preview' ? ' on' : ''}`}
data-key="lib-view-preview"
aria-pressed={view === 'preview'}
onClick={() => setView('preview')}
>
{folder.name}
{t('lib.viewPreview')}
</button>
{ready.has(folder.name) ? (
<>
<button
type="button"
className={`chip${view === 'grid' ? ' on' : ''}`}
data-key="lib-view-grid"
aria-pressed={view === 'grid'}
onClick={() => setView('grid')}
>
{t('lib.viewGrid')}
</button>
</h3>
{/* What can be done to the folder the open node belongs to: a
scan again, forget it, or hand back the permission it lost. */}
{selected ? (
<div className="chip-row">
{ready.has(selected.name) ? (
<>
<button
type="button"
className="btn ghost"
data-key={`lib-rescan-${selected.name}`}
disabled={busy}
onClick={() => void scan(selected)}
>
{t('lib.rescan')}
</button>
<button
type="button"
className="btn ghost"
data-key={`lib-drop-${selected.name}`}
onClick={() => void drop(selected)}
>
{t('lib.remove')}
</button>
</>
) : (
<button
type="button"
className="btn ghost"
data-key={`lib-rescan-${folder.name}`}
className="btn"
data-key={`lib-reconnect-${selected.name}`}
disabled={busy}
onClick={() => void scan(folder)}
onClick={() => void reconnect(selected)}
>
{t('lib.rescan')}
{t('lib.reconnect')}
</button>
<button type="button" className="btn ghost" data-key={`lib-drop-${folder.name}`} onClick={() => void drop(folder)}>
{t('lib.remove')}
</button>
</>
) : (
<button
type="button"
className="btn"
data-key={`lib-reconnect-${folder.name}`}
disabled={busy}
onClick={() => void reconnect(folder)}
>
{t('lib.reconnect')}
</button>
)}
</span>
))}
</div>
{shown.length === 0 && !busy ? <p className="hint adm-note">{t('lib.empty')}</p> : null}
<div className="adm-grid">
{shown.map((photo) => (
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
{urls[photo.id] ? (
<img src={urls[photo.id]} alt="" loading="lazy" onClick={() => void open(photo)} title={t('lib.open')} />
) : (
<div className="lib-noimg" onClick={() => void open(photo)} title={t('lib.open')}>
RAW
</div>
)}
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
)}
</div>
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
{t('lib.open')}
</button>
</article>
))}
</div>
) : null}
<div className="adm-split">
<aside className="adm-album-list" data-key="lib-tree">
{nodes.map((n) => (
<button
key={n.key}
type="button"
className={`adm-album${node === n.key ? ' on' : ''}`}
data-key={`lib-node-${n.key}`}
aria-current={node === n.key}
style={{ paddingLeft: 9 + n.depth * 14 }}
onClick={() => setNode(n.key)}
>
<span className="adm-album-name">{n.label}</span>
<span className="adm-album-count">{n.count}</span>
</button>
))}
</aside>
<section className="adm-stage" data-key="lib-stage">
{view === 'grid' ? (
<div className="adm-grid">
{shown.map((photo) => (
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
{tile(photo, () => void open(photo))}
<div className="adm-meta">
<b>{photo.name}</b>
<span>
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
{t('lib.open')}
</button>
</article>
))}
</div>
) : frame ? (
<>
{urls[frame.id] ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
src={urls[frame.id]}
alt=""
onClick={() => void open(frame)}
title={t('lib.open')}
/>
) : (
<div className="lib-noimg" onClick={() => void open(frame)} title={t('lib.open')}>
RAW
</div>
)}
<div className="adm-meta" data-key="lib-stage-meta">
<b>{frame.name}</b>
<span>{frame.dir || node}</span>
<span>
{new Date(frame.taken).toLocaleString()} · {t('lib.size', { mb: (frame.size / 1048576).toFixed(1) })}
{edited.has(frame.id) ? ` · ${t('lib.edited')}` : ''}
</span>
</div>
<div className="chip-row">
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
{t('lib.open')}
</button>
</div>
</>
) : (
<p className="hint adm-row-empty">{t('lib.empty')}</p>
)}
</section>
</div>
{/* The strip: one thumbnail per frame the open node holds, in the
order the catalogue reads them (newest shutter first). */}
{view === 'preview' ? (
<div className="adm-filmstrip" data-key="lib-strip">
{shown.map((photo) => (
<button
key={photo.id}
type="button"
className={`adm-thumb${frame?.id === photo.id ? ' on' : ''}`}
data-key={`lib-thumb-${photo.id}`}
title={photo.name}
onClick={() => setUp(photo.id)}
>
{tile(photo)}
<span className="adm-thumb-cap">{photo.name}</span>
</button>
))}
{shown.length === 0 ? <p className="hint adm-row-empty">{t('lib.empty')}</p> : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
) : (
@@ -266,7 +427,7 @@ export function Library() {
// One object URL per tile, made when the row does and dropped with it: a folder
// of thousands cannot hold thousands of live URLs, and `shown` is the only list
// the grid paints.
// the panels paint.
function useThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
const [urls, setUrls] = useState<Record<string, string>>({});
useEffect(() => {
+34 -11
View File
@@ -35,10 +35,13 @@ export interface LibraryFolder {
}
export interface LibraryPhoto {
// `${folder}/${name}` — one frame appears once in the catalogue, under the
// folder it was found in.
// `${folder}/${dir}/${name}` — one frame appears once in the catalogue, under
// the folder it was found in.
id: string;
folder: string;
// The subfolder it sits in, relative to the picked folder — '' at the root.
// This is what the tree column reads.
dir: string;
name: string;
handle: FileSystemFileHandle;
thumb: Blob | null;
@@ -221,6 +224,29 @@ export interface ScanProgress {
added: number;
}
// The walk goes all the way down: a picked folder is usually a roll of
// date-named subfolders, and a catalogue that only saw its top level would be
// empty. Names beginning with `.` or `@` are skipped — that is `.thumbnails`,
// `.git`, and the `@eaDir` a Synology writes beside every frame.
async function collectFiles(
dir: FileSystemDirectoryHandle,
prefix: string,
out: { handle: FileSystemFileHandle; rel: string }[]
): Promise<void> {
for await (const entry of dir.values()) {
if (/^[.@]/.test(entry.name)) continue;
if (entry.kind === 'directory') {
try {
await collectFiles(entry as FileSystemDirectoryHandle, `${prefix}${entry.name}/`, out);
} catch {
// A subfolder that will not open is skipped; the rest of the roll stands.
}
} else if (isSupportedPhoto(entry.name)) {
out.push({ handle: entry as FileSystemFileHandle, rel: `${prefix}${entry.name}` });
}
}
}
// Walk the folder, keep what is new or changed, and leave the rest alone: a
// second scan of a 2000-file folder only reads the frames that moved.
//
@@ -234,13 +260,8 @@ export async function scanFolder(
shouldStop?: () => boolean
): Promise<ScanProgress> {
const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p]));
const entries: FileSystemFileHandle[] = [];
for await (const entry of folder.handle.values()) {
// `values()` is typed as the base handle, and only a file handle has the
// getFile this walk needs.
const file = entry as FileSystemFileHandle;
if (entry.kind === 'file' && isSupportedPhoto(file.name)) entries.push(file);
}
const entries: { handle: FileSystemFileHandle; rel: string }[] = [];
await collectFiles(folder.handle, '', entries);
const progress: ScanProgress = { folder: folder.name, total: entries.length, done: 0, added: 0 };
let batch: LibraryPhoto[] = [];
// One transaction per batch, a put per row: a store with `keyPath: 'id'` takes
@@ -258,12 +279,12 @@ export async function scanFolder(
tx.onerror = () => reject(tx.error);
});
};
for (const handle of entries) {
for (const { handle, rel } of entries) {
if (shouldStop?.()) break;
progress.done++;
try {
const file = await handle.getFile();
const id = photoId(folder.name, handle.name);
const id = photoId(folder.name, rel);
const seen = known.get(id);
if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) {
onProgress?.(progress);
@@ -271,9 +292,11 @@ export async function scanFolder(
}
const bytes = new Uint8Array(await file.arrayBuffer());
const [thumb, taken] = [await makeThumbnail(file, bytes), (await readCapturedAt(bytes)) ?? file.lastModified];
const cut = rel.lastIndexOf('/');
batch.push({
id,
folder: folder.name,
dir: cut < 0 ? '' : rel.slice(0, cut),
name: handle.name,
handle,
thumb,
+5 -3
View File
@@ -225,7 +225,7 @@ export const en: Dict = {
'photos.failed': 'That did not work: {msg}',
'photos.noLabels': 'No strip labels yet',
'lib.title': 'Library',
'lib.title': 'LIBRARY',
'lib.hint':
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
'lib.add': 'ADD FOLDER',
@@ -233,7 +233,9 @@ export const en: Dict = {
'lib.rescan': 'RESCAN',
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.all': 'ALL',
'lib.tree': 'FOLDERS',
'lib.viewPreview': 'SINGLE',
'lib.viewGrid': 'ALL THUMBS',
'lib.open': 'Open in the studio',
'lib.edited': 'edited',
'lib.size': '{mb} MB',
@@ -250,7 +252,7 @@ export const en: Dict = {
'nav.admin': 'Admin',
'nav.photos': 'My photos',
'nav.profile': 'Profile',
'nav.library': 'Library',
'nav.library': 'LIBRARY',
'nav.studio': 'Studio',
'adm.title': 'Strip moderation',
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
+5 -3
View File
@@ -17,7 +17,7 @@ export const vi = {
'nav.admin': 'Quản trị',
'nav.photos': 'Ảnh của tôi',
'nav.profile': 'Hồ sơ',
'nav.library': 'Thư viện',
'nav.library': 'THƯ VIỆN',
'nav.studio': 'Studio',
'nav.guest': 'Khách',
@@ -238,7 +238,7 @@ export const vi = {
'photos.failed': 'Không thực hiện được: {msg}',
'photos.noLabels': 'Chưa có nhãn dải phim',
'lib.title': 'Thư viện',
'lib.title': 'THƯ VIỆN',
'lib.hint':
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
'lib.add': 'THÊM THƯ MỤC',
@@ -246,7 +246,9 @@ export const vi = {
'lib.rescan': 'QUÉT LẠI',
'lib.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN',
'lib.all': 'TẤT CẢ',
'lib.tree': 'THƯ MỤC',
'lib.viewPreview': 'MỘT ẢNH',
'lib.viewGrid': 'TOÀN BỘ',
'lib.open': 'Mở trong studio',
'lib.edited': 'đã chỉnh',
'lib.size': '{mb} MB',