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,203 @@
|
|
|
1
|
+
import { Menu as MenuIcon } from 'lucide-react';
|
|
2
|
+
import { useCallback, useMemo } from 'react';
|
|
3
|
+
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
4
|
+
import {
|
|
5
|
+
ALL_DOCS_ID,
|
|
6
|
+
ASSETS_ID,
|
|
7
|
+
DRAFT_ID,
|
|
8
|
+
Sidebar,
|
|
9
|
+
THEMES_ID,
|
|
10
|
+
} from '../components/sidebar/sidebar';
|
|
11
|
+
import { ThemeToggle } from '../components/theme-toggle';
|
|
12
|
+
import { Menu, MenuItem } from '../components/ui/menu';
|
|
13
|
+
import { useAssetCount } from '../lib/assets';
|
|
14
|
+
import { docIds } from '../lib/docs';
|
|
15
|
+
import { useFolders } from '../lib/folders';
|
|
16
|
+
import type { FoldersManifest } from '../lib/sdk';
|
|
17
|
+
import { themes } from '../lib/themes';
|
|
18
|
+
|
|
19
|
+
export type HomeOutletContext = {
|
|
20
|
+
manifest: FoldersManifest;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
/** Documents with no folder assignment. */
|
|
23
|
+
draftDocs: string[];
|
|
24
|
+
docsByFolder: Record<string, string[]>;
|
|
25
|
+
/** ALL_DOCS_ID, DRAFT_ID, a folder id, THEMES_ID, or ASSETS_ID. */
|
|
26
|
+
selectedId: string;
|
|
27
|
+
selectFolder: (id: string) => void;
|
|
28
|
+
assign: (docId: string, folderId: string | null) => Promise<void>;
|
|
29
|
+
renameDoc: (docId: string, title: string) => Promise<void>;
|
|
30
|
+
duplicateDoc: (docId: string, newId?: string) => Promise<string>;
|
|
31
|
+
deleteDoc: (docId: string) => Promise<void>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function pathToSelectedId(pathname: string, search: URLSearchParams): string {
|
|
35
|
+
if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
|
|
36
|
+
if (pathname === '/assets') return ASSETS_ID;
|
|
37
|
+
return search.get('f') ?? ALL_DOCS_ID;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function HomeShell() {
|
|
41
|
+
const {
|
|
42
|
+
manifest,
|
|
43
|
+
loading,
|
|
44
|
+
create,
|
|
45
|
+
update,
|
|
46
|
+
remove,
|
|
47
|
+
reorder,
|
|
48
|
+
assign,
|
|
49
|
+
renameDoc,
|
|
50
|
+
duplicateDoc,
|
|
51
|
+
deleteDoc,
|
|
52
|
+
} = useFolders();
|
|
53
|
+
const navigate = useNavigate();
|
|
54
|
+
const location = useLocation();
|
|
55
|
+
const [searchParams] = useSearchParams();
|
|
56
|
+
const assetCount = useAssetCount();
|
|
57
|
+
|
|
58
|
+
const selectedId = pathToSelectedId(location.pathname, searchParams);
|
|
59
|
+
|
|
60
|
+
const selectFolder = useCallback(
|
|
61
|
+
(id: string) => {
|
|
62
|
+
if (id === THEMES_ID) navigate('/themes');
|
|
63
|
+
else if (id === ASSETS_ID) navigate('/assets');
|
|
64
|
+
else if (id === ALL_DOCS_ID) navigate('/');
|
|
65
|
+
else navigate(`/?f=${encodeURIComponent(id)}`);
|
|
66
|
+
},
|
|
67
|
+
[navigate],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const { draftDocs, docsByFolder } = useMemo(() => {
|
|
71
|
+
const byFolder: Record<string, string[]> = {};
|
|
72
|
+
const draft: string[] = [];
|
|
73
|
+
const known = new Set(manifest.folders.map((f) => f.id));
|
|
74
|
+
for (const id of docIds) {
|
|
75
|
+
const folderId = manifest.assignments[id];
|
|
76
|
+
if (folderId && known.has(folderId)) {
|
|
77
|
+
byFolder[folderId] ??= [];
|
|
78
|
+
byFolder[folderId].push(id);
|
|
79
|
+
} else {
|
|
80
|
+
draft.push(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { draftDocs: draft, docsByFolder: byFolder };
|
|
84
|
+
}, [manifest]);
|
|
85
|
+
|
|
86
|
+
const countFor = (folderId: string | null) =>
|
|
87
|
+
folderId === null ? draftDocs.length : (docsByFolder[folderId]?.length ?? 0);
|
|
88
|
+
|
|
89
|
+
const ctx: HomeOutletContext = {
|
|
90
|
+
manifest,
|
|
91
|
+
loading,
|
|
92
|
+
draftDocs,
|
|
93
|
+
docsByFolder,
|
|
94
|
+
selectedId,
|
|
95
|
+
selectFolder,
|
|
96
|
+
assign,
|
|
97
|
+
renameDoc,
|
|
98
|
+
duplicateDoc,
|
|
99
|
+
deleteDoc,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const isAssetsRoute = location.pathname === '/assets';
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="flex h-dvh overflow-hidden bg-background text-foreground">
|
|
106
|
+
<div className="hidden md:block">
|
|
107
|
+
<Sidebar
|
|
108
|
+
folders={manifest.folders}
|
|
109
|
+
countFor={countFor}
|
|
110
|
+
allCount={docIds.length}
|
|
111
|
+
themesCount={themes.length}
|
|
112
|
+
assetsCount={assetCount}
|
|
113
|
+
selectedId={selectedId}
|
|
114
|
+
onSelect={selectFolder}
|
|
115
|
+
onCreate={create}
|
|
116
|
+
onRename={(id, name) => update(id, { name })}
|
|
117
|
+
onChangeIcon={(id, icon) => update(id, { icon })}
|
|
118
|
+
onDelete={async (id) => {
|
|
119
|
+
if (selectedId === id) selectFolder(ALL_DOCS_ID);
|
|
120
|
+
await remove(id).catch(() => {});
|
|
121
|
+
}}
|
|
122
|
+
onDropToFolder={(folderId, docId) => void assign(docId, folderId)}
|
|
123
|
+
onDropToDraft={(docId) => void assign(docId, null)}
|
|
124
|
+
onReorder={(ids) => void reorder(ids).catch(() => {})}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<div className="relative flex min-w-0 flex-1 flex-col overflow-y-auto bg-canvas">
|
|
129
|
+
<div className="flex items-center justify-between border-border border-b bg-background px-4 py-3 md:hidden">
|
|
130
|
+
<h1 className="font-semibold text-base tracking-tight">MoSage</h1>
|
|
131
|
+
<div className="-mr-1 flex items-center gap-0.5">
|
|
132
|
+
<ThemeToggle />
|
|
133
|
+
<Menu
|
|
134
|
+
trigger={(props) => (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
aria-label="Menu"
|
|
138
|
+
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground aria-expanded:bg-accent"
|
|
139
|
+
{...props}
|
|
140
|
+
>
|
|
141
|
+
<MenuIcon className="size-4" />
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
{(close) => (
|
|
146
|
+
<>
|
|
147
|
+
<MenuItem
|
|
148
|
+
active={selectedId !== THEMES_ID && selectedId !== ASSETS_ID}
|
|
149
|
+
onClick={() => {
|
|
150
|
+
selectFolder(ALL_DOCS_ID);
|
|
151
|
+
close();
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
Documents
|
|
155
|
+
</MenuItem>
|
|
156
|
+
<MenuItem
|
|
157
|
+
active={selectedId === DRAFT_ID}
|
|
158
|
+
onClick={() => {
|
|
159
|
+
selectFolder(DRAFT_ID);
|
|
160
|
+
close();
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
Unfiled
|
|
164
|
+
</MenuItem>
|
|
165
|
+
<MenuItem
|
|
166
|
+
active={selectedId === THEMES_ID}
|
|
167
|
+
onClick={() => {
|
|
168
|
+
selectFolder(THEMES_ID);
|
|
169
|
+
close();
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
Themes
|
|
173
|
+
</MenuItem>
|
|
174
|
+
{import.meta.env.DEV && (
|
|
175
|
+
<MenuItem
|
|
176
|
+
active={selectedId === ASSETS_ID}
|
|
177
|
+
onClick={() => {
|
|
178
|
+
selectFolder(ASSETS_ID);
|
|
179
|
+
close();
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
Assets
|
|
183
|
+
</MenuItem>
|
|
184
|
+
)}
|
|
185
|
+
</>
|
|
186
|
+
)}
|
|
187
|
+
</Menu>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div
|
|
192
|
+
className={
|
|
193
|
+
isAssetsRoute
|
|
194
|
+
? 'flex min-h-0 flex-1 flex-col'
|
|
195
|
+
: 'mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-10'
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
<Outlet context={ctx} />
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Copy,
|
|
3
|
+
FileText,
|
|
4
|
+
FolderInput,
|
|
5
|
+
MoreHorizontal,
|
|
6
|
+
Palette,
|
|
7
|
+
PencilLine,
|
|
8
|
+
Trash2,
|
|
9
|
+
} from 'lucide-react';
|
|
10
|
+
import { useMemo, useState } from 'react';
|
|
11
|
+
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
|
12
|
+
import { PageFrame } from '../components/page-frame';
|
|
13
|
+
import { DOC_DND_MIME } from '../components/sidebar/folder-item';
|
|
14
|
+
import { ALL_DOCS_ID, DRAFT_ID } from '../components/sidebar/sidebar';
|
|
15
|
+
import { Menu, MenuItem, MenuSeparator } from '../components/ui/menu';
|
|
16
|
+
import { coverContent, pageCountLabel } from '../lib/doc-preview';
|
|
17
|
+
import { docCreatedAt, docIds, docThemes } from '../lib/docs';
|
|
18
|
+
import { resolvePageGeometry } from '../lib/sdk';
|
|
19
|
+
import { findTheme } from '../lib/themes';
|
|
20
|
+
import { useDocModule } from '../lib/use-doc-module';
|
|
21
|
+
import type { HomeOutletContext } from './home-shell';
|
|
22
|
+
|
|
23
|
+
const THUMB_WIDTH = 190;
|
|
24
|
+
|
|
25
|
+
export function Home() {
|
|
26
|
+
const ctx = useOutletContext<HomeOutletContext>();
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
const folder = ctx.manifest.folders.find((f) => f.id === ctx.selectedId);
|
|
30
|
+
const visibleIds = useMemo(() => {
|
|
31
|
+
const source =
|
|
32
|
+
ctx.selectedId === ALL_DOCS_ID
|
|
33
|
+
? [...docIds]
|
|
34
|
+
: ctx.selectedId === DRAFT_ID
|
|
35
|
+
? [...ctx.draftDocs]
|
|
36
|
+
: [...(ctx.docsByFolder[ctx.selectedId] ?? [])];
|
|
37
|
+
return source.sort((a, b) => {
|
|
38
|
+
const at = docCreatedAt[a] ?? 0;
|
|
39
|
+
const bt = docCreatedAt[b] ?? 0;
|
|
40
|
+
if (at !== bt) return bt - at;
|
|
41
|
+
return a.localeCompare(b);
|
|
42
|
+
});
|
|
43
|
+
}, [ctx.selectedId, ctx.draftDocs, ctx.docsByFolder]);
|
|
44
|
+
|
|
45
|
+
const heading =
|
|
46
|
+
ctx.selectedId === ALL_DOCS_ID
|
|
47
|
+
? 'Documents'
|
|
48
|
+
: ctx.selectedId === DRAFT_ID
|
|
49
|
+
? 'Unfiled'
|
|
50
|
+
: (folder?.name ?? 'Documents');
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div>
|
|
54
|
+
<header className="mb-8">
|
|
55
|
+
<h1 className="font-medium text-lg tracking-tight">{heading}</h1>
|
|
56
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
57
|
+
{ctx.selectedId === ALL_DOCS_ID ? (
|
|
58
|
+
<>
|
|
59
|
+
Every folder under <code className="font-mono">docs/</code> with an{' '}
|
|
60
|
+
<code className="font-mono">index.tsx</code>.
|
|
61
|
+
</>
|
|
62
|
+
) : ctx.selectedId === DRAFT_ID ? (
|
|
63
|
+
'Documents that have not been filed into a folder yet.'
|
|
64
|
+
) : (
|
|
65
|
+
`${visibleIds.length} document${visibleIds.length === 1 ? '' : 's'} in this folder.`
|
|
66
|
+
)}
|
|
67
|
+
</p>
|
|
68
|
+
{error && (
|
|
69
|
+
<p className="mt-3 rounded-md border border-border bg-background px-3 py-2 text-xs">
|
|
70
|
+
{error}
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
</header>
|
|
74
|
+
|
|
75
|
+
{visibleIds.length === 0 ? (
|
|
76
|
+
<div className="py-16 text-center">
|
|
77
|
+
<FileText className="mx-auto size-6 text-muted-foreground" />
|
|
78
|
+
<p className="mt-3 font-medium text-sm">Nothing here yet</p>
|
|
79
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
80
|
+
{ctx.selectedId === ALL_DOCS_ID ? (
|
|
81
|
+
<>
|
|
82
|
+
Create <code className="font-mono">docs/<id>/index.tsx</code> and it appears
|
|
83
|
+
here.
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
'Drag a document onto this folder in the sidebar to file it here.'
|
|
87
|
+
)}
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
<div className="grid gap-7 [grid-template-columns:repeat(auto-fill,minmax(190px,1fr))]">
|
|
92
|
+
{visibleIds.map((id) => (
|
|
93
|
+
<DocCard key={id} docId={id} ctx={ctx} onError={setError} />
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function DocCard({
|
|
102
|
+
docId,
|
|
103
|
+
ctx,
|
|
104
|
+
onError,
|
|
105
|
+
}: {
|
|
106
|
+
docId: string;
|
|
107
|
+
ctx: HomeOutletContext;
|
|
108
|
+
onError: (message: string | null) => void;
|
|
109
|
+
}) {
|
|
110
|
+
const navigate = useNavigate();
|
|
111
|
+
const state = useDocModule(docId);
|
|
112
|
+
const doc = state.doc;
|
|
113
|
+
const geometry = resolvePageGeometry(doc?.meta);
|
|
114
|
+
const scale = THUMB_WIDTH / geometry.width;
|
|
115
|
+
const cover = coverContent(doc);
|
|
116
|
+
const theme = findTheme(docThemes[docId]);
|
|
117
|
+
const title = doc?.meta?.title ?? docId;
|
|
118
|
+
const currentFolder = ctx.manifest.assignments[docId];
|
|
119
|
+
|
|
120
|
+
const run = async (action: () => Promise<unknown>) => {
|
|
121
|
+
onError(null);
|
|
122
|
+
try {
|
|
123
|
+
await action();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
onError(String((err as Error).message));
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: dragging files a document — the card menu's "Move to" is the keyboard path
|
|
131
|
+
<div
|
|
132
|
+
className="group flex flex-col gap-2.5"
|
|
133
|
+
draggable={import.meta.env.DEV}
|
|
134
|
+
onDragStart={(e) => {
|
|
135
|
+
if (!import.meta.env.DEV) return;
|
|
136
|
+
e.dataTransfer.setData(DOC_DND_MIME, docId);
|
|
137
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<Link to={`/d/${docId}`}>
|
|
141
|
+
<div
|
|
142
|
+
className="overflow-hidden rounded-md ring-1 ring-border transition-shadow group-hover:shadow-lg"
|
|
143
|
+
style={{ width: THUMB_WIDTH, height: geometry.height * scale }}
|
|
144
|
+
>
|
|
145
|
+
{cover ? (
|
|
146
|
+
<PageFrame
|
|
147
|
+
index={0}
|
|
148
|
+
total={doc?.default?.length ?? 1}
|
|
149
|
+
geometry={geometry}
|
|
150
|
+
scale={scale}
|
|
151
|
+
design={doc?.design}
|
|
152
|
+
flat
|
|
153
|
+
>
|
|
154
|
+
{cover}
|
|
155
|
+
</PageFrame>
|
|
156
|
+
) : (
|
|
157
|
+
<div className="size-full bg-muted" />
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
</Link>
|
|
161
|
+
|
|
162
|
+
<div className="flex min-w-0 items-start gap-1">
|
|
163
|
+
<div className="min-w-0 flex-1">
|
|
164
|
+
<Link to={`/d/${docId}`} className="block truncate font-medium text-sm hover:underline">
|
|
165
|
+
{title}
|
|
166
|
+
</Link>
|
|
167
|
+
<p className="mt-0.5 truncate text-muted-foreground text-xs">
|
|
168
|
+
{pageCountLabel(doc)} · {doc?.meta?.pageSize ?? 'A4'}
|
|
169
|
+
</p>
|
|
170
|
+
{theme && (
|
|
171
|
+
<Link
|
|
172
|
+
to={`/themes/${theme.id}`}
|
|
173
|
+
className="mt-1.5 inline-flex items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
174
|
+
>
|
|
175
|
+
<Palette className="size-2.5" />
|
|
176
|
+
{theme.name}
|
|
177
|
+
</Link>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{import.meta.env.DEV && (
|
|
182
|
+
<Menu
|
|
183
|
+
trigger={(props) => (
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
aria-label={`${title} options`}
|
|
187
|
+
className="flex size-6 flex-none items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 aria-expanded:opacity-100"
|
|
188
|
+
{...props}
|
|
189
|
+
>
|
|
190
|
+
<MoreHorizontal className="size-3.5" />
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
>
|
|
194
|
+
{(close) => (
|
|
195
|
+
<>
|
|
196
|
+
<MenuItem
|
|
197
|
+
onClick={() => {
|
|
198
|
+
close();
|
|
199
|
+
const next = window.prompt('Document title', title);
|
|
200
|
+
if (next && next !== title) void run(() => ctx.renameDoc(docId, next));
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<PencilLine className="size-3.5" />
|
|
204
|
+
Rename
|
|
205
|
+
</MenuItem>
|
|
206
|
+
<MenuItem
|
|
207
|
+
onClick={() => {
|
|
208
|
+
close();
|
|
209
|
+
void run(async () => {
|
|
210
|
+
const newId = await ctx.duplicateDoc(docId);
|
|
211
|
+
navigate(`/d/${newId}`);
|
|
212
|
+
});
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<Copy className="size-3.5" />
|
|
216
|
+
Duplicate
|
|
217
|
+
</MenuItem>
|
|
218
|
+
|
|
219
|
+
<MenuSeparator />
|
|
220
|
+
<p className="px-2 py-1 text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
221
|
+
Move to
|
|
222
|
+
</p>
|
|
223
|
+
<MenuItem
|
|
224
|
+
active={!currentFolder}
|
|
225
|
+
onClick={() => {
|
|
226
|
+
close();
|
|
227
|
+
void run(() => ctx.assign(docId, null));
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<FolderInput className="size-3.5" />
|
|
231
|
+
Unfiled
|
|
232
|
+
</MenuItem>
|
|
233
|
+
{ctx.manifest.folders.map((folder) => (
|
|
234
|
+
<MenuItem
|
|
235
|
+
key={folder.id}
|
|
236
|
+
active={currentFolder === folder.id}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
close();
|
|
239
|
+
void run(() => ctx.assign(docId, folder.id));
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<FolderInput className="size-3.5" />
|
|
243
|
+
{folder.name}
|
|
244
|
+
</MenuItem>
|
|
245
|
+
))}
|
|
246
|
+
|
|
247
|
+
<MenuSeparator />
|
|
248
|
+
<MenuItem
|
|
249
|
+
destructive
|
|
250
|
+
onClick={() => {
|
|
251
|
+
close();
|
|
252
|
+
if (
|
|
253
|
+
!window.confirm(`Delete "${title}"? This removes docs/${docId}/ from disk.`)
|
|
254
|
+
)
|
|
255
|
+
return;
|
|
256
|
+
void run(() => ctx.deleteDoc(docId));
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
<Trash2 className="size-3.5" />
|
|
260
|
+
Delete document
|
|
261
|
+
</MenuItem>
|
|
262
|
+
</>
|
|
263
|
+
)}
|
|
264
|
+
</Menu>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ArrowLeft, Palette } from 'lucide-react';
|
|
2
|
+
import { Link, useParams } from 'react-router-dom';
|
|
3
|
+
import { Markdown } from '../components/themes/markdown';
|
|
4
|
+
import { ThemePreview } from '../components/themes/theme-preview';
|
|
5
|
+
import { docsByTheme } from '../lib/docs';
|
|
6
|
+
import { findTheme, themes } from '../lib/themes';
|
|
7
|
+
|
|
8
|
+
const GALLERY_WIDTH = 200;
|
|
9
|
+
const DETAIL_WIDTH = 260;
|
|
10
|
+
|
|
11
|
+
export function ThemesGalleryPage() {
|
|
12
|
+
return (
|
|
13
|
+
<div>
|
|
14
|
+
<header className="border-border border-b px-8 py-6">
|
|
15
|
+
<h1 className="font-medium text-lg tracking-tight">Themes</h1>
|
|
16
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
17
|
+
Every <code className="font-mono">.md</code> file under{' '}
|
|
18
|
+
<code className="font-mono">themes/</code>. A theme is documentation — palette, type
|
|
19
|
+
scale, and paste-ready components a document copies from.
|
|
20
|
+
</p>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
{themes.length === 0 ? (
|
|
24
|
+
<div className="px-8 py-16 text-center">
|
|
25
|
+
<Palette className="mx-auto size-6 text-muted-foreground" />
|
|
26
|
+
<p className="mt-3 font-medium text-sm">No themes yet</p>
|
|
27
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
28
|
+
Ask your agent for the <code className="font-mono">create-theme</code> skill, or add{' '}
|
|
29
|
+
<code className="font-mono">themes/<id>.md</code>.
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
) : (
|
|
33
|
+
<div className="grid gap-8 px-8 py-8 [grid-template-columns:repeat(auto-fill,minmax(200px,1fr))]">
|
|
34
|
+
{themes.map((theme) => (
|
|
35
|
+
<Link key={theme.id} to={`/themes/${theme.id}`} className="group flex flex-col gap-3">
|
|
36
|
+
<div className="transition-shadow group-hover:shadow-lg">
|
|
37
|
+
<ThemePreview theme={theme} width={GALLERY_WIDTH} />
|
|
38
|
+
</div>
|
|
39
|
+
<div className="min-w-0">
|
|
40
|
+
<p className="truncate font-medium text-sm">{theme.name}</p>
|
|
41
|
+
<p className="mt-0.5 line-clamp-2 text-muted-foreground text-xs">
|
|
42
|
+
{theme.description || theme.id}
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
</Link>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ThemeDetailPage() {
|
|
54
|
+
const { themeId } = useParams<{ themeId: string }>();
|
|
55
|
+
const theme = findTheme(themeId);
|
|
56
|
+
|
|
57
|
+
if (!theme) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="px-8 py-16 text-center">
|
|
60
|
+
<p className="font-medium text-sm">Theme “{themeId}” not found.</p>
|
|
61
|
+
<Link to="/themes" className="mt-3 inline-block text-muted-foreground text-xs underline">
|
|
62
|
+
Back to themes
|
|
63
|
+
</Link>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const usedBy = docsByTheme(theme.id);
|
|
69
|
+
const chips = [theme.pageSize, theme.mode].filter(Boolean);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="px-8 py-6">
|
|
73
|
+
<Link
|
|
74
|
+
to="/themes"
|
|
75
|
+
className="inline-flex items-center gap-1.5 text-muted-foreground text-xs hover:text-foreground"
|
|
76
|
+
>
|
|
77
|
+
<ArrowLeft className="size-3" />
|
|
78
|
+
Themes
|
|
79
|
+
</Link>
|
|
80
|
+
|
|
81
|
+
<div className="mt-4 flex flex-wrap items-baseline gap-3">
|
|
82
|
+
<h1 className="font-medium text-lg tracking-tight">{theme.name}</h1>
|
|
83
|
+
<code className="font-mono text-muted-foreground text-xs">{theme.id}</code>
|
|
84
|
+
{chips.map((chip) => (
|
|
85
|
+
<span
|
|
86
|
+
key={chip}
|
|
87
|
+
className="rounded-full border border-border px-2 py-0.5 text-[10px] text-muted-foreground"
|
|
88
|
+
>
|
|
89
|
+
{chip}
|
|
90
|
+
</span>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
{theme.description && (
|
|
94
|
+
<p className="mt-1 max-w-2xl text-muted-foreground text-sm">{theme.description}</p>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
<div className="mt-6">
|
|
98
|
+
<ThemePreview theme={theme} width={DETAIL_WIDTH} all />
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{usedBy.length > 0 && (
|
|
102
|
+
<div className="mt-6 flex flex-wrap items-center gap-2">
|
|
103
|
+
<span className="text-muted-foreground text-xs">Used by</span>
|
|
104
|
+
{usedBy.map((docId) => (
|
|
105
|
+
<Link
|
|
106
|
+
key={docId}
|
|
107
|
+
to={`/d/${docId}`}
|
|
108
|
+
className="rounded-full border border-border px-2 py-0.5 text-[11px] transition-colors hover:bg-accent"
|
|
109
|
+
>
|
|
110
|
+
{docId}
|
|
111
|
+
</Link>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<article className="mt-8 max-w-3xl border-border border-t pt-6">
|
|
117
|
+
<Markdown source={theme.body} />
|
|
118
|
+
</article>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@custom-variant dark (&:is(.dark *));
|
|
4
|
+
|
|
5
|
+
@theme inline {
|
|
6
|
+
--font-sans: -apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif;
|
|
7
|
+
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
|
8
|
+
|
|
9
|
+
--color-background: var(--background);
|
|
10
|
+
--color-foreground: var(--foreground);
|
|
11
|
+
--color-canvas: var(--canvas);
|
|
12
|
+
--color-muted: var(--muted);
|
|
13
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
14
|
+
--color-accent: var(--accent);
|
|
15
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
16
|
+
--color-primary: var(--primary);
|
|
17
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
18
|
+
--color-border: var(--border);
|
|
19
|
+
|
|
20
|
+
--radius-md: 6px;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
:root {
|
|
24
|
+
--background: oklch(0.99 0 0);
|
|
25
|
+
--foreground: oklch(0.145 0 0);
|
|
26
|
+
--canvas: oklch(0.93 0 0);
|
|
27
|
+
--muted: oklch(0.955 0 0);
|
|
28
|
+
--muted-foreground: oklch(0.5 0 0);
|
|
29
|
+
--accent: oklch(0.945 0 0);
|
|
30
|
+
--accent-foreground: oklch(0.185 0 0);
|
|
31
|
+
--primary: oklch(0.185 0 0);
|
|
32
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
33
|
+
--border: oklch(0.9 0 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.dark {
|
|
37
|
+
--background: oklch(0.16 0 0);
|
|
38
|
+
--foreground: oklch(0.97 0 0);
|
|
39
|
+
--canvas: oklch(0.12 0 0);
|
|
40
|
+
--muted: oklch(0.24 0 0);
|
|
41
|
+
--muted-foreground: oklch(0.68 0 0);
|
|
42
|
+
--accent: oklch(0.25 0 0);
|
|
43
|
+
--accent-foreground: oklch(0.97 0 0);
|
|
44
|
+
--primary: oklch(0.97 0 0);
|
|
45
|
+
--primary-foreground: oklch(0.16 0 0);
|
|
46
|
+
--border: oklch(0.28 0 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@layer base {
|
|
50
|
+
* {
|
|
51
|
+
border-color: var(--border);
|
|
52
|
+
}
|
|
53
|
+
/* A heading's space-above is what separates it from the section before it —
|
|
54
|
+
but at the top of a page that space is just a dent. Strip it there so the
|
|
55
|
+
first block of every flow page starts flush with the margin. */
|
|
56
|
+
[data-od-flow-block]:first-child > * {
|
|
57
|
+
/* biome-ignore lint/complexity/noImportantStyles: blocks carry inline styles, which no stylesheet rule can outrank without it */
|
|
58
|
+
margin-top: 0 !important;
|
|
59
|
+
}
|
|
60
|
+
html,
|
|
61
|
+
body,
|
|
62
|
+
#root {
|
|
63
|
+
height: 100%;
|
|
64
|
+
}
|
|
65
|
+
body {
|
|
66
|
+
margin: 0;
|
|
67
|
+
background: var(--background);
|
|
68
|
+
color: var(--foreground);
|
|
69
|
+
font-family: var(--font-sans);
|
|
70
|
+
-webkit-font-smoothing: antialiased;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Search hits.
|
|
75
|
+
*
|
|
76
|
+
* Drawn with the CSS Custom Highlight API, so nothing in the page is wrapped or
|
|
77
|
+
* rewritten to show a match. Wrapping matches in <mark> would mean editing a
|
|
78
|
+
* tree React owns, and the next render would throw the marks away — or worse,
|
|
79
|
+
* keep them and lose the reader's place. Highlights are ranges held beside the
|
|
80
|
+
* DOM instead, which is what they are for.
|
|
81
|
+
*
|
|
82
|
+
* A browser without the API shows no highlight; the page-to-page jumping still
|
|
83
|
+
* works, so search degrades to "take me there" rather than breaking.
|
|
84
|
+
*/
|
|
85
|
+
/* Literal colours, deliberately. These paint the sheet, and the sheet is paper —
|
|
86
|
+
it does not follow the viewer's theme. A chrome token here resolves to
|
|
87
|
+
near-white in dark mode and disappears on a white page, which is the leak the
|
|
88
|
+
guidelines warn about, arriving as an invisible highlight rather than an
|
|
89
|
+
obviously wrong one. Amber is what every reader already reads as "found". */
|
|
90
|
+
::highlight(od-search) {
|
|
91
|
+
background-color: rgb(255 214 0 / 0.42);
|
|
92
|
+
color: inherit;
|
|
93
|
+
}
|
|
94
|
+
::highlight(od-search-active) {
|
|
95
|
+
background-color: rgb(255 138 0 / 0.62);
|
|
96
|
+
color: inherit;
|
|
97
|
+
}
|