multiple groups per user

This commit is contained in:
azykov@mail.ru
2026-09-30 09:06:10 +03:00
parent 45158cde38
commit c197e5978a
21 changed files with 749 additions and 277 deletions
+89 -24
View File
@@ -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
View File
@@ -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']>;
+47 -3
View File
@@ -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?"
+3 -2
View File
@@ -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;
+3 -1
View File
@@ -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);
+50 -7
View File
@@ -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> {
+49
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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 }) {