mosage 0.1.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/LICENSE +1 -0
- package/README.md +16 -217
- 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/init-Bbtj2pxF.js +262 -0
- 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 +84 -64
- package/skills/apply-comments/SKILL.md +43 -56
- package/skills/create-doc/SKILL.md +106 -0
- package/skills/create-theme/SKILL.md +184 -0
- package/skills/current-doc/SKILL.md +120 -0
- package/skills/doc-authoring/SKILL.md +434 -0
- package/skills/doc-authoring/references/assets.md +47 -0
- package/skills/doc-authoring/references/design-system.md +81 -0
- package/skills/doc-authoring/references/long-form.md +131 -0
- package/skills/doc-authoring/references/pagination.md +118 -0
- package/skills/doc-authoring/references/tables-and-charts.md +161 -0
- 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 +27 -0
- package/template/README.md +39 -0
- package/template/docs/getting-started/index.tsx +230 -0
- package/template/mosage.config.ts +5 -0
- package/template/package.json +24 -0
- package/template/tsconfig.json +17 -0
- package/README.en.md +0 -57
- package/dist/cli.js +0 -4545
- package/dist/web/assets/index-Czg2WeHe.js +0 -182
- package/dist/web/assets/index-DKAyt92W.css +0 -1
- package/dist/web/index.html +0 -15
- package/skills/current-position/SKILL.md +0 -65
- package/skills/kickoff/SKILL.md +0 -105
- package/skills/mosage-reference/SKILL.md +0 -164
- package/skills/outline/SKILL.md +0 -79
- package/skills/review/SKILL.md +0 -64
- package/skills/write-chapter/SKILL.md +0 -58
- package/template/book/STYLE.md +0 -6
- package/template/book/assets/.gitkeep +0 -0
- package/template/book/book.yaml +0 -42
- package/template/book/brief.md +0 -6
- package/template/book/chapters/.gitkeep +0 -0
- package/template/book/notes/README.md +0 -7
- package/template/project/AGENTS.md +0 -82
- package/template/project/CLAUDE.md +0 -1
- package/template/project/README.md +0 -48
- package/template/project/books/.gitkeep +0 -0
- package/template/project/gitignore +0 -5
- package/template/project/mosage.yaml +0 -23
- package/template/project/notes/README.md +0 -8
- package/template/project/package.json +0 -14
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useId } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
LABEL_ATTR,
|
|
4
|
+
LABEL_ID_ATTR,
|
|
5
|
+
LABEL_TEXT_ATTR,
|
|
6
|
+
type LabelKind,
|
|
7
|
+
onPageParts,
|
|
8
|
+
useDocLabel,
|
|
9
|
+
useDocLabels,
|
|
10
|
+
useLabelVocabulary,
|
|
11
|
+
} from '../lib/labels';
|
|
12
|
+
import { useDocPageNumber } from '../lib/page-context';
|
|
13
|
+
|
|
14
|
+
export const REF_ATTR = 'data-od-ref';
|
|
15
|
+
/** The page number inside a reference's page suffix โ the Word export makes it a PAGEREF. */
|
|
16
|
+
export const REF_PAGE_ATTR = 'data-od-ref-page';
|
|
17
|
+
export const LIST_OF_ATTR = 'data-od-list-of';
|
|
18
|
+
|
|
19
|
+
function nameFor(kind: LabelKind, vocabulary: { figure: string; table: string }): string {
|
|
20
|
+
return kind === 'table' ? vocabulary.table : vocabulary.figure;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type FigureProps = {
|
|
24
|
+
/** Stable id, so `<Ref to>` can point at it. Generated when omitted. */
|
|
25
|
+
id?: string;
|
|
26
|
+
/** What the caption says after the number. */
|
|
27
|
+
caption?: ReactNode;
|
|
28
|
+
/** `figure` numbers with figures, `table` with tables. Defaults to `figure`. */
|
|
29
|
+
kind?: LabelKind;
|
|
30
|
+
/** Tables conventionally caption above, figures below. */
|
|
31
|
+
captionPosition?: 'above' | 'below';
|
|
32
|
+
/** Plain-text caption for the list of figures, when `caption` carries markup. */
|
|
33
|
+
captionText?: string;
|
|
34
|
+
style?: CSSProperties;
|
|
35
|
+
captionStyle?: CSSProperties;
|
|
36
|
+
className?: string;
|
|
37
|
+
children?: ReactNode;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A numbered figure or table. The number comes from a scan of the rendered
|
|
42
|
+
* pages, not from a counter in the source, so inserting a figure halfway
|
|
43
|
+
* through a document renumbers everything after it โ including the references
|
|
44
|
+
* to it โ without anyone editing a number.
|
|
45
|
+
*
|
|
46
|
+
* Caption and content are one block, so a `flow()` section never separates them.
|
|
47
|
+
*/
|
|
48
|
+
export function Figure({
|
|
49
|
+
id,
|
|
50
|
+
caption,
|
|
51
|
+
kind = 'figure',
|
|
52
|
+
captionPosition,
|
|
53
|
+
captionText,
|
|
54
|
+
style,
|
|
55
|
+
captionStyle,
|
|
56
|
+
className,
|
|
57
|
+
children,
|
|
58
|
+
}: FigureProps) {
|
|
59
|
+
const generated = useId();
|
|
60
|
+
const labelId = id ?? generated;
|
|
61
|
+
const entry = useDocLabel(labelId);
|
|
62
|
+
const vocabulary = useLabelVocabulary();
|
|
63
|
+
const position = captionPosition ?? (kind === 'table' ? 'above' : 'below');
|
|
64
|
+
|
|
65
|
+
const text =
|
|
66
|
+
captionText ??
|
|
67
|
+
(typeof caption === 'string' || typeof caption === 'number' ? String(caption) : '');
|
|
68
|
+
|
|
69
|
+
const captionNode = caption ? (
|
|
70
|
+
<figcaption
|
|
71
|
+
style={{
|
|
72
|
+
fontSize: 'var(--od-size-caption)',
|
|
73
|
+
color: 'var(--od-muted)',
|
|
74
|
+
lineHeight: 1.4,
|
|
75
|
+
margin: position === 'above' ? '0 0 6px' : '6px 0 0',
|
|
76
|
+
...captionStyle,
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<span style={{ fontWeight: 600, color: 'var(--od-text)' }}>
|
|
80
|
+
{nameFor(kind, vocabulary)} {entry?.number ?? ''}
|
|
81
|
+
</span>
|
|
82
|
+
{caption ? <span> โ {caption}</span> : null}
|
|
83
|
+
</figcaption>
|
|
84
|
+
) : null;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<figure
|
|
88
|
+
{...{
|
|
89
|
+
[LABEL_ATTR]: kind,
|
|
90
|
+
[LABEL_ID_ATTR]: labelId,
|
|
91
|
+
[LABEL_TEXT_ATTR]: text,
|
|
92
|
+
}}
|
|
93
|
+
className={className}
|
|
94
|
+
style={{ margin: '0 0 16px', ...style }}
|
|
95
|
+
>
|
|
96
|
+
{position === 'above' && captionNode}
|
|
97
|
+
{children}
|
|
98
|
+
{position === 'below' && captionNode}
|
|
99
|
+
</figure>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type RefProps = {
|
|
104
|
+
/** The `id` of a `<Figure>` or `<Footnote>`. */
|
|
105
|
+
to: string;
|
|
106
|
+
/**
|
|
107
|
+
* Append the page. `auto` (the default) adds it only when the target sits on
|
|
108
|
+
* another sheet, which is the only time a reader needs it.
|
|
109
|
+
*/
|
|
110
|
+
showPage?: boolean | 'auto';
|
|
111
|
+
style?: CSSProperties;
|
|
112
|
+
className?: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A cross-reference that resolves after layout: "Figure 3", plus the page when
|
|
117
|
+
* the target is elsewhere. Never write "see Figure 3 on page 12" by hand โ both
|
|
118
|
+
* numbers move.
|
|
119
|
+
*/
|
|
120
|
+
export function Ref({ to, showPage = 'auto', style, className }: RefProps) {
|
|
121
|
+
const entry = useDocLabel(to);
|
|
122
|
+
const vocabulary = useLabelVocabulary();
|
|
123
|
+
const here = useDocPageNumber();
|
|
124
|
+
|
|
125
|
+
if (!entry) {
|
|
126
|
+
// Visible on the page and reported by `mosage check`: a reference that
|
|
127
|
+
// resolves to nothing is a broken document, not a blank space.
|
|
128
|
+
return (
|
|
129
|
+
<span
|
|
130
|
+
data-od-ref-unresolved={to}
|
|
131
|
+
className={className}
|
|
132
|
+
style={{ color: 'var(--od-accent)', ...style }}
|
|
133
|
+
>
|
|
134
|
+
[?{to}]
|
|
135
|
+
</span>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const name = entry.kind === 'footnote' ? '' : `${nameFor(entry.kind, vocabulary)} `;
|
|
140
|
+
const withPage = showPage === 'auto' ? entry.page !== here : showPage;
|
|
141
|
+
const parts = onPageParts(vocabulary);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<span {...{ [REF_ATTR]: to }} className={className} style={style}>
|
|
145
|
+
{name}
|
|
146
|
+
{entry.number}
|
|
147
|
+
{withPage && parts && (
|
|
148
|
+
<>
|
|
149
|
+
{parts[0]}
|
|
150
|
+
<span {...{ [REF_PAGE_ATTR]: '' }}>{entry.page}</span>
|
|
151
|
+
{parts[1]}
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
{withPage && !parts && vocabulary.onPage}
|
|
155
|
+
</span>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type ListOfProps = {
|
|
160
|
+
kind?: LabelKind;
|
|
161
|
+
/** Show the page number column with dot leaders. Defaults to true. */
|
|
162
|
+
showPageNumbers?: boolean;
|
|
163
|
+
style?: CSSProperties;
|
|
164
|
+
className?: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The document's figures or tables as a contents list. Same lifecycle as
|
|
169
|
+
* `<TableOfContents>`: empty on the first render pass, filled once the scan has
|
|
170
|
+
* run โ including before an export serializes.
|
|
171
|
+
*/
|
|
172
|
+
export function ListOf({ kind = 'figure', showPageNumbers = true, style, className }: ListOfProps) {
|
|
173
|
+
const entries = useDocLabels(kind);
|
|
174
|
+
const vocabulary = useLabelVocabulary();
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div
|
|
178
|
+
{...{ [LIST_OF_ATTR]: kind }}
|
|
179
|
+
className={className}
|
|
180
|
+
style={{
|
|
181
|
+
fontFamily: 'var(--od-font-body)',
|
|
182
|
+
fontSize: 'var(--od-size-body)',
|
|
183
|
+
color: 'var(--od-text)',
|
|
184
|
+
...style,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{entries.map((entry) => (
|
|
188
|
+
<div
|
|
189
|
+
key={entry.id}
|
|
190
|
+
{...{ [REF_ATTR]: entry.id }}
|
|
191
|
+
style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}
|
|
192
|
+
>
|
|
193
|
+
<span style={{ flex: 'none', color: 'var(--od-muted)' }}>
|
|
194
|
+
{nameFor(kind, vocabulary)} {entry.number}
|
|
195
|
+
</span>
|
|
196
|
+
<span>{entry.text}</span>
|
|
197
|
+
{showPageNumbers && (
|
|
198
|
+
<>
|
|
199
|
+
<span
|
|
200
|
+
aria-hidden
|
|
201
|
+
style={{
|
|
202
|
+
flex: 1,
|
|
203
|
+
borderBottom: '1px dotted var(--od-rule)',
|
|
204
|
+
transform: 'translateY(-3px)',
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
<span {...{ [REF_PAGE_ATTR]: '' }} style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
208
|
+
{entry.page}
|
|
209
|
+
</span>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function ListOfFigures(props: Omit<ListOfProps, 'kind'>) {
|
|
219
|
+
return <ListOf {...props} kind="figure" />;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function ListOfTables(props: Omit<ListOfProps, 'kind'>) {
|
|
223
|
+
return <ListOf {...props} kind="table" />;
|
|
224
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
import { type DesignSystem, designToCssVars } from '../lib/design';
|
|
4
|
+
import { PAGE_ATTR, PAGE_INDEX_ATTR } from '../lib/outline';
|
|
5
|
+
import { DocPageProvider } from '../lib/page-context';
|
|
6
|
+
import type { PageGeometry } from '../lib/sdk';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
index: number;
|
|
10
|
+
total: number;
|
|
11
|
+
geometry: PageGeometry;
|
|
12
|
+
scale: number;
|
|
13
|
+
design?: DesignSystem;
|
|
14
|
+
flat?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One sheet of paper. The inner node always lays out at the page's true pixel
|
|
21
|
+
* size โ authors write absolute px against A4 โ and only the wrapper shrinks,
|
|
22
|
+
* so a page looks identical on screen, in the PDF, and in the HTML export.
|
|
23
|
+
*/
|
|
24
|
+
export function PageFrame({
|
|
25
|
+
index,
|
|
26
|
+
total,
|
|
27
|
+
geometry,
|
|
28
|
+
scale,
|
|
29
|
+
design,
|
|
30
|
+
flat = false,
|
|
31
|
+
className,
|
|
32
|
+
children,
|
|
33
|
+
}: Props) {
|
|
34
|
+
const designVars = design ? designToCssVars(design) : undefined;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
'relative flex-none overflow-hidden bg-white text-black',
|
|
40
|
+
!flat && 'shadow-[0_1px_3px_rgba(0,0,0,.28),0_10px_28px_-6px_rgba(0,0,0,.28)]',
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
style={{ width: geometry.width * scale, height: geometry.height * scale }}
|
|
44
|
+
>
|
|
45
|
+
<div
|
|
46
|
+
{...{ [PAGE_ATTR]: '', [PAGE_INDEX_ATTR]: index }}
|
|
47
|
+
style={
|
|
48
|
+
{
|
|
49
|
+
width: geometry.width,
|
|
50
|
+
height: geometry.height,
|
|
51
|
+
transform: `scale(${scale})`,
|
|
52
|
+
transformOrigin: 'top left',
|
|
53
|
+
overflow: 'hidden',
|
|
54
|
+
position: 'relative',
|
|
55
|
+
// A page must render identically wherever it is embedded. The
|
|
56
|
+
// thumbnail rail wraps pages in a <button>, whose default
|
|
57
|
+
// `text-align: center` would otherwise leak into the content.
|
|
58
|
+
textAlign: 'start',
|
|
59
|
+
...(designVars ?? {}),
|
|
60
|
+
...(designVars ? { background: 'var(--od-bg)', color: 'var(--od-text)' } : {}),
|
|
61
|
+
} as CSSProperties
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<DocPageProvider index={index} total={total}>
|
|
65
|
+
{children}
|
|
66
|
+
</DocPageProvider>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { FileText, Image, MoreHorizontal, Palette, PencilLine, Trash2 } from 'lucide-react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import type { Folder, FolderIcon } from '../../lib/sdk';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { Menu, MenuItem, MenuSeparator } from '../ui/menu';
|
|
6
|
+
import { IconPicker } from './icon-picker';
|
|
7
|
+
|
|
8
|
+
export const DOC_DND_MIME = 'application/x-mosage-id';
|
|
9
|
+
|
|
10
|
+
export type FolderRow =
|
|
11
|
+
| { kind: 'all' }
|
|
12
|
+
| { kind: 'themes' }
|
|
13
|
+
| { kind: 'assets' }
|
|
14
|
+
| { kind: 'draft' }
|
|
15
|
+
| {
|
|
16
|
+
kind: 'folder';
|
|
17
|
+
folder: Folder;
|
|
18
|
+
onRename: (name: string) => void;
|
|
19
|
+
onChangeIcon: (icon: FolderIcon) => void;
|
|
20
|
+
onDelete: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function FolderIconChip({ icon }: { icon: FolderIcon }) {
|
|
24
|
+
if (icon.type === 'emoji') {
|
|
25
|
+
return (
|
|
26
|
+
<span className="grid size-5 shrink-0 place-items-center text-[13px] leading-none">
|
|
27
|
+
{icon.value}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
<span
|
|
33
|
+
className="size-3 shrink-0 rounded-[3px] ring-1 ring-black/10"
|
|
34
|
+
style={{ background: icon.value }}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STATIC_ICON = {
|
|
40
|
+
all: FileText,
|
|
41
|
+
themes: Palette,
|
|
42
|
+
assets: Image,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export function FolderItem({
|
|
46
|
+
row,
|
|
47
|
+
count,
|
|
48
|
+
selected,
|
|
49
|
+
onSelect,
|
|
50
|
+
onDropDoc,
|
|
51
|
+
}: {
|
|
52
|
+
row: FolderRow;
|
|
53
|
+
count: number;
|
|
54
|
+
selected: boolean;
|
|
55
|
+
onSelect: () => void;
|
|
56
|
+
onDropDoc: (docId: string) => void;
|
|
57
|
+
}) {
|
|
58
|
+
const [renaming, setRenaming] = useState(false);
|
|
59
|
+
const [draft, setDraft] = useState('');
|
|
60
|
+
const [dragOver, setDragOver] = useState(false);
|
|
61
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (renaming) inputRef.current?.select();
|
|
65
|
+
}, [renaming]);
|
|
66
|
+
|
|
67
|
+
const label =
|
|
68
|
+
row.kind === 'folder'
|
|
69
|
+
? row.folder.name
|
|
70
|
+
: row.kind === 'all'
|
|
71
|
+
? 'Documents'
|
|
72
|
+
: row.kind === 'themes'
|
|
73
|
+
? 'Themes'
|
|
74
|
+
: row.kind === 'assets'
|
|
75
|
+
? 'Assets'
|
|
76
|
+
: 'Unfiled';
|
|
77
|
+
|
|
78
|
+
const acceptsDrop = row.kind === 'folder' || row.kind === 'draft';
|
|
79
|
+
|
|
80
|
+
const commitRename = () => {
|
|
81
|
+
if (row.kind !== 'folder') return;
|
|
82
|
+
const next = draft.trim();
|
|
83
|
+
setRenaming(false);
|
|
84
|
+
if (next && next !== row.folder.name) row.onRename(next);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: drop target wraps the button, which stays the keyboard path
|
|
89
|
+
<div
|
|
90
|
+
className={cn(
|
|
91
|
+
'group relative flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors',
|
|
92
|
+
selected ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/60',
|
|
93
|
+
dragOver && 'ring-1 ring-foreground/40',
|
|
94
|
+
)}
|
|
95
|
+
onDragOver={(e) => {
|
|
96
|
+
if (!acceptsDrop || !e.dataTransfer.types.includes(DOC_DND_MIME)) return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
e.dataTransfer.dropEffect = 'move';
|
|
99
|
+
setDragOver(true);
|
|
100
|
+
}}
|
|
101
|
+
onDragLeave={() => setDragOver(false)}
|
|
102
|
+
onDrop={(e) => {
|
|
103
|
+
if (!acceptsDrop) return;
|
|
104
|
+
const docId = e.dataTransfer.getData(DOC_DND_MIME);
|
|
105
|
+
setDragOver(false);
|
|
106
|
+
if (!docId) return;
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
onDropDoc(docId);
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{row.kind === 'folder' ? (
|
|
112
|
+
<Menu
|
|
113
|
+
placement="right-start"
|
|
114
|
+
trigger={(props) => (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
aria-label="Change icon"
|
|
118
|
+
className="shrink-0 transition-transform hover:scale-110"
|
|
119
|
+
{...props}
|
|
120
|
+
>
|
|
121
|
+
<FolderIconChip icon={row.folder.icon} />
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{() => (
|
|
126
|
+
<div className="p-1">
|
|
127
|
+
<IconPicker value={row.folder.icon} onChange={row.onChangeIcon} />
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</Menu>
|
|
131
|
+
) : row.kind === 'draft' ? (
|
|
132
|
+
<FolderIconChip icon={{ type: 'color', value: '#9ca3af' }} />
|
|
133
|
+
) : (
|
|
134
|
+
(() => {
|
|
135
|
+
const Icon = STATIC_ICON[row.kind];
|
|
136
|
+
return <Icon className="size-3.5 shrink-0" />;
|
|
137
|
+
})()
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{renaming && row.kind === 'folder' ? (
|
|
141
|
+
<input
|
|
142
|
+
ref={inputRef}
|
|
143
|
+
value={draft}
|
|
144
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
145
|
+
onBlur={commitRename}
|
|
146
|
+
onKeyDown={(e) => {
|
|
147
|
+
if (e.nativeEvent.isComposing) return;
|
|
148
|
+
if (e.key === 'Enter') commitRename();
|
|
149
|
+
if (e.key === 'Escape') setRenaming(false);
|
|
150
|
+
}}
|
|
151
|
+
maxLength={40}
|
|
152
|
+
className="min-w-0 flex-1 bg-transparent text-[13px] outline-none"
|
|
153
|
+
/>
|
|
154
|
+
) : (
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={onSelect}
|
|
158
|
+
className="min-w-0 flex-1 truncate text-left text-[13px]"
|
|
159
|
+
>
|
|
160
|
+
{label}
|
|
161
|
+
</button>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
<span className="font-mono text-[10px] text-muted-foreground tabular-nums group-hover:opacity-0">
|
|
165
|
+
{String(count).padStart(2, '0')}
|
|
166
|
+
</span>
|
|
167
|
+
|
|
168
|
+
{row.kind === 'folder' && import.meta.env.DEV && (
|
|
169
|
+
<div className="absolute right-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
170
|
+
<Menu
|
|
171
|
+
trigger={(props) => (
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
aria-label={`${row.folder.name} options`}
|
|
175
|
+
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-background hover:text-foreground aria-expanded:bg-background"
|
|
176
|
+
{...props}
|
|
177
|
+
>
|
|
178
|
+
<MoreHorizontal className="size-3.5" />
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
{(close) => (
|
|
183
|
+
<>
|
|
184
|
+
<MenuItem
|
|
185
|
+
onClick={() => {
|
|
186
|
+
setDraft(row.folder.name);
|
|
187
|
+
setRenaming(true);
|
|
188
|
+
close();
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<PencilLine className="size-3.5" />
|
|
192
|
+
Rename
|
|
193
|
+
</MenuItem>
|
|
194
|
+
<MenuSeparator />
|
|
195
|
+
<MenuItem
|
|
196
|
+
destructive
|
|
197
|
+
onClick={() => {
|
|
198
|
+
close();
|
|
199
|
+
row.onDelete();
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
<Trash2 className="size-3.5" />
|
|
203
|
+
Delete folder
|
|
204
|
+
</MenuItem>
|
|
205
|
+
</>
|
|
206
|
+
)}
|
|
207
|
+
</Menu>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { FolderIcon } from '../../lib/sdk';
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
|
|
5
|
+
export const PRESET_COLORS = [
|
|
6
|
+
'#e5484d',
|
|
7
|
+
'#f76b15',
|
|
8
|
+
'#ffb224',
|
|
9
|
+
'#30a46c',
|
|
10
|
+
'#12a594',
|
|
11
|
+
'#0091ff',
|
|
12
|
+
'#6e56cf',
|
|
13
|
+
'#60646c',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// A curated set beats an emoji-picker dependency for a folder chip.
|
|
17
|
+
const PRESET_EMOJI = [
|
|
18
|
+
'๐',
|
|
19
|
+
'๐',
|
|
20
|
+
'๐',
|
|
21
|
+
'๐',
|
|
22
|
+
'๐งพ',
|
|
23
|
+
'๐๏ธ',
|
|
24
|
+
'๐',
|
|
25
|
+
'โญ',
|
|
26
|
+
'๐๏ธ',
|
|
27
|
+
'๐ผ',
|
|
28
|
+
'๐งช',
|
|
29
|
+
'โ๏ธ',
|
|
30
|
+
'๐',
|
|
31
|
+
'๐',
|
|
32
|
+
'๐ฐ',
|
|
33
|
+
'๐๏ธ',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export function IconPicker({
|
|
37
|
+
value,
|
|
38
|
+
onChange,
|
|
39
|
+
}: {
|
|
40
|
+
value: FolderIcon;
|
|
41
|
+
onChange: (icon: FolderIcon) => void;
|
|
42
|
+
}) {
|
|
43
|
+
const [tab, setTab] = useState<'emoji' | 'color'>(value.type);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="w-[232px]">
|
|
47
|
+
<div className="mb-2 flex gap-1 rounded-md bg-muted p-0.5">
|
|
48
|
+
{(['emoji', 'color'] as const).map((next) => (
|
|
49
|
+
<button
|
|
50
|
+
key={next}
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => setTab(next)}
|
|
53
|
+
className={cn(
|
|
54
|
+
'flex-1 rounded px-2 py-1 text-[11px] capitalize transition-colors',
|
|
55
|
+
tab === next ? 'bg-background font-medium' : 'text-muted-foreground',
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{next}
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{tab === 'emoji' ? (
|
|
64
|
+
<div className="grid grid-cols-8 gap-1">
|
|
65
|
+
{PRESET_EMOJI.map((emoji) => (
|
|
66
|
+
<button
|
|
67
|
+
key={emoji}
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => onChange({ type: 'emoji', value: emoji })}
|
|
70
|
+
className={cn(
|
|
71
|
+
'grid size-6 place-items-center rounded text-[13px] transition-transform hover:scale-110',
|
|
72
|
+
value.type === 'emoji' && value.value === emoji && 'bg-accent',
|
|
73
|
+
)}
|
|
74
|
+
aria-label={emoji}
|
|
75
|
+
>
|
|
76
|
+
{emoji}
|
|
77
|
+
</button>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
<div className="grid grid-cols-8 gap-1">
|
|
82
|
+
{PRESET_COLORS.map((color) => (
|
|
83
|
+
<button
|
|
84
|
+
key={color}
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => onChange({ type: 'color', value: color })}
|
|
87
|
+
className={cn(
|
|
88
|
+
'size-6 rounded ring-1 ring-black/10 transition-transform hover:scale-110',
|
|
89
|
+
value.type === 'color' && value.value === color && 'ring-2 ring-foreground',
|
|
90
|
+
)}
|
|
91
|
+
style={{ background: color }}
|
|
92
|
+
aria-label={color}
|
|
93
|
+
/>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|