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:
+241
-80
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user