mosage 0.2.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -15
- package/bin.js +2 -0
- package/dist/build-C7NW_3Pk.js +14 -0
- package/dist/check-CP4873Wx.js +41 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +228 -0
- package/dist/config-DPm1BBAb.js +2619 -0
- package/dist/config-TlTe7Ona.d.ts +24 -0
- package/dist/context-BqsdSrAQ.js +1084 -0
- package/dist/dev-Biz42qlu.js +17 -0
- package/dist/diagram-xlVDekYk.js +763 -0
- package/dist/export-Bi6nuxjT.js +31 -0
- package/dist/import-D2jNB07F.js +25 -0
- package/dist/index.d.ts +455 -0
- package/dist/index.js +693 -0
- package/dist/{cli.js → init-Bbtj2pxF.js} +34 -68
- package/dist/preview-CLm51aRt.js +19 -0
- package/dist/sdk-DjpX6mCv.js +51 -0
- package/dist/vite/index.d.ts +25 -0
- package/dist/vite/index.js +2 -0
- package/env.d.ts +83 -0
- package/package.json +59 -12
- package/{template/.agents/skills → skills}/create-doc/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/create-theme/SKILL.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/SKILL.md +8 -9
- package/{template/.agents/skills → skills}/doc-authoring/references/assets.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/design-system.md +1 -1
- package/{template/.agents/skills → skills}/doc-authoring/references/long-form.md +4 -4
- package/{template/.agents/skills → skills}/doc-authoring/references/pagination.md +1 -1
- package/src/app/app.tsx +42 -0
- package/src/app/components/data-table.tsx +196 -0
- package/src/app/components/design-panel/design-panel.tsx +318 -0
- package/src/app/components/design-panel/design-provider.tsx +121 -0
- package/src/app/components/design-panel/use-design.ts +85 -0
- package/src/app/components/diagram.tsx +76 -0
- package/src/app/components/doc-assets.tsx +129 -0
- package/src/app/components/doc-search.tsx +248 -0
- package/src/app/components/doc-sidebar.tsx +162 -0
- package/src/app/components/flow-page.tsx +93 -0
- package/src/app/components/footnote.tsx +204 -0
- package/src/app/components/image-placeholder.tsx +50 -0
- package/src/app/components/inspector/inspector.tsx +518 -0
- package/src/app/components/numbering.tsx +224 -0
- package/src/app/components/page-frame.tsx +70 -0
- package/src/app/components/sidebar/folder-item.tsx +212 -0
- package/src/app/components/sidebar/icon-picker.tsx +99 -0
- package/src/app/components/sidebar/sidebar.tsx +252 -0
- package/src/app/components/table-of-contents.tsx +93 -0
- package/src/app/components/theme-toggle.tsx +50 -0
- package/src/app/components/themes/markdown.tsx +249 -0
- package/src/app/components/themes/theme-preview.tsx +74 -0
- package/src/app/components/ui/menu.tsx +143 -0
- package/src/app/index.html +12 -0
- package/src/app/lib/agent-bridge.ts +140 -0
- package/src/app/lib/assets.ts +151 -0
- package/src/app/lib/design-presets.ts +109 -0
- package/src/app/lib/design.ts +88 -0
- package/src/app/lib/diagnostics.ts +282 -0
- package/src/app/lib/doc-preview.tsx +29 -0
- package/src/app/lib/docs.ts +26 -0
- package/src/app/lib/docx/extract.ts +1623 -0
- package/src/app/lib/docx/fonts.test.ts +136 -0
- package/src/app/lib/docx/fonts.ts +166 -0
- package/src/app/lib/docx/media.ts +102 -0
- package/src/app/lib/docx/model.ts +206 -0
- package/src/app/lib/docx/paragraph.test.ts +92 -0
- package/src/app/lib/docx/paragraph.ts +107 -0
- package/src/app/lib/docx/props.ts +187 -0
- package/src/app/lib/docx/styles.ts +306 -0
- package/src/app/lib/docx/units.ts +35 -0
- package/src/app/lib/docx/write.test.ts +507 -0
- package/src/app/lib/docx/write.ts +581 -0
- package/src/app/lib/docx/xml.ts +39 -0
- package/src/app/lib/export-docx.ts +289 -0
- package/src/app/lib/export-dom.ts +318 -0
- package/src/app/lib/export-html.ts +156 -0
- package/src/app/lib/export-image.ts +70 -0
- package/src/app/lib/export-pdf.ts +165 -0
- package/src/app/lib/flow-measure.test.ts +31 -0
- package/src/app/lib/flow-measure.ts +183 -0
- package/src/app/lib/flow.test.ts +110 -0
- package/src/app/lib/flow.ts +136 -0
- package/src/app/lib/folders.ts +192 -0
- package/src/app/lib/footnotes.test.tsx +102 -0
- package/src/app/lib/footnotes.ts +94 -0
- package/src/app/lib/inspector/fiber.ts +99 -0
- package/src/app/lib/labels.test.ts +18 -0
- package/src/app/lib/labels.ts +181 -0
- package/src/app/lib/outline.ts +118 -0
- package/src/app/lib/page-context.tsx +43 -0
- package/src/app/lib/page-range.test.ts +95 -0
- package/src/app/lib/page-range.ts +90 -0
- package/src/app/lib/print-ready.ts +69 -0
- package/src/app/lib/rasterize.ts +173 -0
- package/src/app/lib/scan.ts +26 -0
- package/src/app/lib/sdk.test.ts +32 -0
- package/src/app/lib/sdk.ts +115 -0
- package/src/app/lib/themes.ts +31 -0
- package/src/app/lib/use-doc-module.ts +53 -0
- package/src/app/lib/use-doc-pages.ts +147 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/lib/view-mode.test.ts +91 -0
- package/src/app/lib/view-mode.ts +104 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +257 -0
- package/src/app/routes/doc.tsx +877 -0
- package/src/app/routes/home-shell.tsx +203 -0
- package/src/app/routes/home.tsx +269 -0
- package/src/app/routes/themes.tsx +121 -0
- package/src/app/styles.css +97 -0
- package/src/app/virtual.d.ts +30 -0
- package/template/AGENTS.md +1 -1
- package/template/README.md +24 -52
- package/template/docs/getting-started/index.tsx +2 -2
- package/template/mosage.config.ts +1 -1
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/README.zh-TW.md +0 -28
- /package/{template/.agents/skills → skills}/apply-comments/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/current-doc/SKILL.md +0 -0
- /package/{template/.agents/skills → skills}/doc-authoring/references/tables-and-charts.md +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which page the counter names, and how many sheets share a grid row.
|
|
3
|
+
*
|
|
4
|
+
* Two-up and grid put several sheets on the same top, so "the sheet under the
|
|
5
|
+
* reading line" stops being one sheet. These cases pin down what the counter
|
|
6
|
+
* says then — plus the guarantee that a single column reads exactly as it did.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { gridColumns, pageInView, READING_LINE } from './view-mode';
|
|
11
|
+
|
|
12
|
+
const GAP = 24;
|
|
13
|
+
const GUTTER = 48;
|
|
14
|
+
const VIEW_HEIGHT = 900;
|
|
15
|
+
const LINE = VIEW_HEIGHT * READING_LINE;
|
|
16
|
+
|
|
17
|
+
function layout(count: number, columns: number, height: number) {
|
|
18
|
+
const rowTop = (row: number) => GUTTER + row * (height + GAP);
|
|
19
|
+
const sheets = Array.from({ length: count }, (_, index) => ({
|
|
20
|
+
top: rowTop(Math.floor(index / columns)),
|
|
21
|
+
height,
|
|
22
|
+
}));
|
|
23
|
+
return { sheets, rowTop };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const view = (top: number) => ({ top, height: VIEW_HEIGHT });
|
|
27
|
+
|
|
28
|
+
describe('gridColumns', () => {
|
|
29
|
+
it('fits as many columns as the pane holds, gaps included', () => {
|
|
30
|
+
// 4 × 238 + 3 × 24 = 1024; a fifth sheet would need 1286.
|
|
31
|
+
expect(gridColumns(238, 1100, GAP, 30)).toBe(4);
|
|
32
|
+
expect(gridColumns(238, 1024, GAP, 30)).toBe(4);
|
|
33
|
+
expect(gridColumns(238, 1023, GAP, 30)).toBe(3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('never drops below one column, even in a pane narrower than a sheet', () => {
|
|
37
|
+
expect(gridColumns(238, 100, GAP, 30)).toBe(1);
|
|
38
|
+
expect(gridColumns(238, 0, GAP, 30)).toBe(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('never opens more columns than there are pages', () => {
|
|
42
|
+
expect(gridColumns(238, 5000, GAP, 3)).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('pageInView', () => {
|
|
47
|
+
it('reads a single column exactly as before: the last sheet above the line', () => {
|
|
48
|
+
const { sheets, rowTop } = layout(10, 1, 1123);
|
|
49
|
+
expect(pageInView(sheets, view(0), null)).toBe(1);
|
|
50
|
+
expect(pageInView(sheets, view(rowTop(1) - LINE), null)).toBe(2);
|
|
51
|
+
expect(pageInView(sheets, view(rowTop(1) - LINE - 1), null)).toBe(1);
|
|
52
|
+
expect(pageInView(sheets, view(rowTop(5) - LINE + 500), null)).toBe(6);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('names page 1 while the line is still above the first sheet', () => {
|
|
56
|
+
const { sheets } = layout(3, 1, 1123);
|
|
57
|
+
expect(pageInView(sheets, view(-LINE), null)).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('names the first page of the row under the line when nothing was chosen', () => {
|
|
61
|
+
const { sheets, rowTop } = layout(30, 4, 337);
|
|
62
|
+
// The third row holds pages 9–12.
|
|
63
|
+
expect(pageInView(sheets, view(rowTop(2) - LINE), null)).toBe(9);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps a chosen page that sits in the row under the line', () => {
|
|
67
|
+
const { sheets, rowTop } = layout(30, 4, 337);
|
|
68
|
+
expect(pageInView(sheets, view(rowTop(2) - LINE), 11)).toBe(11);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps a chosen page that is wholly on screen, even off the line', () => {
|
|
72
|
+
const { sheets, rowTop } = layout(30, 4, 337);
|
|
73
|
+
// Scrolled to the bottom: the last row (29–30) is on screen, but the line
|
|
74
|
+
// can never reach it — it sits in the row above.
|
|
75
|
+
const bottom = rowTop(7) + 337 + GUTTER * 1.5 - VIEW_HEIGHT;
|
|
76
|
+
expect(pageInView(sheets, view(bottom), null)).toBe(25);
|
|
77
|
+
expect(pageInView(sheets, view(bottom), 30)).toBe(30);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('lets go of a chosen page once it is neither on the line nor on screen', () => {
|
|
81
|
+
const { sheets, rowTop } = layout(30, 4, 337);
|
|
82
|
+
expect(pageInView(sheets, view(rowTop(5) - LINE), 2)).toBe(21);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('pairs a spread that opens on a right-hand page', () => {
|
|
86
|
+
// Page 1 alone, then 2–3 and 4–5 side by side.
|
|
87
|
+
const sheets = [48, 861, 861, 1674, 1674].map((top) => ({ top, height: 789 }));
|
|
88
|
+
expect(pageInView(sheets, view(861 - LINE), null)).toBe(2);
|
|
89
|
+
expect(pageInView(sheets, view(861 - LINE), 3)).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How the viewer lays the sheets out. Every mode keeps every page mounted, in
|
|
5
|
+
* order, as a direct child of one scrolling grid — the page counter, page jump,
|
|
6
|
+
* the outline, find and the inspector all locate a sheet by its index there, so
|
|
7
|
+
* none of them has to know which mode is on.
|
|
8
|
+
*/
|
|
9
|
+
export const VIEW_MODES = ['continuous', 'two-up', 'grid'] as const;
|
|
10
|
+
export type ViewMode = (typeof VIEW_MODES)[number];
|
|
11
|
+
|
|
12
|
+
const STORAGE_PREFIX = 'mosage:view-mode:';
|
|
13
|
+
|
|
14
|
+
function isViewMode(value: unknown): value is ViewMode {
|
|
15
|
+
return VIEW_MODES.some((mode) => mode === value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readViewMode(docId: string | undefined): ViewMode {
|
|
19
|
+
if (!docId) return 'continuous';
|
|
20
|
+
try {
|
|
21
|
+
const stored = localStorage.getItem(STORAGE_PREFIX + docId);
|
|
22
|
+
return isViewMode(stored) ? stored : 'continuous';
|
|
23
|
+
} catch {
|
|
24
|
+
return 'continuous';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The layout the reader last used for this document.
|
|
30
|
+
*
|
|
31
|
+
* The route keeps its state when it moves from one document to another, so the
|
|
32
|
+
* choice is held together with the document it was made for; anything else
|
|
33
|
+
* reads what that document last stored.
|
|
34
|
+
*/
|
|
35
|
+
export function useViewMode(docId: string | undefined): [ViewMode, (mode: ViewMode) => void] {
|
|
36
|
+
const [chosen, setChosen] = useState<{ docId: string | undefined; mode: ViewMode } | null>(null);
|
|
37
|
+
const stored = useMemo(() => readViewMode(docId), [docId]);
|
|
38
|
+
const mode = chosen && chosen.docId === docId ? chosen.mode : stored;
|
|
39
|
+
|
|
40
|
+
const choose = useCallback(
|
|
41
|
+
(next: ViewMode) => {
|
|
42
|
+
setChosen({ docId, mode: next });
|
|
43
|
+
if (!docId) return;
|
|
44
|
+
try {
|
|
45
|
+
localStorage.setItem(STORAGE_PREFIX + docId, next);
|
|
46
|
+
} catch {
|
|
47
|
+
/* Blocked storage forgets the choice on reload; the choice itself stands. */
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[docId],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return [mode, choose];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Sheets per grid row: as many as the pane holds at the current zoom, and never
|
|
58
|
+
* more than there are pages, or a short document's only row would sit off-centre
|
|
59
|
+
* beside empty columns.
|
|
60
|
+
*/
|
|
61
|
+
export function gridColumns(
|
|
62
|
+
sheetWidth: number,
|
|
63
|
+
availableWidth: number,
|
|
64
|
+
gap: number,
|
|
65
|
+
pageCount: number,
|
|
66
|
+
): number {
|
|
67
|
+
const fit = Math.floor((availableWidth + gap) / (sheetWidth + gap));
|
|
68
|
+
return Math.max(1, Math.min(fit, pageCount));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Box = { top: number; height: number };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Where the line being read sits, as a fraction of the viewport's height. A
|
|
75
|
+
* full-size page is taller than the viewport, so intersection ratios never cross
|
|
76
|
+
* a useful threshold; the row under this line is the one being read.
|
|
77
|
+
*/
|
|
78
|
+
export const READING_LINE = 1 / 3;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The page the reader is on, from each sheet's box in scroll coordinates: the
|
|
82
|
+
* first page of the row under the reading line.
|
|
83
|
+
*
|
|
84
|
+
* A page the reader chose — page jump, a thumbnail, the outline, a find hit —
|
|
85
|
+
* stays current while it is in that row or wholly on screen. Without that, a
|
|
86
|
+
* jump to the right-hand page of a spread reads back as the left one, and a jump
|
|
87
|
+
* into a grid's last row, which can never scroll up to the line, reads back as
|
|
88
|
+
* a row above it.
|
|
89
|
+
*/
|
|
90
|
+
export function pageInView(sheets: readonly Box[], view: Box, chosen: number | null): number {
|
|
91
|
+
const line = view.top + view.height * READING_LINE;
|
|
92
|
+
let rowTop = Number.NEGATIVE_INFINITY;
|
|
93
|
+
for (const sheet of sheets) {
|
|
94
|
+
if (sheet.top <= line && sheet.top > rowTop) rowTop = sheet.top;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const pick = chosen === null ? undefined : sheets[chosen - 1];
|
|
98
|
+
if (chosen !== null && pick) {
|
|
99
|
+
const onScreen = pick.top >= view.top && pick.top + pick.height <= view.top + view.height;
|
|
100
|
+
if (onScreen || pick.top === rowTop) return chosen;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Math.max(1, sheets.findIndex((sheet) => sheet.top === rowTop) + 1);
|
|
104
|
+
}
|
package/src/app/main.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ThemeProvider } from 'next-themes';
|
|
2
|
+
import { StrictMode } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { App } from './app';
|
|
5
|
+
import './styles.css';
|
|
6
|
+
|
|
7
|
+
// biome-ignore lint/style/noNonNullAssertion: #root is guaranteed by index.html
|
|
8
|
+
createRoot(document.getElementById('root')!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
|
11
|
+
<App />
|
|
12
|
+
</ThemeProvider>
|
|
13
|
+
</StrictMode>,
|
|
14
|
+
);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Check, Copy, FileIcon, Loader2, Trash2, Upload } from 'lucide-react';
|
|
2
|
+
import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
type Asset,
|
|
5
|
+
deleteAsset,
|
|
6
|
+
formatBytes,
|
|
7
|
+
GLOBAL_SCOPE,
|
|
8
|
+
importSnippet,
|
|
9
|
+
isPreviewable,
|
|
10
|
+
listAssets,
|
|
11
|
+
renameAsset,
|
|
12
|
+
uploadAsset,
|
|
13
|
+
} from '../lib/assets';
|
|
14
|
+
import { docIds } from '../lib/docs';
|
|
15
|
+
import { cn } from '../lib/utils';
|
|
16
|
+
|
|
17
|
+
export function AssetsPage() {
|
|
18
|
+
const scopes = useMemo(() => [GLOBAL_SCOPE, ...[...docIds].sort()], []);
|
|
19
|
+
const [scope, setScope] = useState<string>(GLOBAL_SCOPE);
|
|
20
|
+
const [assets, setAssets] = useState<Asset[] | null>(null);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const [dragging, setDragging] = useState(false);
|
|
24
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
25
|
+
|
|
26
|
+
const refresh = useCallback(async (target: string) => {
|
|
27
|
+
const result = await listAssets(target);
|
|
28
|
+
if (result.ok) {
|
|
29
|
+
setAssets(result.value);
|
|
30
|
+
setError(null);
|
|
31
|
+
} else {
|
|
32
|
+
setAssets([]);
|
|
33
|
+
setError(result.error);
|
|
34
|
+
}
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setAssets(null);
|
|
39
|
+
void refresh(scope);
|
|
40
|
+
}, [scope, refresh]);
|
|
41
|
+
|
|
42
|
+
const upload = useCallback(
|
|
43
|
+
async (files: FileList | File[]) => {
|
|
44
|
+
setBusy(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
for (const file of Array.from(files)) {
|
|
47
|
+
let result = await uploadAsset(scope, file);
|
|
48
|
+
if (!result.ok && result.error === 'asset exists') {
|
|
49
|
+
const replace = window.confirm(`"${file.name}" already exists in ${scope}. Replace it?`);
|
|
50
|
+
if (!replace) continue;
|
|
51
|
+
result = await uploadAsset(scope, file, { overwrite: true });
|
|
52
|
+
}
|
|
53
|
+
if (!result.ok) setError(`${file.name}: ${result.error}`);
|
|
54
|
+
}
|
|
55
|
+
await refresh(scope);
|
|
56
|
+
setBusy(false);
|
|
57
|
+
},
|
|
58
|
+
[scope, refresh],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
setDragging(false);
|
|
64
|
+
if (e.dataTransfer.files.length > 0) void upload(e.dataTransfer.files);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div>
|
|
69
|
+
<header className="border-border border-b px-8 py-6">
|
|
70
|
+
<h1 className="font-medium text-lg tracking-tight">Assets</h1>
|
|
71
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
72
|
+
Images and fonts documents can import. Global files live in{' '}
|
|
73
|
+
<code className="font-mono">assets/</code>; per-document files in{' '}
|
|
74
|
+
<code className="font-mono">docs/<id>/assets/</code>.
|
|
75
|
+
</p>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<div className="flex flex-wrap items-center gap-2 px-8 pt-6">
|
|
79
|
+
{scopes.map((s) => (
|
|
80
|
+
<button
|
|
81
|
+
key={s}
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => setScope(s)}
|
|
84
|
+
className={cn(
|
|
85
|
+
'rounded-full border px-3 py-1 text-xs transition-colors',
|
|
86
|
+
s === scope
|
|
87
|
+
? 'border-transparent bg-primary text-primary-foreground'
|
|
88
|
+
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{s === GLOBAL_SCOPE ? 'Global' : s}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="px-8 py-6">
|
|
97
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: drop target is an enhancement — the "Choose files" button is the keyboard path */}
|
|
98
|
+
<div
|
|
99
|
+
onDragOver={(e) => {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
setDragging(true);
|
|
102
|
+
}}
|
|
103
|
+
onDragLeave={() => setDragging(false)}
|
|
104
|
+
onDrop={onDrop}
|
|
105
|
+
className={cn(
|
|
106
|
+
'flex items-center justify-between gap-4 rounded-lg border border-dashed px-5 py-4 transition-colors',
|
|
107
|
+
dragging ? 'border-primary bg-accent' : 'border-border',
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
<div className="text-sm">
|
|
111
|
+
<p className="font-medium">Drop files to upload</p>
|
|
112
|
+
<p className="mt-0.5 text-muted-foreground text-xs">
|
|
113
|
+
Up to 25 MB each · png, jpg, svg, webp, pdf, woff2, csv…
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={() => inputRef.current?.click()}
|
|
119
|
+
disabled={busy}
|
|
120
|
+
className="flex flex-none items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-xs transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
121
|
+
>
|
|
122
|
+
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
|
123
|
+
Choose files
|
|
124
|
+
</button>
|
|
125
|
+
<input
|
|
126
|
+
ref={inputRef}
|
|
127
|
+
type="file"
|
|
128
|
+
multiple
|
|
129
|
+
hidden
|
|
130
|
+
onChange={(e) => {
|
|
131
|
+
if (e.target.files?.length) void upload(e.target.files);
|
|
132
|
+
e.target.value = '';
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{error && (
|
|
138
|
+
<p className="mt-3 rounded-md border border-border bg-muted px-3 py-2 text-xs">{error}</p>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{assets === null ? (
|
|
142
|
+
<div className="grid place-items-center py-16">
|
|
143
|
+
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
144
|
+
</div>
|
|
145
|
+
) : assets.length === 0 ? (
|
|
146
|
+
<p className="py-16 text-center text-muted-foreground text-sm">
|
|
147
|
+
No assets in {scope === GLOBAL_SCOPE ? 'the global folder' : scope} yet.
|
|
148
|
+
</p>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="mt-6 grid gap-5 [grid-template-columns:repeat(auto-fill,minmax(180px,1fr))]">
|
|
151
|
+
{assets.map((asset) => (
|
|
152
|
+
<AssetCard
|
|
153
|
+
key={asset.name}
|
|
154
|
+
asset={asset}
|
|
155
|
+
scope={scope}
|
|
156
|
+
onChanged={() => refresh(scope)}
|
|
157
|
+
onError={setError}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function AssetCard({
|
|
168
|
+
asset,
|
|
169
|
+
scope,
|
|
170
|
+
onChanged,
|
|
171
|
+
onError,
|
|
172
|
+
}: {
|
|
173
|
+
asset: Asset;
|
|
174
|
+
scope: string;
|
|
175
|
+
onChanged: () => void;
|
|
176
|
+
onError: (message: string) => void;
|
|
177
|
+
}) {
|
|
178
|
+
const [copied, setCopied] = useState(false);
|
|
179
|
+
|
|
180
|
+
const copyImport = async () => {
|
|
181
|
+
try {
|
|
182
|
+
await navigator.clipboard.writeText(importSnippet(asset));
|
|
183
|
+
setCopied(true);
|
|
184
|
+
setTimeout(() => setCopied(false), 1200);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
onError(String((err as Error).message));
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const rename = async () => {
|
|
191
|
+
const next = window.prompt('New file name', asset.name);
|
|
192
|
+
if (!next || next === asset.name) return;
|
|
193
|
+
const result = await renameAsset(scope, asset.name, next);
|
|
194
|
+
if (!result.ok) onError(`${asset.name}: ${result.error}`);
|
|
195
|
+
onChanged();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const remove = async () => {
|
|
199
|
+
if (!window.confirm(`Delete "${asset.name}"? This removes the file from disk.`)) return;
|
|
200
|
+
const result = await deleteAsset(scope, asset.name);
|
|
201
|
+
if (!result.ok) onError(`${asset.name}: ${result.error}`);
|
|
202
|
+
onChanged();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div className="flex flex-col gap-2">
|
|
207
|
+
<div className="relative grid h-32 place-items-center overflow-hidden rounded-md border border-border bg-muted">
|
|
208
|
+
{isPreviewable(asset.mime) ? (
|
|
209
|
+
<img
|
|
210
|
+
src={asset.url}
|
|
211
|
+
alt={asset.name}
|
|
212
|
+
className="max-h-full max-w-full object-contain p-2"
|
|
213
|
+
/>
|
|
214
|
+
) : (
|
|
215
|
+
<FileIcon className="size-6 text-muted-foreground" />
|
|
216
|
+
)}
|
|
217
|
+
{asset.unused && (
|
|
218
|
+
<span className="absolute top-1.5 left-1.5 rounded-full bg-background/90 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
|
219
|
+
unused
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="min-w-0">
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={rename}
|
|
228
|
+
title="Rename"
|
|
229
|
+
className="block w-full truncate text-left text-xs hover:underline"
|
|
230
|
+
>
|
|
231
|
+
{asset.name}
|
|
232
|
+
</button>
|
|
233
|
+
<p className="mt-0.5 text-[11px] text-muted-foreground">{formatBytes(asset.size)}</p>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="flex items-center gap-1">
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={copyImport}
|
|
240
|
+
title={importSnippet(asset)}
|
|
241
|
+
className="flex flex-1 items-center justify-center gap-1 rounded-md border border-border px-2 py-1 text-[11px] transition-colors hover:bg-accent"
|
|
242
|
+
>
|
|
243
|
+
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
244
|
+
{copied ? 'Copied' : 'Import'}
|
|
245
|
+
</button>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
onClick={remove}
|
|
249
|
+
aria-label={`Delete ${asset.name}`}
|
|
250
|
+
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
251
|
+
>
|
|
252
|
+
<Trash2 className="size-3" />
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|