fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline

- scan: rows go down on a clock as well as on a full batch. The reader's folder
  of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
  the reading was over — a wall that stood empty for the whole scan and then
  filled in one go. Frames now reach the screen every 400ms while the roll is
  still being read, so the count climbs and the first tiles are drawn as the
  frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
  every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
  instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
  `recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
  so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
  one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
  document — a worker that precached only the document answered an offline
  launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
  resume after a reload, tiles of a long roll), offline-read-check,
  offline-queue-check.
This commit is contained in:
2026-10-09 12:02:05 +07:00
parent f834d10762
commit 45fc852d04
17 changed files with 2054 additions and 122 deletions
+16 -7
View File
@@ -25,6 +25,21 @@ interface I18nValue {
const Ctx = createContext<I18nValue | null>(null);
function translate(lang: Lang, key: MsgKey, params?: Record<string, string | number>): string {
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
if (params) {
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
}
return text;
}
// The same lookup, for the code outside React that has to write a line the
// screen will show — the held write in api.ts, which throws its sentence out of
// a catch block. It reads the language the way the provider does.
export function t(key: MsgKey, params?: Record<string, string | number>): string {
return translate(readLang(), key, params);
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(readLang);
@@ -40,13 +55,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const value = useMemo<I18nValue>(() => ({
lang,
setLang,
t: (key, params) => {
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
if (params) {
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
}
return text;
},
t: (key, params) => translate(lang, key, params),
}), [lang, setLang]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
+17
View File
@@ -249,6 +249,8 @@ export const en: Dict = {
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
'lib.update': 'UPDATE...',
'lib.rescan': 'RESCAN FROM THE TOP',
'lib.resume': 'KEEP READING {folder}',
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.',
@@ -322,6 +324,13 @@ export const en: Dict = {
'lib.restoreNothing': 'There is no catalogue of its own in {folder} — pick the folder a backup was written into.',
'lib.restoreFailed': 'The backup folder could not be read.',
// The repair (Library.tsx, admin only): every roll read back to the disk, so
// the catalogue and the disk say the same thing again.
'lib.repair': 'REPAIR DATA',
'lib.repairConfirm': 'Read all {n} folders from the top so the library matches the disk? Frames that are gone will be dropped from the catalogue.',
'lib.repairRun': 'Repairing the library ({n} folders)…',
'lib.repairDone': 'Library repaired: {n} folders read back.',
'nav.admin': 'Admin',
'nav.photos': 'My photos',
'nav.profile': 'Profile',
@@ -435,6 +444,14 @@ export const en: Dict = {
'err.generic': 'Something went wrong: {msg}',
'err.offline': 'Could not reach the server.',
// The offline bar (src/ui/OfflineBar.tsx).
'offline.notice': 'No server — showing the copy saved on this machine.',
// A write the server never got. It is held, not lost; the sentence rides
// `err.generic`'s `{msg}`, so it reads as the second half of that one, and the
// count is the bar's own line for the queue (`offline.pending`).
'offline.queued': 'the server is out of reach, so this change is saved here and sent when it is back.',
'offline.pending': 'Not sent yet: {n}',
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
};
+15
View File
@@ -261,6 +261,8 @@ export const vi = {
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
'lib.update': 'CẬP NHẬT...',
'lib.rescan': 'QUÉT LẠI TỪ ĐẦU',
'lib.resume': 'ĐỌC TIẾP {folder}',
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
'lib.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN',
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.',
@@ -335,6 +337,13 @@ export const vi = {
'lib.restoreNothing': 'Thư mục {folder} không có danh mục sao lưu nào — hãy chọn đúng thư mục đã ghi sao lưu.',
'lib.restoreFailed': 'Không đọc được thư mục sao lưu.',
// Sửa dữ liệu (Library.tsx, chỉ quản trị): đọc lại từng cuộn ảnh về đúng đĩa,
// để danh mục và đĩa nói cùng một điều.
'lib.repair': 'SỬA DỮ LIỆU',
'lib.repairConfirm': 'Đọc lại cả {n} thư mục từ đầu để thư viện khớp với đĩa? Ảnh đã mất sẽ bị gỡ khỏi danh mục.',
'lib.repairRun': 'Đang sửa dữ liệu thư viện ({n} thư mục)…',
'lib.repairDone': 'Đã sửa dữ liệu thư viện: đọc lại {n} thư mục.',
'adm.title': 'Quản trị dải phim',
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
'adm.upload': 'THÊM ẢNH',
@@ -445,6 +454,12 @@ export const vi = {
'err.generic': 'Có lỗi xảy ra: {msg}',
'err.offline': 'Không gọi được máy chủ.',
// The offline bar (src/ui/OfflineBar.tsx). Says both halves: the server is
// gone, and what is on the screen is the copy this machine kept.
'offline.notice': 'Mất kết nối máy chủ — đang xem bản đã lưu trên máy.',
'offline.queued': 'máy chủ không tới được, nên thay đổi này đã lưu trên máy và sẽ gửi khi kết nối lại.',
'offline.pending': 'Chưa gửi được: {n}',
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
} as const;