@autono/open-pages 0.1.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 +23 -0
- package/README.md +108 -0
- package/bin.js +2 -0
- package/dist/build-TP72kiw7.js +35 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +100 -0
- package/dist/config-C9or4m4J.d.ts +23 -0
- package/dist/config-L0IbktJ0.js +3135 -0
- package/dist/design-C13iz9_4.js +35 -0
- package/dist/dev-I2IyEh5W.js +97 -0
- package/dist/export-B-kBRWB1.js +4 -0
- package/dist/export-ic28osQP.js +131 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +3 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +881 -0
- package/dist/open-pages-plugin-D_DN_zjh.js +632 -0
- package/dist/preview-BrDuvgAN.js +21 -0
- package/dist/sync-Be_hxSOF.js +139 -0
- package/dist/sync-lKEXyPOh.js +3 -0
- package/dist/types-C5NCdDT-.d.ts +219 -0
- package/dist/update-CV62NhBT.js +37 -0
- package/dist/update-package-Cxt57pz7.js +152 -0
- package/dist/version-B6AiiGvT.js +26 -0
- package/dist/version-CeffgdfZ.js +3 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +7 -0
- package/env.d.ts +59 -0
- package/package.json +106 -0
- package/skills/apply-comments/SKILL.md +86 -0
- package/skills/create-page/SKILL.md +92 -0
- package/skills/create-theme/SKILL.md +223 -0
- package/skills/current-page/SKILL.md +106 -0
- package/skills/page-authoring/SKILL.md +148 -0
- package/skills/page-authoring/references/assets-and-fonts.md +85 -0
- package/skills/page-authoring/references/html-pages.md +65 -0
- package/skills/page-authoring/references/interactivity.md +77 -0
- package/skills/page-authoring/references/layout-and-responsive.md +71 -0
- package/skills/page-authoring/references/typography-and-color.md +60 -0
- package/src/app/app.tsx +45 -0
- package/src/app/components/asset-view.tsx +1665 -0
- package/src/app/components/command/command-menu.tsx +227 -0
- package/src/app/components/command/command.tsx +142 -0
- package/src/app/components/command/home-command-menu.tsx +96 -0
- package/src/app/components/icon-tooltip.tsx +34 -0
- package/src/app/components/language-toggle.tsx +46 -0
- package/src/app/components/sidebar/folder-item.tsx +269 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +139 -0
- package/src/app/components/sidebar/sidebar.tsx +302 -0
- package/src/app/components/theme-toggle.tsx +66 -0
- package/src/app/components/themes/theme-detail.tsx +226 -0
- package/src/app/components/themes/themes-gallery.tsx +104 -0
- package/src/app/components/ui/badge.tsx +48 -0
- package/src/app/components/ui/button.tsx +93 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +247 -0
- package/src/app/components/ui/dialog.tsx +155 -0
- package/src/app/components/ui/dropdown-menu.tsx +267 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +21 -0
- package/src/app/components/ui/popover.tsx +82 -0
- package/src/app/components/ui/progress.tsx +33 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +195 -0
- package/src/app/components/ui/separator.tsx +26 -0
- package/src/app/components/ui/slider.tsx +68 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +84 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +75 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/frame/inspect.ts +140 -0
- package/src/app/frame/main.tsx +91 -0
- package/src/app/frame.html +12 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/asset-filter.test.ts +176 -0
- package/src/app/lib/asset-filter.ts +80 -0
- package/src/app/lib/assets.ts +257 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/frame.ts +38 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-thumb.tsx +46 -0
- package/src/app/lib/pages.ts +28 -0
- package/src/app/lib/sdk.ts +43 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-page-module.ts +48 -0
- package/src/app/lib/use-page-titles.ts +29 -0
- package/src/app/lib/use-restart-server.ts +78 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +252 -0
- package/src/app/routes/home.tsx +846 -0
- package/src/app/routes/page.tsx +329 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +405 -0
- package/src/app/virtual.d.ts +53 -0
- package/src/locale/en.ts +218 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +221 -0
- package/src/locale/types.ts +215 -0
- package/src/locale/zh-cn.ts +218 -0
- package/src/locale/zh-tw.ts +218 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>open-pages</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="./frame/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" href="./favicon.ico" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>open-pages</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="./main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { filterAssets, sortAssets } from './asset-filter.ts';
|
|
3
|
+
import type { AssetEntry } from './assets.ts';
|
|
4
|
+
|
|
5
|
+
const assets: AssetEntry[] = [
|
|
6
|
+
{
|
|
7
|
+
name: 'Brand Hero.PNG',
|
|
8
|
+
size: 100,
|
|
9
|
+
createdAt: 1,
|
|
10
|
+
mtime: 1,
|
|
11
|
+
mime: 'image/png',
|
|
12
|
+
url: '/brand-hero.png',
|
|
13
|
+
unused: false,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'draft-hero.svg',
|
|
17
|
+
size: 200,
|
|
18
|
+
createdAt: 2,
|
|
19
|
+
mtime: 2,
|
|
20
|
+
mime: 'IMAGE/SVG+XML',
|
|
21
|
+
url: '/draft-hero.svg',
|
|
22
|
+
unused: true,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'display.woff2',
|
|
26
|
+
size: 300,
|
|
27
|
+
createdAt: 3,
|
|
28
|
+
mtime: 3,
|
|
29
|
+
mime: 'font/woff2',
|
|
30
|
+
url: '/display.woff2',
|
|
31
|
+
unused: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'intro.webm',
|
|
35
|
+
size: 400,
|
|
36
|
+
createdAt: 4,
|
|
37
|
+
mtime: 4,
|
|
38
|
+
mime: 'video/webm',
|
|
39
|
+
url: '/intro.webm',
|
|
40
|
+
unused: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'metadata.json',
|
|
44
|
+
size: 500,
|
|
45
|
+
createdAt: 5,
|
|
46
|
+
mtime: 5,
|
|
47
|
+
mime: 'application/json',
|
|
48
|
+
url: '/metadata.json',
|
|
49
|
+
unused: false,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const names = (entries: AssetEntry[]) => entries.map((asset) => asset.name);
|
|
54
|
+
|
|
55
|
+
describe('filterAssets', () => {
|
|
56
|
+
it('filters assets by usage', () => {
|
|
57
|
+
expect(names(filterAssets(assets, { usage: 'used', type: 'all', search: '' }))).toEqual([
|
|
58
|
+
'Brand Hero.PNG',
|
|
59
|
+
'intro.webm',
|
|
60
|
+
'metadata.json',
|
|
61
|
+
]);
|
|
62
|
+
expect(names(filterAssets(assets, { usage: 'unused', type: 'all', search: '' }))).toEqual([
|
|
63
|
+
'draft-hero.svg',
|
|
64
|
+
'display.woff2',
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
['image', ['Brand Hero.PNG', 'draft-hero.svg']],
|
|
70
|
+
['font', ['display.woff2']],
|
|
71
|
+
['video', ['intro.webm']],
|
|
72
|
+
['other', ['metadata.json']],
|
|
73
|
+
] as const)('filters %s MIME types', (type, expected) => {
|
|
74
|
+
expect(names(filterAssets(assets, { usage: 'all', type, search: '' }))).toEqual(expected);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('matches trimmed filename searches case-insensitively', () => {
|
|
78
|
+
expect(names(filterAssets(assets, { usage: 'all', type: 'all', search: ' HERO ' }))).toEqual([
|
|
79
|
+
'Brand Hero.PNG',
|
|
80
|
+
'draft-hero.svg',
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('applies usage, type, and search filters together', () => {
|
|
85
|
+
expect(
|
|
86
|
+
names(filterAssets(assets, { usage: 'unused', type: 'image', search: 'DRAFT' })),
|
|
87
|
+
).toEqual(['draft-hero.svg']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns all assets when every filter is inactive', () => {
|
|
91
|
+
expect(filterAssets(assets, { usage: 'all', type: 'all', search: ' ' })).toEqual(assets);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('sortAssets', () => {
|
|
96
|
+
const sortableAssets: AssetEntry[] = [
|
|
97
|
+
{
|
|
98
|
+
name: 'image-10.png',
|
|
99
|
+
size: 300,
|
|
100
|
+
createdAt: 1,
|
|
101
|
+
mtime: 200,
|
|
102
|
+
mime: 'image/png',
|
|
103
|
+
url: '/image-10.png',
|
|
104
|
+
unused: false,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'Image-2.svg',
|
|
108
|
+
size: 100,
|
|
109
|
+
createdAt: 2,
|
|
110
|
+
mtime: 300,
|
|
111
|
+
mime: 'IMAGE/SVG+XML',
|
|
112
|
+
url: '/image-2.svg',
|
|
113
|
+
unused: false,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'clip.webm',
|
|
117
|
+
size: 200,
|
|
118
|
+
createdAt: 3,
|
|
119
|
+
mtime: 100,
|
|
120
|
+
mime: 'video/webm',
|
|
121
|
+
url: '/clip.webm',
|
|
122
|
+
unused: false,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
it.each([
|
|
127
|
+
['name', 'asc', ['clip.webm', 'Image-2.svg', 'image-10.png']],
|
|
128
|
+
['name', 'desc', ['image-10.png', 'Image-2.svg', 'clip.webm']],
|
|
129
|
+
['modified', 'asc', ['clip.webm', 'image-10.png', 'Image-2.svg']],
|
|
130
|
+
['modified', 'desc', ['Image-2.svg', 'image-10.png', 'clip.webm']],
|
|
131
|
+
['size', 'asc', ['Image-2.svg', 'clip.webm', 'image-10.png']],
|
|
132
|
+
['size', 'desc', ['image-10.png', 'clip.webm', 'Image-2.svg']],
|
|
133
|
+
['type', 'asc', ['image-10.png', 'Image-2.svg', 'clip.webm']],
|
|
134
|
+
['type', 'desc', ['clip.webm', 'Image-2.svg', 'image-10.png']],
|
|
135
|
+
] as const)('sorts by %s %s', (key, direction, expected) => {
|
|
136
|
+
expect(names(sortAssets(sortableAssets, { key, direction }))).toEqual(expected);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('sorts names naturally and case-insensitively', () => {
|
|
140
|
+
const naturalNames = [
|
|
141
|
+
{ ...sortableAssets[0], name: 'Page 11.png' },
|
|
142
|
+
{ ...sortableAssets[0], name: 'page 2.png' },
|
|
143
|
+
{ ...sortableAssets[0], name: 'PAGE 1.png' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
expect(names(sortAssets(naturalNames, { key: 'name', direction: 'asc' }))).toEqual([
|
|
147
|
+
'PAGE 1.png',
|
|
148
|
+
'page 2.png',
|
|
149
|
+
'Page 11.png',
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('breaks primary-value ties by name ascending for both directions', () => {
|
|
154
|
+
const tiedAssets = [
|
|
155
|
+
{ ...sortableAssets[0], name: 'zebra.png', size: 100 },
|
|
156
|
+
{ ...sortableAssets[1], name: 'alpha.svg', size: 100 },
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
expect(names(sortAssets(tiedAssets, { key: 'size', direction: 'asc' }))).toEqual([
|
|
160
|
+
'alpha.svg',
|
|
161
|
+
'zebra.png',
|
|
162
|
+
]);
|
|
163
|
+
expect(names(sortAssets(tiedAssets, { key: 'size', direction: 'desc' }))).toEqual([
|
|
164
|
+
'alpha.svg',
|
|
165
|
+
'zebra.png',
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does not mutate the input', () => {
|
|
170
|
+
const originalOrder = [...sortableAssets];
|
|
171
|
+
const sorted = sortAssets(sortableAssets, { key: 'name', direction: 'asc' });
|
|
172
|
+
|
|
173
|
+
expect(sortableAssets).toEqual(originalOrder);
|
|
174
|
+
expect(sorted).not.toBe(sortableAssets);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AssetEntry } from './assets';
|
|
2
|
+
|
|
3
|
+
export type AssetUsageFilter = 'all' | 'used' | 'unused';
|
|
4
|
+
export type AssetTypeFilter = 'all' | 'image' | 'font' | 'video' | 'other';
|
|
5
|
+
export type AssetSortKey = 'name' | 'modified' | 'size' | 'type';
|
|
6
|
+
export type AssetSortDirection = 'asc' | 'desc';
|
|
7
|
+
|
|
8
|
+
export type AssetFilterOptions = {
|
|
9
|
+
usage: AssetUsageFilter;
|
|
10
|
+
type: AssetTypeFilter;
|
|
11
|
+
search: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AssetSortOptions = {
|
|
15
|
+
key: AssetSortKey;
|
|
16
|
+
direction: AssetSortDirection;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type SpecificAssetType = Exclude<AssetTypeFilter, 'all'>;
|
|
20
|
+
|
|
21
|
+
function assetType(mime: string): SpecificAssetType {
|
|
22
|
+
const normalizedMime = mime.toLowerCase();
|
|
23
|
+
if (normalizedMime.startsWith('image/')) return 'image';
|
|
24
|
+
if (normalizedMime.startsWith('font/')) return 'font';
|
|
25
|
+
if (normalizedMime.startsWith('video/')) return 'video';
|
|
26
|
+
return 'other';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function filterAssets(
|
|
30
|
+
assets: readonly AssetEntry[],
|
|
31
|
+
{ usage, type, search }: AssetFilterOptions,
|
|
32
|
+
): AssetEntry[] {
|
|
33
|
+
const query = search.trim().toLowerCase();
|
|
34
|
+
const filtered: AssetEntry[] = [];
|
|
35
|
+
|
|
36
|
+
for (const asset of assets) {
|
|
37
|
+
if (usage === 'used' && asset.unused) continue;
|
|
38
|
+
if (usage === 'unused' && !asset.unused) continue;
|
|
39
|
+
if (type !== 'all' && assetType(asset.mime) !== type) continue;
|
|
40
|
+
if (query && !asset.name.toLowerCase().includes(query)) continue;
|
|
41
|
+
filtered.push(asset);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return filtered;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const nameCollator = new Intl.Collator(undefined, {
|
|
48
|
+
numeric: true,
|
|
49
|
+
sensitivity: 'base',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function compareNames(left: AssetEntry, right: AssetEntry): number {
|
|
53
|
+
return nameCollator.compare(left.name, right.name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function comparePrimary(left: AssetEntry, right: AssetEntry, key: AssetSortKey): number {
|
|
57
|
+
switch (key) {
|
|
58
|
+
case 'name':
|
|
59
|
+
return compareNames(left, right);
|
|
60
|
+
case 'modified':
|
|
61
|
+
return left.mtime - right.mtime;
|
|
62
|
+
case 'size':
|
|
63
|
+
return left.size - right.size;
|
|
64
|
+
case 'type':
|
|
65
|
+
return left.mime.toLowerCase().localeCompare(right.mime.toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function sortAssets(
|
|
70
|
+
assets: readonly AssetEntry[],
|
|
71
|
+
{ key, direction }: AssetSortOptions,
|
|
72
|
+
): AssetEntry[] {
|
|
73
|
+
return [...assets].sort((left, right) => {
|
|
74
|
+
const primaryComparison = comparePrimary(left, right, key);
|
|
75
|
+
if (primaryComparison !== 0) {
|
|
76
|
+
return direction === 'asc' ? primaryComparison : -primaryComparison;
|
|
77
|
+
}
|
|
78
|
+
return compareNames(left, right);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export type AssetEntry = {
|
|
4
|
+
name: string;
|
|
5
|
+
size: number;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
mtime: number;
|
|
8
|
+
mime: string;
|
|
9
|
+
url: string;
|
|
10
|
+
unused: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type UploadOptions = { overwrite?: boolean };
|
|
14
|
+
|
|
15
|
+
export async function listAssets(pageId: string): Promise<AssetEntry[]> {
|
|
16
|
+
const res = await fetch(`/__assets/${pageId}`);
|
|
17
|
+
if (!res.ok) throw new Error(`GET /__assets/${pageId} ${res.status}`);
|
|
18
|
+
const data = (await res.json()) as { assets?: AssetEntry[] };
|
|
19
|
+
return data.assets ?? [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function uploadAsset(
|
|
23
|
+
pageId: string,
|
|
24
|
+
file: File,
|
|
25
|
+
opts: UploadOptions = {},
|
|
26
|
+
): Promise<Response> {
|
|
27
|
+
const qs = opts.overwrite ? '?overwrite=1' : '';
|
|
28
|
+
return fetch(`/__assets/${pageId}/${encodeURIComponent(file.name)}${qs}`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: {
|
|
31
|
+
'content-type': file.type || 'application/octet-stream',
|
|
32
|
+
'content-length': String(file.size),
|
|
33
|
+
},
|
|
34
|
+
body: file,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function renameAsset(pageId: string, from: string, to: string): Promise<Response> {
|
|
39
|
+
return fetch(`/__assets/${pageId}/${encodeURIComponent(from)}`, {
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
headers: { 'content-type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ name: to }),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function deleteAsset(pageId: string, name: string): Promise<Response> {
|
|
47
|
+
return fetch(`/__assets/${pageId}/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AssetUsage = { pageId: string; count: number };
|
|
51
|
+
|
|
52
|
+
export async function listAssetUsages(pageId: string, name: string): Promise<AssetUsage[]> {
|
|
53
|
+
const res = await fetch(`/__assets/${pageId}/${encodeURIComponent(name)}/usages`);
|
|
54
|
+
if (!res.ok) return [];
|
|
55
|
+
const data = (await res.json().catch(() => null)) as { usages?: AssetUsage[] } | null;
|
|
56
|
+
return data?.usages ?? [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function revertAssetUsage(
|
|
60
|
+
pageId: string,
|
|
61
|
+
assetPath: string,
|
|
62
|
+
): Promise<{ ok: boolean; status: number }> {
|
|
63
|
+
const res = await fetch('/__edit/revert-asset', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ pageId, assetPath }),
|
|
67
|
+
});
|
|
68
|
+
return { ok: res.ok, status: res.status };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function uploadWithAutoRename(
|
|
72
|
+
pageId: string,
|
|
73
|
+
file: File,
|
|
74
|
+
): Promise<{ ok: boolean; status: number; entry: AssetEntry | null }> {
|
|
75
|
+
// Vite's default `assetsInclude` matches asset extensions case-sensitively,
|
|
76
|
+
// so `<img src="./assets/foo.JPG" />` (which the placeholder edit rewrites
|
|
77
|
+
// into a real `import`) fails to parse. Lowercase the extension so the
|
|
78
|
+
// import path is always one Vite recognizes.
|
|
79
|
+
let uploaded = lowercaseExtension(file);
|
|
80
|
+
let res = await uploadAsset(pageId, uploaded);
|
|
81
|
+
if (res.status === 409) {
|
|
82
|
+
const list = await listAssets(pageId);
|
|
83
|
+
const taken = new Set(list.map((a) => a.name));
|
|
84
|
+
uploaded = renamedCopy(uploaded, taken);
|
|
85
|
+
res = await uploadAsset(pageId, uploaded);
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok) return { ok: false, status: res.status, entry: null };
|
|
88
|
+
const body = (await res.json().catch(() => null)) as Partial<AssetEntry> | null;
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const entry: AssetEntry = {
|
|
91
|
+
name: body?.name ?? uploaded.name,
|
|
92
|
+
size: body?.size ?? uploaded.size,
|
|
93
|
+
createdAt: body?.createdAt ?? now,
|
|
94
|
+
mtime: body?.mtime ?? now,
|
|
95
|
+
mime: body?.mime ?? uploaded.type ?? 'application/octet-stream',
|
|
96
|
+
url: body?.url ?? `/__assets/${pageId}/${encodeURIComponent(uploaded.name)}`,
|
|
97
|
+
unused: body?.unused ?? false,
|
|
98
|
+
};
|
|
99
|
+
return { ok: true, status: res.status, entry };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lowercaseExtension(file: File): File {
|
|
103
|
+
const dot = file.name.lastIndexOf('.');
|
|
104
|
+
if (dot <= 0) return file;
|
|
105
|
+
const ext = file.name.slice(dot);
|
|
106
|
+
const lower = ext.toLowerCase();
|
|
107
|
+
if (ext === lower) return file;
|
|
108
|
+
return new File([file], file.name.slice(0, dot) + lower, {
|
|
109
|
+
type: file.type,
|
|
110
|
+
lastModified: file.lastModified,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function renamedCopy(file: File, taken: Set<string>): File {
|
|
115
|
+
const dot = file.name.lastIndexOf('.');
|
|
116
|
+
const stem = dot > 0 ? file.name.slice(0, dot) : file.name;
|
|
117
|
+
const ext = dot > 0 ? file.name.slice(dot) : '';
|
|
118
|
+
let i = 1;
|
|
119
|
+
let next = `${stem}-${i}${ext}`;
|
|
120
|
+
while (taken.has(next)) {
|
|
121
|
+
i += 1;
|
|
122
|
+
next = `${stem}-${i}${ext}`;
|
|
123
|
+
}
|
|
124
|
+
return new File([file], next, { type: file.type, lastModified: file.lastModified });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type SvglItem = {
|
|
128
|
+
id: number;
|
|
129
|
+
title: string;
|
|
130
|
+
category: string | string[];
|
|
131
|
+
route: string | { light: string; dark: string };
|
|
132
|
+
url: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export async function searchSvgl(query: string, signal?: AbortSignal): Promise<SvglItem[]> {
|
|
136
|
+
const q = query.trim();
|
|
137
|
+
const params = new URLSearchParams();
|
|
138
|
+
if (q) params.set('q', q);
|
|
139
|
+
else params.set('limit', '24');
|
|
140
|
+
const res = await fetch(`/__svgl/search?${params.toString()}`, { signal });
|
|
141
|
+
// svgl returns 404 when a search has no matches — treat it as an empty list,
|
|
142
|
+
// not an error.
|
|
143
|
+
if (res.status === 404) return [];
|
|
144
|
+
if (!res.ok) throw new Error(`svgl ${res.status}`);
|
|
145
|
+
return (await res.json()) as SvglItem[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function svgProxyUrl(routeUrl: string): string {
|
|
149
|
+
return `/__svgl/svg?u=${encodeURIComponent(routeUrl)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function fetchSvgAsFile(routeUrl: string, filename: string): Promise<File> {
|
|
153
|
+
const res = await fetch(svgProxyUrl(routeUrl));
|
|
154
|
+
if (!res.ok) throw new Error(`svgl route ${res.status}`);
|
|
155
|
+
const blob = await res.blob();
|
|
156
|
+
return new File([blob], filename, { type: 'image/svg+xml' });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type UseAssetsResult = {
|
|
160
|
+
assets: AssetEntry[];
|
|
161
|
+
loading: boolean;
|
|
162
|
+
available: boolean;
|
|
163
|
+
upload: (file: File, opts?: UploadOptions) => Promise<{ ok: boolean; status: number }>;
|
|
164
|
+
rename: (from: string, to: string) => Promise<{ ok: boolean; status: number }>;
|
|
165
|
+
remove: (name: string) => Promise<{ ok: boolean; status: number }>;
|
|
166
|
+
refresh: () => Promise<void>;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const NOOP_RESULT = { ok: false, status: 0 } as const;
|
|
170
|
+
|
|
171
|
+
export function useAssets(pageId: string): UseAssetsResult {
|
|
172
|
+
const available = import.meta.env.DEV;
|
|
173
|
+
const [assets, setAssets] = useState<AssetEntry[]>([]);
|
|
174
|
+
const [loading, setLoading] = useState(available);
|
|
175
|
+
|
|
176
|
+
const refresh = useCallback(async () => {
|
|
177
|
+
if (!available) return;
|
|
178
|
+
const next = await listAssets(pageId);
|
|
179
|
+
setAssets(next);
|
|
180
|
+
}, [pageId]);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!available) return;
|
|
184
|
+
let cancelled = false;
|
|
185
|
+
setLoading(true);
|
|
186
|
+
listAssets(pageId)
|
|
187
|
+
.then((next) => {
|
|
188
|
+
if (!cancelled) {
|
|
189
|
+
setAssets(next);
|
|
190
|
+
setLoading(false);
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
.catch(() => {
|
|
194
|
+
if (!cancelled) setLoading(false);
|
|
195
|
+
});
|
|
196
|
+
return () => {
|
|
197
|
+
cancelled = true;
|
|
198
|
+
};
|
|
199
|
+
}, [pageId]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!available || !import.meta.hot) return;
|
|
203
|
+
const assetHandler = (data: { pageId?: string } | undefined) => {
|
|
204
|
+
if (!data || data.pageId === pageId) {
|
|
205
|
+
refresh().catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const docHandler = (data: { pageId?: unknown; pageIds?: unknown } | undefined) => {
|
|
209
|
+
const changedIds = Array.isArray(data?.pageIds)
|
|
210
|
+
? data.pageIds
|
|
211
|
+
: typeof data?.pageId === 'string'
|
|
212
|
+
? [data.pageId]
|
|
213
|
+
: [];
|
|
214
|
+
if (pageId === '@global' ? changedIds.length > 0 : changedIds.includes(pageId)) {
|
|
215
|
+
refresh().catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
import.meta.hot.on('open-pages:assets-changed', assetHandler);
|
|
219
|
+
import.meta.hot.on('open-pages:page-changed', docHandler);
|
|
220
|
+
return () => {
|
|
221
|
+
import.meta.hot?.off('open-pages:assets-changed', assetHandler);
|
|
222
|
+
import.meta.hot?.off('open-pages:page-changed', docHandler);
|
|
223
|
+
};
|
|
224
|
+
}, [pageId, refresh]);
|
|
225
|
+
|
|
226
|
+
const upload = useCallback(
|
|
227
|
+
async (file: File, opts?: UploadOptions) => {
|
|
228
|
+
if (!available) return NOOP_RESULT;
|
|
229
|
+
const res = await uploadAsset(pageId, file, opts);
|
|
230
|
+
if (res.ok) await refresh();
|
|
231
|
+
return { ok: res.ok, status: res.status };
|
|
232
|
+
},
|
|
233
|
+
[pageId, refresh],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const rename = useCallback(
|
|
237
|
+
async (from: string, to: string) => {
|
|
238
|
+
if (!available) return NOOP_RESULT;
|
|
239
|
+
const res = await renameAsset(pageId, from, to);
|
|
240
|
+
if (res.ok) await refresh();
|
|
241
|
+
return { ok: res.ok, status: res.status };
|
|
242
|
+
},
|
|
243
|
+
[pageId, refresh],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const remove = useCallback(
|
|
247
|
+
async (name: string) => {
|
|
248
|
+
if (!available) return NOOP_RESULT;
|
|
249
|
+
const res = await deleteAsset(pageId, name);
|
|
250
|
+
if (res.ok) await refresh();
|
|
251
|
+
return { ok: res.ok, status: res.status };
|
|
252
|
+
},
|
|
253
|
+
[pageId, refresh],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return { assets, loading, available, upload, rename, remove, refresh };
|
|
257
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type DesignPalette = {
|
|
2
|
+
bg: string;
|
|
3
|
+
text: string;
|
|
4
|
+
accent: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type DesignFonts = {
|
|
8
|
+
display: string;
|
|
9
|
+
body: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type DesignTypeScale = {
|
|
13
|
+
hero: number;
|
|
14
|
+
body: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type DesignSystem = {
|
|
18
|
+
palette: DesignPalette;
|
|
19
|
+
fonts: DesignFonts;
|
|
20
|
+
typeScale: DesignTypeScale;
|
|
21
|
+
radius: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function designToCssVars(d: DesignSystem): Record<string, string> {
|
|
25
|
+
return {
|
|
26
|
+
'--osd-bg': d.palette.bg,
|
|
27
|
+
'--osd-text': d.palette.text,
|
|
28
|
+
'--osd-accent': d.palette.accent,
|
|
29
|
+
'--osd-font-display': d.fonts.display,
|
|
30
|
+
'--osd-font-body': d.fonts.body,
|
|
31
|
+
'--osd-size-hero': `${d.typeScale.hero}px`,
|
|
32
|
+
'--osd-size-body': `${d.typeScale.body}px`,
|
|
33
|
+
'--osd-radius': `${d.radius}px`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function cssVarsToString(vars: Record<string, string>): string {
|
|
38
|
+
return Object.entries(vars)
|
|
39
|
+
.map(([k, v]) => ` ${k}: ${v};`)
|
|
40
|
+
.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const defaultDesign: DesignSystem = {
|
|
44
|
+
palette: {
|
|
45
|
+
bg: '#f7f5f0',
|
|
46
|
+
text: '#1a1814',
|
|
47
|
+
accent: '#6d4cff',
|
|
48
|
+
},
|
|
49
|
+
fonts: {
|
|
50
|
+
display: 'Georgia, "Times New Roman", serif',
|
|
51
|
+
body: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif',
|
|
52
|
+
},
|
|
53
|
+
typeScale: {
|
|
54
|
+
hero: 168,
|
|
55
|
+
body: 36,
|
|
56
|
+
},
|
|
57
|
+
radius: 12,
|
|
58
|
+
};
|