multiple groups per user
This commit is contained in:
+89
-24
@@ -1,10 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { parseLoginDeepLink, type Card } from '@cardy/shared';
|
||||
import { createApi } from './api.js';
|
||||
import { createApi, type GroupApi } from './api.js';
|
||||
import { useNavigation, type LoginPrefill } from './navigation.js';
|
||||
import { onBackButton, onDeepLink, onResumeAfter, runOverlayBack } from './platform.js';
|
||||
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
|
||||
import { refreshCardList, syncImages } from './sync.js';
|
||||
import {
|
||||
LEGACY_GROUP_ID,
|
||||
cardImageIds,
|
||||
currentGroup,
|
||||
images,
|
||||
sessionGroups,
|
||||
storage,
|
||||
type PinRecord,
|
||||
type Session,
|
||||
type UsageMap,
|
||||
} from './storage.js';
|
||||
import { refreshCardList, refreshGroups, syncImages } from './sync.js';
|
||||
import {
|
||||
applyPendingUpdate,
|
||||
checkForApkUpdate,
|
||||
@@ -32,9 +42,12 @@ interface Boot {
|
||||
export function App() {
|
||||
const [boot, setBoot] = useState<Boot>();
|
||||
useEffect(() => {
|
||||
void Promise.all([storage.getSession(), storage.getPin(), storage.getCards(), storage.getUsage()]).then(
|
||||
([session, pin, cards, usage]) => setBoot({ session, pin, cards, usage }),
|
||||
);
|
||||
void (async () => {
|
||||
const [session, pin] = await Promise.all([storage.getSession(), storage.getPin()]);
|
||||
const groupId = session ? currentGroup(session).id : LEGACY_GROUP_ID;
|
||||
const [cards, usage] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId)]);
|
||||
setBoot({ session, pin, cards, usage });
|
||||
})();
|
||||
}, []);
|
||||
if (!boot) return <div className="splash" />;
|
||||
return <Main boot={boot} justUpdated={JUST_UPDATED} />;
|
||||
@@ -64,25 +77,52 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
const route = stack[stack.length - 1];
|
||||
|
||||
const api = useMemo(() => (session ? createApi(session) : undefined), [session]);
|
||||
// Cards shown are those of the current group (switchable when the user has several).
|
||||
const groupId = session ? currentGroup(session).id : LEGACY_GROUP_ID;
|
||||
const groupApi = useMemo(() => api?.forGroup(groupId), [api, groupId]);
|
||||
const groupIdRef = useRef(groupId);
|
||||
groupIdRef.current = groupId;
|
||||
const sessionRef = useRef(session);
|
||||
sessionRef.current = session;
|
||||
|
||||
/** Shows the stored cards of a group. */
|
||||
const loadGroup = useCallback(async (id: string) => {
|
||||
const [list, u] = await Promise.all([storage.getCards(id), storage.getUsage(id)]);
|
||||
if (groupIdRef.current !== id) return;
|
||||
setCards(list);
|
||||
setUsage(u);
|
||||
}, []);
|
||||
|
||||
// ---- data ----
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!api) return;
|
||||
const current = sessionRef.current;
|
||||
if (!current) return;
|
||||
setSync({ busy: true });
|
||||
let list: Card[];
|
||||
let target: string;
|
||||
let targetApi: GroupApi;
|
||||
try {
|
||||
list = await refreshCardList(api);
|
||||
// Groups first: memberships may have changed (and legacy sessions get their groups here).
|
||||
const updated = await refreshGroups(createApi(current), current);
|
||||
target = currentGroup(updated).id;
|
||||
sessionRef.current = updated;
|
||||
groupIdRef.current = target;
|
||||
setSession(updated);
|
||||
targetApi = createApi(updated).forGroup(target);
|
||||
list = await refreshCardList(targetApi, target);
|
||||
} catch (e) {
|
||||
setSync({ busy: false, error: (e as Error).message });
|
||||
return;
|
||||
}
|
||||
if (groupIdRef.current !== target) return setSync({ busy: false });
|
||||
setCards(list);
|
||||
setUsage(await storage.getUsage());
|
||||
setUsage(await storage.getUsage(target));
|
||||
setSync({ busy: true, progress: { done: 0, total: 0 } });
|
||||
try {
|
||||
const result = await syncImages(
|
||||
api,
|
||||
targetApi,
|
||||
target,
|
||||
list,
|
||||
() => setImageEpoch((e) => e + 1),
|
||||
(done, total) => setSync({ busy: true, progress: { done, total } }),
|
||||
@@ -95,33 +135,54 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
} catch (e) {
|
||||
setSync({ busy: false, error: (e as Error).message });
|
||||
}
|
||||
}, [api]);
|
||||
}, []);
|
||||
|
||||
/** Applies a card returned by the server after add/edit. */
|
||||
/** Switches to another group of the user; a group never downloaded to this device is refreshed. */
|
||||
const switchGroup = useCallback(
|
||||
async (id: string) => {
|
||||
const current = sessionRef.current;
|
||||
if (!current || id === groupIdRef.current) return;
|
||||
const next = { ...current, currentGroupId: id };
|
||||
await storage.setSession(next);
|
||||
// Refs first: the refresh below must already see the new group (state updates land on the next render).
|
||||
sessionRef.current = next;
|
||||
groupIdRef.current = id;
|
||||
setSession(next);
|
||||
setSync({ busy: false });
|
||||
await loadGroup(id);
|
||||
reset([{ name: 'cards' }]);
|
||||
if (!(await storage.hasCards(id))) void refresh();
|
||||
},
|
||||
[loadGroup, reset, refresh],
|
||||
);
|
||||
|
||||
/** Applies a card returned by the server after add/edit (in the current group). */
|
||||
const saveCardLocally = useCallback(async (card: Card) => {
|
||||
const list = await storage.getCards();
|
||||
const gid = groupIdRef.current;
|
||||
const list = await storage.getCards(gid);
|
||||
const index = list.findIndex((c) => c.id === card.id);
|
||||
if (index >= 0) list[index] = card;
|
||||
else list.push(card);
|
||||
await storage.setCards(list);
|
||||
await storage.setCards(gid, list);
|
||||
// Images uploaded from this device are stored already; record the version only if nothing is missing.
|
||||
const stored = await images.storedKeys();
|
||||
if (cardImageIds(card).every((id) => stored.has(images.key(card.id, id)))) {
|
||||
await images.prune(card.id, cardImageIds(card));
|
||||
const versions = await storage.getVersions();
|
||||
const versions = await storage.getVersions(gid);
|
||||
versions[card.id] = card.imagesVersion;
|
||||
await storage.setVersions(versions);
|
||||
await storage.setVersions(gid, versions);
|
||||
}
|
||||
setCards(list);
|
||||
setImageEpoch((e) => e + 1);
|
||||
}, []);
|
||||
|
||||
const removeCardLocally = useCallback(async (id: string) => {
|
||||
const [list, u, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
|
||||
const gid = groupIdRef.current;
|
||||
const [list, u, versions] = await Promise.all([storage.getCards(gid), storage.getUsage(gid), storage.getVersions(gid)]);
|
||||
const next = list.filter((c) => c.id !== id);
|
||||
delete u[id];
|
||||
delete versions[id];
|
||||
await Promise.all([storage.setCards(next), storage.setUsage(u), storage.setVersions(versions), images.prune(id)]);
|
||||
await Promise.all([storage.setCards(gid, next), storage.setUsage(gid, u), storage.setVersions(gid, versions), images.prune(id)]);
|
||||
setCards(next);
|
||||
setUsage({ ...u });
|
||||
}, []);
|
||||
@@ -130,11 +191,12 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
const shownCardId = !locked && route?.name === 'card' ? route.id : undefined;
|
||||
useEffect(() => {
|
||||
if (!shownCardId) return;
|
||||
const gid = groupIdRef.current;
|
||||
void (async () => {
|
||||
const u = await storage.getUsage();
|
||||
const u = await storage.getUsage(gid);
|
||||
u[shownCardId] = new Date().toISOString();
|
||||
await storage.setUsage(u);
|
||||
setUsage({ ...u });
|
||||
await storage.setUsage(gid, u);
|
||||
if (groupIdRef.current === gid) setUsage({ ...u });
|
||||
})();
|
||||
}, [shownCardId, stack.length]);
|
||||
|
||||
@@ -245,7 +307,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
|
||||
// ---- screens ----
|
||||
|
||||
if (!session || !api) {
|
||||
if (!session || !api || !groupApi) {
|
||||
const prefill = route?.name === 'login' ? route.prefill : undefined;
|
||||
return <LoginScreen key={JSON.stringify(prefill ?? {})} prefill={prefill} onSignedIn={signedIn} />;
|
||||
}
|
||||
@@ -272,7 +334,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
onEdit={() => push({ name: 'edit', id: card.id })}
|
||||
onDelete={async () => {
|
||||
try {
|
||||
await api.deleteCard(card.id);
|
||||
await groupApi.deleteCard(card.id);
|
||||
} catch (e) {
|
||||
// Already deleted elsewhere: drop it locally as well.
|
||||
if ((e as { status?: number }).status !== 404) throw e;
|
||||
@@ -289,7 +351,7 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
<EditScreen
|
||||
key={route.id ?? 'new'}
|
||||
card={card}
|
||||
api={api}
|
||||
api={groupApi}
|
||||
onCancel={goBack}
|
||||
saveCardLocally={saveCardLocally}
|
||||
onSaved={(saved) => (route.id ? goBack() : replaceTop({ name: 'card', id: saved.id }))}
|
||||
@@ -305,6 +367,9 @@ function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
|
||||
imageEpoch={imageEpoch}
|
||||
sync={sync}
|
||||
session={session}
|
||||
groups={sessionGroups(session)}
|
||||
currentGroupId={groupId}
|
||||
onSwitchGroup={switchGroup}
|
||||
apkUpdate={apkUpdate}
|
||||
onDismissSync={() => setSync({ busy: false })}
|
||||
onRefresh={refresh}
|
||||
|
||||
+23
-12
@@ -1,5 +1,5 @@
|
||||
import type { ApiAuthResponse, Card } from '@cardy/shared';
|
||||
import type { Session } from './storage.js';
|
||||
import { LEGACY_GROUP_ID, type Session } from './storage.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -62,18 +62,29 @@ export function createApi(session: Session) {
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
|
||||
/** Card endpoints of one group; the legacy pseudo group uses the group-less routes (first group). */
|
||||
const forGroup = (groupId: string) => {
|
||||
const g = groupId === LEGACY_GROUP_ID ? '' : `/groups/${enc(groupId)}`;
|
||||
return {
|
||||
cards: () => json<Card[]>('GET', `${g}/cards`),
|
||||
createCard: (form: FormData) => json<Card>('POST', `${g}/cards`, form, UPLOAD_TIMEOUT),
|
||||
updateCard: (id: string, form: FormData) => json<Card>('PUT', `${g}/cards/${enc(id)}`, form, UPLOAD_TIMEOUT),
|
||||
deleteCard: async (id: string) => void (await send('DELETE', `${g}/cards/${enc(id)}`)),
|
||||
addImages: (id: string, form: FormData) => json<Card>('POST', `${g}/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT),
|
||||
removeImage: (id: string, imageId: string) => json<Card>('DELETE', `${g}/cards/${enc(id)}/images/${enc(imageId)}`),
|
||||
/** Logo when `imageId` is the card's logo id, otherwise one of its images. */
|
||||
file: async (card: Card, imageId: string) => {
|
||||
const path =
|
||||
card.logo?.id === imageId ? `${g}/cards/${enc(card.id)}/logo` : `${g}/cards/${enc(card.id)}/images/${enc(imageId)}`;
|
||||
return (await send('GET', path, undefined, UPLOAD_TIMEOUT)).blob();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
auth: () => json<ApiAuthResponse>('GET', '/auth'),
|
||||
cards: () => json<Card[]>('GET', '/cards'),
|
||||
createCard: (form: FormData) => json<Card>('POST', '/cards', form, UPLOAD_TIMEOUT),
|
||||
updateCard: (id: string, form: FormData) => json<Card>('PUT', `/cards/${enc(id)}`, form, UPLOAD_TIMEOUT),
|
||||
deleteCard: async (id: string) => void (await send('DELETE', `/cards/${enc(id)}`)),
|
||||
addImages: (id: string, form: FormData) => json<Card>('POST', `/cards/${enc(id)}/images`, form, UPLOAD_TIMEOUT),
|
||||
removeImage: (id: string, imageId: string) => json<Card>('DELETE', `/cards/${enc(id)}/images/${enc(imageId)}`),
|
||||
/** Logo when `imageId` is the card's logo id, otherwise one of its images. */
|
||||
file: async (card: Card, imageId: string) => {
|
||||
const path = card.logo?.id === imageId ? `/cards/${enc(card.id)}/logo` : `/cards/${enc(card.id)}/images/${enc(imageId)}`;
|
||||
return (await send('GET', path, undefined, UPLOAD_TIMEOUT)).blob();
|
||||
},
|
||||
forGroup,
|
||||
};
|
||||
}
|
||||
|
||||
export type GroupApi = ReturnType<Api['forGroup']>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
|
||||
import { cardMatchesQuery, textColorOn, type Card, type Group } from '@cardy/shared';
|
||||
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
|
||||
import type { Session, UsageMap } from '../storage.js';
|
||||
import type { PublishedApk } from '../updater.js';
|
||||
@@ -19,6 +19,9 @@ export function CardsScreen({
|
||||
imageEpoch,
|
||||
sync,
|
||||
session,
|
||||
groups,
|
||||
currentGroupId,
|
||||
onSwitchGroup,
|
||||
apkUpdate,
|
||||
onDismissSync,
|
||||
onRefresh,
|
||||
@@ -31,6 +34,10 @@ export function CardsScreen({
|
||||
imageEpoch: number;
|
||||
sync: SyncState;
|
||||
session: Session;
|
||||
/** The user's groups (one or more). */
|
||||
groups: Group[];
|
||||
currentGroupId: string;
|
||||
onSwitchGroup: (groupId: string) => void;
|
||||
apkUpdate?: PublishedApk;
|
||||
onDismissSync: () => void;
|
||||
onRefresh: () => void;
|
||||
@@ -40,15 +47,28 @@ export function CardsScreen({
|
||||
}) {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [confirmKey, setConfirmKey] = useState(false);
|
||||
const [pickingGroup, setPickingGroup] = useState(false);
|
||||
const filterInput = useRef<HTMLInputElement>(null);
|
||||
const group = groups.find((g) => g.id === currentGroupId) ?? groups[0]!;
|
||||
|
||||
// Search by name, tags and comment.
|
||||
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
|
||||
|
||||
// With several groups the title is the current group and switches between them.
|
||||
const title =
|
||||
groups.length > 1 ? (
|
||||
<button className="group-title" onClick={() => setPickingGroup(true)} disabled={sync.busy} aria-label={`Group ${group.name}, switch group`}>
|
||||
<span>{group.name}</span>
|
||||
<Icon name="chevron" size={18} />
|
||||
</button>
|
||||
) : (
|
||||
'Cards'
|
||||
);
|
||||
|
||||
return (
|
||||
<Screen
|
||||
className="cards-screen"
|
||||
title="Cards"
|
||||
title={title}
|
||||
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
|
||||
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
|
||||
>
|
||||
@@ -97,7 +117,7 @@ export function CardsScreen({
|
||||
{cards.length === 0 ? (
|
||||
<div className="empty">
|
||||
<p>No cards on this device yet.</p>
|
||||
<p className="muted">Tap refresh to download the cards of {session.groupName ? `“${session.groupName}”` : 'your group'}, or add a new one.</p>
|
||||
<p className="muted">Tap refresh to download the cards of “{group.name}”, or add a new one.</p>
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty muted">No cards match “{filter}”.</div>
|
||||
@@ -118,6 +138,30 @@ export function CardsScreen({
|
||||
<Icon name="plus" size={28} />
|
||||
</button>
|
||||
|
||||
{pickingGroup && (
|
||||
<div className="dialog-backdrop" onClick={(e) => e.target === e.currentTarget && setPickingGroup(false)}>
|
||||
<div className="dialog" role="dialog" aria-modal="true" aria-label="Choose a group">
|
||||
<h2>Group</h2>
|
||||
<ul className="group-list">
|
||||
{groups.map((g) => (
|
||||
<li key={g.id}>
|
||||
<button
|
||||
className={g.id === group.id ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
setPickingGroup(false);
|
||||
setFilter('');
|
||||
onSwitchGroup(g.id);
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmKey && (
|
||||
<ConfirmDialog
|
||||
title="Change access key?"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type CodeType,
|
||||
type ImageRef,
|
||||
} from '@cardy/shared';
|
||||
import { ApiError, type Api } from '../api.js';
|
||||
import { ApiError, type GroupApi } from '../api.js';
|
||||
import { CropScreen } from '../CropScreen.js';
|
||||
import { fileNameFor } from '../imageTools.js';
|
||||
import { ScannerView, scanImageFile } from '../ScannerView.js';
|
||||
@@ -42,7 +42,8 @@ export function EditScreen({
|
||||
saveCardLocally,
|
||||
}: {
|
||||
card?: Card;
|
||||
api: Api;
|
||||
/** Card endpoints of the current group. */
|
||||
api: GroupApi;
|
||||
onCancel: () => void;
|
||||
onSaved: (card: Card) => void;
|
||||
onMissing: (id: string) => void;
|
||||
|
||||
@@ -38,7 +38,9 @@ export function LoginScreen({
|
||||
setError(undefined);
|
||||
try {
|
||||
const me = await createApi({ server: normalized, key: trimmedKey }).auth();
|
||||
onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groupName: me.group.name });
|
||||
// `groups` comes from multi-group servers; older ones only report the single `group`.
|
||||
const groups = me.groups?.length ? me.groups : [me.group];
|
||||
onSignedIn({ server: normalized, key: trimmedKey, email: me.email, groups, currentGroupId: groups[0]!.id });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { clear, createStore, del, delMany, get, keys, set } from 'idb-keyval';
|
||||
import type { Card } from '@cardy/shared';
|
||||
import type { Card, Group } from '@cardy/shared';
|
||||
|
||||
export interface Session {
|
||||
/** Base URL of the client API, e.g. https://dev.aggtaa.com/api */
|
||||
@@ -8,9 +8,30 @@ export interface Session {
|
||||
key: string;
|
||||
/** From the server at sign-in, for display. */
|
||||
email?: string;
|
||||
/** The key owner's groups (one or more); missing in sessions from before multi-group. */
|
||||
groups?: Group[];
|
||||
/** Group whose cards are shown. */
|
||||
currentGroupId?: string;
|
||||
/** Sessions from before multi-group: name of their only group. */
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pseudo group of sessions from before multi-group: its data lives under the old storage keys and is
|
||||
* served by the group-less API routes (the owner's first group) until the first sync moves it.
|
||||
*/
|
||||
export const LEGACY_GROUP_ID = '';
|
||||
|
||||
/** Groups of the session; a legacy session has the single pseudo group. */
|
||||
export function sessionGroups(session: Session): Group[] {
|
||||
return session.groups?.length ? session.groups : [{ id: LEGACY_GROUP_ID, name: session.groupName ?? 'Cards' }];
|
||||
}
|
||||
|
||||
export function currentGroup(session: Session): Group {
|
||||
const groups = sessionGroups(session);
|
||||
return groups.find((g) => g.id === session.currentGroupId) ?? groups[0]!;
|
||||
}
|
||||
|
||||
export interface PinRecord {
|
||||
salt: string;
|
||||
hash: string;
|
||||
@@ -44,17 +65,39 @@ async function setJson(key: string, value: unknown): Promise<void> {
|
||||
else await Preferences.set({ key, value: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
/** Per-group key; the legacy pseudo group keeps the old unsuffixed keys. */
|
||||
const groupKey = (base: string, groupId: string) => (groupId === LEGACY_GROUP_ID ? base : `${base}:${groupId}`);
|
||||
|
||||
export const storage = {
|
||||
getSession: () => getJson<Session | null>(K.session, null),
|
||||
setSession: (s: Session | null) => setJson(K.session, s),
|
||||
getPin: () => getJson<PinRecord | null>(K.pin, null),
|
||||
setPin: (p: PinRecord | null) => setJson(K.pin, p),
|
||||
getCards: () => getJson<Card[]>(K.cards, []),
|
||||
setCards: (cards: Card[]) => setJson(K.cards, cards),
|
||||
getUsage: () => getJson<UsageMap>(K.usage, {}),
|
||||
setUsage: (u: UsageMap) => setJson(K.usage, u),
|
||||
getVersions: () => getJson<VersionMap>(K.versions, {}),
|
||||
setVersions: (v: VersionMap) => setJson(K.versions, v),
|
||||
|
||||
// Cards, usage and image versions are stored per group.
|
||||
getCards: (groupId: string) => getJson<Card[]>(groupKey(K.cards, groupId), []),
|
||||
/** Whether cards of the group were ever downloaded to this device. */
|
||||
hasCards: async (groupId: string) => (await Preferences.get({ key: groupKey(K.cards, groupId) })).value !== null,
|
||||
setCards: (groupId: string, cards: Card[]) => setJson(groupKey(K.cards, groupId), cards),
|
||||
getUsage: (groupId: string) => getJson<UsageMap>(groupKey(K.usage, groupId), {}),
|
||||
setUsage: (groupId: string, u: UsageMap) => setJson(groupKey(K.usage, groupId), u),
|
||||
getVersions: (groupId: string) => getJson<VersionMap>(groupKey(K.versions, groupId), {}),
|
||||
setVersions: (groupId: string, v: VersionMap) => setJson(groupKey(K.versions, groupId), v),
|
||||
|
||||
/** Moves the data of one group key to another (legacy pseudo group -> the real first group). */
|
||||
async moveGroup(from: string, to: string): Promise<void> {
|
||||
for (const base of [K.cards, K.usage, K.versions]) {
|
||||
const { value } = await Preferences.get({ key: groupKey(base, from) });
|
||||
if (value !== null) await Preferences.set({ key: groupKey(base, to), value });
|
||||
await Preferences.remove({ key: groupKey(base, from) });
|
||||
}
|
||||
},
|
||||
|
||||
/** Forgets a group's cards (with their images), usage and image versions. */
|
||||
async removeGroup(groupId: string): Promise<void> {
|
||||
for (const card of await storage.getCards(groupId)) await images.prune(card.id);
|
||||
for (const base of [K.cards, K.usage, K.versions]) await Preferences.remove({ key: groupKey(base, groupId) });
|
||||
},
|
||||
|
||||
/** Forgets everything: used when signing in with a new key. */
|
||||
async clearAll(): Promise<void> {
|
||||
|
||||
@@ -997,6 +997,55 @@ textarea:focus {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Group switcher in the card list title. */
|
||||
.group-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-title span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-title:active:not(:disabled) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.group-list {
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-list button {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-list button.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
+34
-11
@@ -1,6 +1,6 @@
|
||||
import type { Card } from '@cardy/shared';
|
||||
import type { Api } from './api.js';
|
||||
import { cardImageIds, images, storage, type UsageMap, type VersionMap } from './storage.js';
|
||||
import type { Api, GroupApi } from './api.js';
|
||||
import { LEGACY_GROUP_ID, cardImageIds, images, storage, type Session, type UsageMap, type VersionMap } from './storage.js';
|
||||
|
||||
export const MAX_PARALLEL_DOWNLOADS = 5;
|
||||
|
||||
@@ -31,13 +31,35 @@ export function cardsNeedingImages(cards: Card[], versions: VersionMap, stored:
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the list of cards and replaces the local list (only when the download succeeded).
|
||||
* Re-reads the key owner's groups from the server and returns the updated session:
|
||||
* - a legacy session (from before multi-group) gets its groups, and its local data moves to the first
|
||||
* group (the one the group-less routes served);
|
||||
* - local data of groups the user no longer belongs to is removed;
|
||||
* - the current group falls back to the first group when it is gone.
|
||||
*/
|
||||
export async function refreshGroups(api: Api, session: Session): Promise<Session> {
|
||||
const { groups } = await api.auth();
|
||||
const firstId = groups[0]!.id;
|
||||
if (!session.groups?.length) {
|
||||
await storage.moveGroup(LEGACY_GROUP_ID, firstId);
|
||||
} else {
|
||||
for (const old of session.groups) if (!groups.some((g) => g.id === old.id)) await storage.removeGroup(old.id);
|
||||
}
|
||||
const current = groups.some((g) => g.id === session.currentGroupId) ? session.currentGroupId : firstId;
|
||||
const { groupName: _legacy, ...rest } = session;
|
||||
const next: Session = { ...rest, groups, currentGroupId: current };
|
||||
await storage.setSession(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the group's list of cards and replaces the local list (only when the download succeeded).
|
||||
* Cards gone from the server are removed locally with their images and usage.
|
||||
*/
|
||||
export async function refreshCardList(api: Api): Promise<Card[]> {
|
||||
export async function refreshCardList(api: GroupApi, groupId: string): Promise<Card[]> {
|
||||
const fresh = await api.cards();
|
||||
const [old, usage, versions] = await Promise.all([storage.getCards(), storage.getUsage(), storage.getVersions()]);
|
||||
await storage.setCards(fresh);
|
||||
const [old, usage, versions] = await Promise.all([storage.getCards(groupId), storage.getUsage(groupId), storage.getVersions(groupId)]);
|
||||
await storage.setCards(groupId, fresh);
|
||||
|
||||
const freshIds = new Set(fresh.map((c) => c.id));
|
||||
const gone = old.filter((c) => !freshIds.has(c.id));
|
||||
@@ -47,7 +69,7 @@ export async function refreshCardList(api: Api): Promise<Card[]> {
|
||||
delete versions[c.id];
|
||||
await images.prune(c.id);
|
||||
}
|
||||
await Promise.all([storage.setUsage(usage), storage.setVersions(versions)]);
|
||||
await Promise.all([storage.setUsage(groupId, usage), storage.setVersions(groupId, versions)]);
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
@@ -62,13 +84,14 @@ export interface ImageSyncResult {
|
||||
* A card's version is recorded only after all its files are stored, so failures are retried next time.
|
||||
*/
|
||||
export async function syncImages(
|
||||
api: Api,
|
||||
api: GroupApi,
|
||||
groupId: string,
|
||||
cards: Card[],
|
||||
onCardDone?: (card: Card) => void,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<ImageSyncResult> {
|
||||
const stored = await images.storedKeys();
|
||||
const changed = cardsNeedingImages(cards, await storage.getVersions(), stored);
|
||||
const changed = cardsNeedingImages(cards, await storage.getVersions(groupId), stored);
|
||||
const tasks = changed.flatMap((card) =>
|
||||
cardImageIds(card)
|
||||
.filter((id) => !stored.has(images.key(card.id, id)))
|
||||
@@ -85,9 +108,9 @@ export async function syncImages(
|
||||
if (failedCards.has(card.id)) return versionWrites;
|
||||
versionWrites = versionWrites.then(async () => {
|
||||
await images.prune(card.id, cardImageIds(card));
|
||||
const versions = await storage.getVersions();
|
||||
const versions = await storage.getVersions(groupId);
|
||||
versions[card.id] = card.imagesVersion;
|
||||
await storage.setVersions(versions);
|
||||
await storage.setVersions(groupId, versions);
|
||||
onCardDone?.(card);
|
||||
});
|
||||
return versionWrites;
|
||||
|
||||
@@ -24,6 +24,7 @@ const paths = {
|
||||
key: 'M14 10a4 4 0 1 0-3.5 3.97L10 14v3H8v2H5v-3l5.03-5.03A4 4 0 0 0 14 10zM15.5 8.5h.01',
|
||||
backspace: 'M21 5H8l-6 7 6 7h13zM11 9l6 6M17 9l-6 6',
|
||||
rotate: 'M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7',
|
||||
chevron: 'M6 9l6 6 6-6',
|
||||
} as const;
|
||||
|
||||
export function Icon({ name, size = 22 }: { name: keyof typeof paths; size?: number }) {
|
||||
|
||||
Reference in New Issue
Block a user