docs-i18n 0.6.2 → 0.7.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/{src/admin/ui → admin/app}/components/JobDialog.tsx +21 -2
- package/{src/admin/ui → admin/app}/components/JobPanel.tsx +1 -1
- package/{src/admin/ui → admin/app}/components/Preview.tsx +2 -5
- package/{src/admin/ui → admin/app}/lib/api.ts +18 -39
- package/admin/app/routeTree.gen.ts +68 -0
- package/admin/app/router.tsx +23 -0
- package/admin/app/routes/__root.tsx +55 -0
- package/admin/app/routes/index.tsx +416 -0
- package/{src/admin/ui → admin/app}/styles.css +36 -3
- package/admin/package.json +27 -0
- package/admin/server/functions/jobs.ts +53 -0
- package/admin/server/functions/misc.ts +84 -0
- package/{src/admin/server/routes → admin/server/functions}/models.ts +16 -29
- package/admin/server/functions/status.ts +61 -0
- package/admin/server/index.ts +35 -0
- package/admin/server/init.ts +46 -0
- package/{src/admin → admin}/server/services/job-manager.ts +39 -10
- package/{src/admin → admin}/server/services/status.ts +6 -6
- package/admin/tsconfig.json +19 -0
- package/{src/admin → admin}/vite.config.ts +8 -2
- package/dist/{assemble-7H4QCW35.js → assemble-CP2BRYQJ.js} +6 -4
- package/dist/{chunk-A3YQNPKZ.js → chunk-CLYUAWZE.js} +1 -1
- package/dist/{chunk-YN4VJHCQ.js → chunk-JHBSHTXC.js} +1 -1
- package/dist/chunk-L64GJ4OB.js +32 -0
- package/dist/{chunk-SKKZIV3L.js → chunk-PNKVD2UK.js} +1 -29
- package/dist/{chunk-XEOYZUHS.js → chunk-QKIR7RKQ.js} +4 -31
- package/dist/chunk-TRURQFP4.js +31 -0
- package/dist/cli.js +108 -23
- package/dist/index.d.ts +41 -1
- package/dist/index.js +92 -3
- package/dist/{rescan-O5D3CYC2.js → rescan-HXMWFAOC.js} +5 -3
- package/dist/{status-F4MYIAAY.js → status-AGZDXOTZ.js} +4 -2
- package/dist/{translate-ZIVKNAC4.js → translate-A5X6MX4Y.js} +14 -7
- package/dist/upload-XL6KG6S2.js +132 -0
- package/package.json +17 -15
- package/template/app/components/BlogArticle.tsx +159 -0
- package/template/app/components/BlogList.tsx +88 -0
- package/template/app/components/Breadcrumbs.tsx +81 -0
- package/template/app/components/Card.tsx +31 -0
- package/template/app/components/Doc.tsx +191 -0
- package/template/app/components/DocBreadcrumb.tsx +60 -0
- package/template/app/components/DocContainer.tsx +13 -0
- package/template/app/components/DocTitle.tsx +11 -0
- package/template/app/components/DocsLayout.tsx +715 -0
- package/template/app/components/Dropdown.tsx +116 -0
- package/template/app/components/FallbackBanner.tsx +36 -0
- package/template/app/components/Footer.tsx +29 -0
- package/template/app/components/FrameworkSelect.tsx +150 -0
- package/template/app/components/LibraryCard.tsx +178 -0
- package/template/app/components/LocaleSwitcher.tsx +43 -0
- package/template/app/components/Navbar.tsx +430 -0
- package/template/app/components/PostNotFound.tsx +20 -0
- package/template/app/components/SearchButton.tsx +32 -0
- package/template/app/components/Select.tsx +103 -0
- package/template/app/components/Spinner.tsx +18 -0
- package/template/app/components/ThemeProvider.tsx +141 -0
- package/template/app/components/ThemeToggle.tsx +31 -0
- package/template/app/components/Toc.tsx +86 -0
- package/template/app/components/VersionSelect.tsx +118 -0
- package/template/app/components/icons/BSkyIcon.tsx +27 -0
- package/template/app/components/icons/BaseballCapIcon.tsx +25 -0
- package/template/app/components/icons/BrandXIcon.tsx +28 -0
- package/template/app/components/icons/CheckCircleIcon.tsx +28 -0
- package/template/app/components/icons/CogsIcon.tsx +25 -0
- package/template/app/components/icons/DiscordIcon.tsx +24 -0
- package/template/app/components/icons/GithubIcon.tsx +24 -0
- package/template/app/components/icons/GoogleIcon.tsx +24 -0
- package/template/app/components/icons/InstagramIcon.tsx +24 -0
- package/template/app/components/icons/NpmIcon.tsx +26 -0
- package/template/app/components/icons/YinYangIcon.tsx +26 -0
- package/template/app/components/icons/YouTubeIcon.tsx +24 -0
- package/template/app/components/markdown/CodeBlock.tsx +254 -0
- package/template/app/components/markdown/FileTabs.tsx +58 -0
- package/template/app/components/markdown/FrameworkContent.tsx +76 -0
- package/template/app/components/markdown/Markdown.tsx +216 -0
- package/template/app/components/markdown/MarkdownContent.tsx +89 -0
- package/template/app/components/markdown/MarkdownFrameworkHandler.tsx +66 -0
- package/template/app/components/markdown/MarkdownHeadingContext.tsx +35 -0
- package/template/app/components/markdown/MarkdownLink.tsx +46 -0
- package/template/app/components/markdown/MarkdownTabsHandler.tsx +109 -0
- package/template/app/components/markdown/PackageManagerTabs.tsx +95 -0
- package/template/app/components/markdown/Tabs.tsx +139 -0
- package/template/app/components/markdown/index.ts +15 -0
- package/template/app/components/ui/Button.tsx +141 -0
- package/template/app/components/ui/InlineCode.tsx +16 -0
- package/template/app/components/ui/MarkdownImg.tsx +21 -0
- package/template/app/config/frameworks.ts +93 -0
- package/template/app/contexts/SearchContext.tsx +36 -0
- package/template/app/db/index.ts +17 -0
- package/template/app/db/schema.ts +74 -0
- package/template/app/hooks/useClickOutside.ts +106 -0
- package/template/app/routeTree.gen.ts +584 -0
- package/template/app/router.tsx +29 -0
- package/template/app/routes/$lang.$project.$version.docs.$.tsx +128 -0
- package/template/app/routes/$lang.$project.$version.docs.framework.$framework.$.tsx +106 -0
- package/template/app/routes/$lang.$project.$version.docs.framework.$framework.index.tsx +27 -0
- package/template/app/routes/$lang.$project.$version.docs.framework.index.tsx +44 -0
- package/template/app/routes/$lang.$project.$version.docs.index.tsx +27 -0
- package/template/app/routes/$lang.$project.$version.docs.tsx +70 -0
- package/template/app/routes/$lang.$project.$version.tsx +69 -0
- package/template/app/routes/$lang.$project.docs.$.tsx +104 -0
- package/template/app/routes/$lang.$project.docs.index.tsx +20 -0
- package/template/app/routes/$lang.$project.docs.tsx +79 -0
- package/template/app/routes/$lang.$project.tsx +89 -0
- package/template/app/routes/$lang.blog.$.tsx +82 -0
- package/template/app/routes/$lang.blog.index.tsx +56 -0
- package/template/app/routes/$lang.blog.tsx +26 -0
- package/template/app/routes/$lang.docs.$.tsx +100 -0
- package/template/app/routes/$lang.docs.framework.$framework.$.tsx +104 -0
- package/template/app/routes/$lang.docs.framework.$framework.index.tsx +32 -0
- package/template/app/routes/$lang.docs.framework.index.tsx +47 -0
- package/template/app/routes/$lang.docs.index.tsx +20 -0
- package/template/app/routes/$lang.docs.tsx +90 -0
- package/template/app/routes/$lang.tsx +16 -0
- package/template/app/routes/__root.tsx +180 -0
- package/template/app/routes/index.tsx +89 -0
- package/template/app/site.config.ts +182 -0
- package/template/app/styles/app.css +1029 -0
- package/template/app/types/index.ts +77 -0
- package/template/app/utils/blog.server.ts +193 -0
- package/template/app/utils/blog.ts +42 -0
- package/template/app/utils/config.ts +120 -0
- package/template/app/utils/content-loader.ts +400 -0
- package/template/app/utils/dates.ts +29 -0
- package/template/app/utils/docs.server.ts +150 -0
- package/template/app/utils/markdown/filterFrameworkContent.ts +233 -0
- package/template/app/utils/markdown/index.ts +2 -0
- package/template/app/utils/markdown/installCommand.ts +143 -0
- package/template/app/utils/markdown/plugins/collectHeadings.ts +104 -0
- package/template/app/utils/markdown/plugins/extractCodeMeta.ts +57 -0
- package/template/app/utils/markdown/plugins/helpers.ts +33 -0
- package/template/app/utils/markdown/plugins/index.ts +8 -0
- package/template/app/utils/markdown/plugins/parseCommentComponents.ts +103 -0
- package/template/app/utils/markdown/plugins/transformCommentComponents.ts +23 -0
- package/template/app/utils/markdown/plugins/transformFrameworkComponent.ts +217 -0
- package/template/app/utils/markdown/plugins/transformTabsComponent.ts +359 -0
- package/template/app/utils/markdown/processor.ts +75 -0
- package/template/app/utils/site-config.tsx +11 -0
- package/template/app/utils/upload.ts +232 -0
- package/template/app/utils/useLocalStorage.ts +65 -0
- package/template/app/utils/utils.ts +23 -0
- package/template/package.json +54 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/fonts/Inter-latin-ext.woff2 +0 -0
- package/template/public/fonts/Inter-latin.woff2 +0 -0
- package/template/public/images/frameworks/angular-logo.svg +1 -0
- package/template/public/images/frameworks/js-logo.svg +1 -0
- package/template/public/images/frameworks/lit-logo.svg +1 -0
- package/template/public/images/frameworks/preact-logo.svg +6 -0
- package/template/public/images/frameworks/qwik-logo.svg +1 -0
- package/template/public/images/frameworks/react-logo.svg +1 -0
- package/template/public/images/frameworks/solid-logo.svg +1 -0
- package/template/public/images/frameworks/svelte-logo.svg +1 -0
- package/template/public/images/frameworks/vue-logo.svg +4 -0
- package/template/tsconfig.json +24 -0
- package/template/vite.config.ts +43 -0
- package/template/wrangler.jsonc +16 -0
- package/README.md +0 -161
- package/dist/server-73AVSOL5.js +0 -598
- package/src/admin/index.html +0 -13
- package/src/admin/server/index.ts +0 -138
- package/src/admin/server/routes/jobs.ts +0 -113
- package/src/admin/server/routes/status.ts +0 -57
- package/src/admin/ui/App.tsx +0 -332
- package/src/admin/ui/main.tsx +0 -19
- /package/{src/admin/ui → admin/app}/components/FileList.tsx +0 -0
- /package/{src/admin/ui → admin/app}/components/LangGrid.tsx +0 -0
- /package/{src/admin/ui → admin/app}/components/ProgressBar.tsx +0 -0
- /package/{src/admin/ui → admin/app}/lib/flags.ts +0 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { FileList } from '../components/FileList';
|
|
5
|
+
import { JobDialog } from '../components/JobDialog';
|
|
6
|
+
import { JobPanel } from '../components/JobPanel';
|
|
7
|
+
import { LangGrid } from '../components/LangGrid';
|
|
8
|
+
import { Preview } from '../components/Preview';
|
|
9
|
+
import { api } from '../lib/api';
|
|
10
|
+
|
|
11
|
+
type ViewMode = 'split' | 'en' | 'lang';
|
|
12
|
+
type StatusFilter = 'all' | 'complete' | 'partial' | 'missing';
|
|
13
|
+
type SectionFilter = 'all' | 'docs' | 'blog' | 'learn';
|
|
14
|
+
|
|
15
|
+
export interface AdminSearch {
|
|
16
|
+
project?: string;
|
|
17
|
+
v?: string;
|
|
18
|
+
lang?: string;
|
|
19
|
+
file?: string;
|
|
20
|
+
files?: string;
|
|
21
|
+
view?: ViewMode;
|
|
22
|
+
toc?: string;
|
|
23
|
+
nodes?: string;
|
|
24
|
+
status?: StatusFilter;
|
|
25
|
+
section?: SectionFilter;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse version keys into project/version structure.
|
|
30
|
+
* Multi-project keys look like "query/v5", single-project keys look like "v5".
|
|
31
|
+
*/
|
|
32
|
+
function parseProjectVersions(versionKeys: string[]) {
|
|
33
|
+
const isMultiProject = versionKeys.some((k) => k.includes('/'));
|
|
34
|
+
|
|
35
|
+
if (!isMultiProject) {
|
|
36
|
+
return { projects: [{ id: '_default', versions: versionKeys }], isMultiProject: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const projectMap = new Map<string, string[]>();
|
|
40
|
+
for (const key of versionKeys) {
|
|
41
|
+
const slashIdx = key.indexOf('/');
|
|
42
|
+
const projectId = slashIdx >= 0 ? key.slice(0, slashIdx) : '_default';
|
|
43
|
+
const version = slashIdx >= 0 ? key.slice(slashIdx + 1) : key;
|
|
44
|
+
if (!projectMap.has(projectId)) projectMap.set(projectId, []);
|
|
45
|
+
projectMap.get(projectId)!.push(version);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
projects: Array.from(projectMap.entries()).map(([id, versions]) => ({ id, versions })),
|
|
50
|
+
isMultiProject: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Route = createFileRoute('/')({
|
|
55
|
+
validateSearch: (search: Record<string, unknown>): AdminSearch => ({
|
|
56
|
+
project: (search.project as string) || undefined,
|
|
57
|
+
v: (search.v as string) || undefined,
|
|
58
|
+
lang: (search.lang as string) || undefined,
|
|
59
|
+
file: (search.file as string) || undefined,
|
|
60
|
+
files: (search.files as string) || undefined,
|
|
61
|
+
view: (search.view as ViewMode) || undefined,
|
|
62
|
+
toc: (search.toc as string) || undefined,
|
|
63
|
+
nodes: (search.nodes as string) || undefined,
|
|
64
|
+
status: (search.status as StatusFilter) || undefined,
|
|
65
|
+
section: (search.section as SectionFilter) || undefined,
|
|
66
|
+
}),
|
|
67
|
+
component: AdminPage,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function AdminPage() {
|
|
71
|
+
const search = Route.useSearch();
|
|
72
|
+
const navigate = useNavigate({ from: '/' });
|
|
73
|
+
|
|
74
|
+
const lang = search.lang || null;
|
|
75
|
+
const file = search.file || null;
|
|
76
|
+
const showFiles = search.files !== '0';
|
|
77
|
+
const view: ViewMode = search.view || 'lang';
|
|
78
|
+
const toc = search.toc !== '0';
|
|
79
|
+
const nodes = search.nodes === '1';
|
|
80
|
+
const status: StatusFilter = search.status || 'all';
|
|
81
|
+
const section: SectionFilter = search.section || 'all';
|
|
82
|
+
|
|
83
|
+
const [theme, setThemeState] = useState(() => {
|
|
84
|
+
if (typeof window === 'undefined') return 'dark';
|
|
85
|
+
const saved = localStorage.getItem('theme');
|
|
86
|
+
return saved === 'light' ? 'light' : 'dark';
|
|
87
|
+
});
|
|
88
|
+
const [toast, setToast] = useState<string | null>(null);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
document.documentElement.dataset.theme = theme;
|
|
92
|
+
}, [theme]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!toast) return;
|
|
96
|
+
const t = setTimeout(() => setToast(null), 3000);
|
|
97
|
+
return () => clearTimeout(t);
|
|
98
|
+
}, [toast]);
|
|
99
|
+
|
|
100
|
+
const updateSearch = useCallback(
|
|
101
|
+
(updates: Partial<AdminSearch>) => {
|
|
102
|
+
navigate({
|
|
103
|
+
search: (prev: AdminSearch) => {
|
|
104
|
+
const next = { ...prev };
|
|
105
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
106
|
+
if (v === null || v === undefined || v === '') {
|
|
107
|
+
delete (next as Record<string, unknown>)[k];
|
|
108
|
+
} else {
|
|
109
|
+
(next as Record<string, unknown>)[k] = v;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return next;
|
|
113
|
+
},
|
|
114
|
+
replace: true,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
[navigate],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// ── URL setters ──
|
|
121
|
+
const setProject = useCallback(
|
|
122
|
+
(p: string | null) => updateSearch({ project: p || undefined, v: undefined, lang: undefined, file: undefined }),
|
|
123
|
+
[updateSearch],
|
|
124
|
+
);
|
|
125
|
+
const setVersion = useCallback(
|
|
126
|
+
(v: string) => updateSearch({ v: v || undefined, lang: undefined, file: undefined }),
|
|
127
|
+
[updateSearch],
|
|
128
|
+
);
|
|
129
|
+
const setLang = useCallback(
|
|
130
|
+
(l: string | null) => updateSearch({ lang: l || undefined }),
|
|
131
|
+
[updateSearch],
|
|
132
|
+
);
|
|
133
|
+
const setFile = useCallback(
|
|
134
|
+
(f: string | null) => updateSearch({ file: f || undefined }),
|
|
135
|
+
[updateSearch],
|
|
136
|
+
);
|
|
137
|
+
const setShowFiles = useCallback(
|
|
138
|
+
(show: boolean) => updateSearch({ files: show ? undefined : '0' }),
|
|
139
|
+
[updateSearch],
|
|
140
|
+
);
|
|
141
|
+
const setView = useCallback(
|
|
142
|
+
(m: ViewMode) => updateSearch({ view: m === 'lang' ? undefined : m }),
|
|
143
|
+
[updateSearch],
|
|
144
|
+
);
|
|
145
|
+
const setNodes = useCallback(
|
|
146
|
+
(show: boolean) => updateSearch({ nodes: show ? '1' : undefined }),
|
|
147
|
+
[updateSearch],
|
|
148
|
+
);
|
|
149
|
+
const setToc = useCallback(
|
|
150
|
+
(show: boolean) => updateSearch({ toc: show ? undefined : '0' }),
|
|
151
|
+
[updateSearch],
|
|
152
|
+
);
|
|
153
|
+
const setStatusFilter = useCallback(
|
|
154
|
+
(s: StatusFilter) => updateSearch({ status: s === 'all' ? undefined : s }),
|
|
155
|
+
[updateSearch],
|
|
156
|
+
);
|
|
157
|
+
const setSectionFilter = useCallback(
|
|
158
|
+
(s: SectionFilter) => updateSearch({ section: s === 'all' ? undefined : s }),
|
|
159
|
+
[updateSearch],
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// ── Non-URL state ──
|
|
163
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
164
|
+
const [showDialog, setShowDialog] = useState(false);
|
|
165
|
+
const [dialogFiles, setDialogFiles] = useState<string[] | undefined>();
|
|
166
|
+
|
|
167
|
+
// ── Queries ──
|
|
168
|
+
const { data: statusData } = useQuery({
|
|
169
|
+
queryKey: ['status'],
|
|
170
|
+
queryFn: api.status,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const { data: versionInfo } = useQuery({
|
|
174
|
+
queryKey: ['version'],
|
|
175
|
+
queryFn: api.version,
|
|
176
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── Derive project/version structure ──
|
|
180
|
+
const { projectList, isMultiProject, activeProject, activeVersions, version } = useMemo(() => {
|
|
181
|
+
if (!statusData) {
|
|
182
|
+
return { projectList: [], isMultiProject: false, activeProject: null, activeVersions: [] as string[], version: 'latest' };
|
|
183
|
+
}
|
|
184
|
+
const parsed = parseProjectVersions(statusData.versions);
|
|
185
|
+
const projectList = parsed.projects;
|
|
186
|
+
const isMultiProject = parsed.isMultiProject;
|
|
187
|
+
|
|
188
|
+
// Determine active project
|
|
189
|
+
let activeProject = isMultiProject
|
|
190
|
+
? (search.project || projectList[0]?.id || null)
|
|
191
|
+
: null;
|
|
192
|
+
// Validate the project exists
|
|
193
|
+
if (isMultiProject && activeProject && !projectList.find((p) => p.id === activeProject)) {
|
|
194
|
+
activeProject = projectList[0]?.id || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Determine versions for active project
|
|
198
|
+
const activeVersions = isMultiProject
|
|
199
|
+
? (projectList.find((p) => p.id === activeProject)?.versions || [])
|
|
200
|
+
: statusData.versions;
|
|
201
|
+
|
|
202
|
+
// Determine active version — for multi-project, version key is "project/version"
|
|
203
|
+
const rawVersion = search.v || activeVersions[0] || 'latest';
|
|
204
|
+
const version = isMultiProject && activeProject
|
|
205
|
+
? `${activeProject}/${rawVersion}`
|
|
206
|
+
: rawVersion;
|
|
207
|
+
|
|
208
|
+
return { projectList, isMultiProject, activeProject, activeVersions, version };
|
|
209
|
+
}, [statusData, search.project, search.v]);
|
|
210
|
+
|
|
211
|
+
// Short version (without project prefix) for display
|
|
212
|
+
const displayVersion = isMultiProject && activeProject && version.startsWith(`${activeProject}/`)
|
|
213
|
+
? version.slice(activeProject.length + 1)
|
|
214
|
+
: version;
|
|
215
|
+
|
|
216
|
+
const { data: files } = useQuery({
|
|
217
|
+
queryKey: ['files', version, lang],
|
|
218
|
+
queryFn: () => api.fileCoverage(version, lang as string),
|
|
219
|
+
enabled: !!lang,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── Handlers ──
|
|
223
|
+
const handleSelectProject = useCallback(
|
|
224
|
+
(p: string) => {
|
|
225
|
+
setProject(p);
|
|
226
|
+
setSelected(new Set());
|
|
227
|
+
},
|
|
228
|
+
[setProject],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const handleSelectVersion = useCallback(
|
|
232
|
+
(v: string) => {
|
|
233
|
+
setVersion(v);
|
|
234
|
+
setSelected(new Set());
|
|
235
|
+
},
|
|
236
|
+
[setVersion],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const handleSelectLang = useCallback(
|
|
240
|
+
(l: string) => {
|
|
241
|
+
setLang(l);
|
|
242
|
+
setSelected(new Set());
|
|
243
|
+
},
|
|
244
|
+
[setLang],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const handleToggle = useCallback((f: string) => {
|
|
248
|
+
setSelected((prev) => {
|
|
249
|
+
const next = new Set(prev);
|
|
250
|
+
if (next.has(f)) next.delete(f);
|
|
251
|
+
else next.add(f);
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
const handleSelectAll = useCallback(
|
|
257
|
+
(fileList: string[]) => setSelected(new Set(fileList)),
|
|
258
|
+
[],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const handleClear = useCallback(() => setSelected(new Set()), []);
|
|
262
|
+
|
|
263
|
+
const handleTranslateSelected = useCallback(() => {
|
|
264
|
+
setDialogFiles([...selected]);
|
|
265
|
+
setShowDialog(true);
|
|
266
|
+
}, [selected]);
|
|
267
|
+
|
|
268
|
+
const handleNewJob = useCallback(() => {
|
|
269
|
+
setDialogFiles(undefined);
|
|
270
|
+
setShowDialog(true);
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
if (!statusData) return <div className="loading">Loading...</div>;
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<>
|
|
277
|
+
<nav>
|
|
278
|
+
<h1>
|
|
279
|
+
{'🌐 Translation Admin '}
|
|
280
|
+
{versionInfo?.version && (
|
|
281
|
+
<span className="version-badge">v{versionInfo.version}</span>
|
|
282
|
+
)}
|
|
283
|
+
</h1>
|
|
284
|
+
<span className="spacer" />
|
|
285
|
+
<button type="button" className="btn" onClick={handleNewJob}>
|
|
286
|
+
+ New Job
|
|
287
|
+
</button>
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
className="btn btn-icon"
|
|
291
|
+
onClick={() => {
|
|
292
|
+
const next = theme === 'light' ? 'dark' : 'light';
|
|
293
|
+
setThemeState(next);
|
|
294
|
+
localStorage.setItem('theme', next);
|
|
295
|
+
}}
|
|
296
|
+
title="Toggle theme"
|
|
297
|
+
>
|
|
298
|
+
{theme === 'light' ? '🌙' : '☀️'}
|
|
299
|
+
</button>
|
|
300
|
+
</nav>
|
|
301
|
+
|
|
302
|
+
<div className="container">
|
|
303
|
+
{/* Project tabs (multi-project only) */}
|
|
304
|
+
{isMultiProject && (
|
|
305
|
+
<div className="project-tabs">
|
|
306
|
+
{projectList.map((p) => (
|
|
307
|
+
<button
|
|
308
|
+
key={p.id}
|
|
309
|
+
type="button"
|
|
310
|
+
className={`project-tab${p.id === activeProject ? ' active' : ''}`}
|
|
311
|
+
onClick={() => handleSelectProject(p.id)}
|
|
312
|
+
>
|
|
313
|
+
{p.id}
|
|
314
|
+
</button>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{/* Version tabs */}
|
|
320
|
+
<div className="tabs">
|
|
321
|
+
{activeVersions.map((v) => (
|
|
322
|
+
<button
|
|
323
|
+
key={v}
|
|
324
|
+
type="button"
|
|
325
|
+
className={`tab${v === displayVersion ? ' active' : ''}`}
|
|
326
|
+
onClick={() => handleSelectVersion(v)}
|
|
327
|
+
>
|
|
328
|
+
{v}
|
|
329
|
+
</button>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
{/* Language cards */}
|
|
334
|
+
<LangGrid
|
|
335
|
+
data={statusData}
|
|
336
|
+
version={version}
|
|
337
|
+
selectedLang={lang}
|
|
338
|
+
onSelect={handleSelectLang}
|
|
339
|
+
/>
|
|
340
|
+
|
|
341
|
+
{/* Jobs */}
|
|
342
|
+
<JobPanel />
|
|
343
|
+
|
|
344
|
+
{/* File list + Preview */}
|
|
345
|
+
{lang && files && (
|
|
346
|
+
<>
|
|
347
|
+
<div className="file-panel-toolbar">
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
className={`btn btn-sm${showFiles ? ' active' : ''}`}
|
|
351
|
+
onClick={() => setShowFiles(!showFiles)}
|
|
352
|
+
>
|
|
353
|
+
{showFiles ? '◀ Hide files' : '▶ Show files'}
|
|
354
|
+
</button>
|
|
355
|
+
{file && <span className="file-panel-current">{file}</span>}
|
|
356
|
+
</div>
|
|
357
|
+
<div
|
|
358
|
+
className={`file-panel${!showFiles ? ' no-list' : ''}${!file ? ' no-preview' : ''}`}
|
|
359
|
+
>
|
|
360
|
+
{showFiles && (
|
|
361
|
+
<FileList
|
|
362
|
+
files={files}
|
|
363
|
+
lang={lang}
|
|
364
|
+
activeFile={file}
|
|
365
|
+
selected={selected}
|
|
366
|
+
statusFilter={status}
|
|
367
|
+
sectionFilter={section}
|
|
368
|
+
onStatusFilter={setStatusFilter}
|
|
369
|
+
onSectionFilter={setSectionFilter}
|
|
370
|
+
onSelect={setFile}
|
|
371
|
+
onToggle={handleToggle}
|
|
372
|
+
onSelectAll={handleSelectAll}
|
|
373
|
+
onClear={handleClear}
|
|
374
|
+
onTranslateSelected={handleTranslateSelected}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
{file && (
|
|
378
|
+
<Preview
|
|
379
|
+
version={version}
|
|
380
|
+
lang={lang}
|
|
381
|
+
file={file}
|
|
382
|
+
viewMode={view}
|
|
383
|
+
onViewMode={setView}
|
|
384
|
+
showToc={toc}
|
|
385
|
+
onToggleToc={() => setToc(!toc)}
|
|
386
|
+
showNodes={nodes}
|
|
387
|
+
onToggleNodes={() => setNodes(!nodes)}
|
|
388
|
+
onClose={() => setFile(null)}
|
|
389
|
+
/>
|
|
390
|
+
)}
|
|
391
|
+
</div>
|
|
392
|
+
</>
|
|
393
|
+
)}
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{/* Toast */}
|
|
397
|
+
{toast && <div className="toast">{toast}</div>}
|
|
398
|
+
|
|
399
|
+
{/* Job dialog */}
|
|
400
|
+
{showDialog && (
|
|
401
|
+
<JobDialog
|
|
402
|
+
langs={statusData.langs}
|
|
403
|
+
versions={statusData.versions}
|
|
404
|
+
defaultLang={lang || undefined}
|
|
405
|
+
defaultVersion={version}
|
|
406
|
+
files={dialogFiles}
|
|
407
|
+
onClose={() => setShowDialog(false)}
|
|
408
|
+
onSuccess={(msg) => {
|
|
409
|
+
setShowDialog(false);
|
|
410
|
+
setToast(msg);
|
|
411
|
+
}}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
414
|
+
</>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -148,7 +148,37 @@ nav h1 {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/*
|
|
151
|
+
/* Project tabs */
|
|
152
|
+
.project-tabs {
|
|
153
|
+
display: flex;
|
|
154
|
+
gap: 0.25rem;
|
|
155
|
+
margin-bottom: 0.75rem;
|
|
156
|
+
border-bottom: 1px solid var(--border);
|
|
157
|
+
padding-bottom: 0.75rem;
|
|
158
|
+
}
|
|
159
|
+
.project-tab {
|
|
160
|
+
padding: 0.4rem 1rem;
|
|
161
|
+
border-radius: 9999px;
|
|
162
|
+
border: 1px solid var(--border);
|
|
163
|
+
background: transparent;
|
|
164
|
+
color: var(--fg2);
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
font-size: 0.8rem;
|
|
167
|
+
font-weight: 500;
|
|
168
|
+
text-transform: capitalize;
|
|
169
|
+
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
170
|
+
}
|
|
171
|
+
.project-tab:hover {
|
|
172
|
+
background: var(--hover);
|
|
173
|
+
color: var(--fg);
|
|
174
|
+
}
|
|
175
|
+
.project-tab.active {
|
|
176
|
+
background: var(--accent);
|
|
177
|
+
color: var(--btn-fg);
|
|
178
|
+
border-color: var(--accent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* Version tabs */
|
|
152
182
|
.tabs {
|
|
153
183
|
display: flex;
|
|
154
184
|
gap: 0.5rem;
|
|
@@ -272,6 +302,8 @@ nav h1 {
|
|
|
272
302
|
background: var(--border);
|
|
273
303
|
border-radius: 2px;
|
|
274
304
|
margin-top: 0.5rem;
|
|
305
|
+
overflow: hidden;
|
|
306
|
+
max-width: 100%;
|
|
275
307
|
}
|
|
276
308
|
.progress-fill {
|
|
277
309
|
height: 100%;
|
|
@@ -1088,9 +1120,10 @@ a.preview-filename:hover {
|
|
|
1088
1120
|
position: relative;
|
|
1089
1121
|
}
|
|
1090
1122
|
.log-copy {
|
|
1091
|
-
position:
|
|
1123
|
+
position: sticky;
|
|
1092
1124
|
top: 0.25rem;
|
|
1093
|
-
|
|
1125
|
+
float: right;
|
|
1126
|
+
margin-right: 0.25rem;
|
|
1094
1127
|
background: var(--card);
|
|
1095
1128
|
border: 1px solid var(--border);
|
|
1096
1129
|
border-radius: 0.25rem;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@docs-i18n/admin",
|
|
3
|
+
"version": "0.6.3",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./server": "./server/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "vite dev",
|
|
11
|
+
"build": "vite build"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"docs-i18n": "workspace:*",
|
|
15
|
+
"hono": "^4.12.0",
|
|
16
|
+
"react": "^19.1.0",
|
|
17
|
+
"react-dom": "^19.1.0",
|
|
18
|
+
"vite": "^8.0.1",
|
|
19
|
+
"@vitejs/plugin-react": "^6.0.1"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"@types/react": "^19.2.14",
|
|
24
|
+
"@types/react-dom": "^19.2.3",
|
|
25
|
+
"typescript": "^5.8.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start';
|
|
2
|
+
import { jobManager } from '../services/job-manager';
|
|
3
|
+
import { ensureInit } from '../init';
|
|
4
|
+
|
|
5
|
+
export const fetchJobs = createServerFn({ method: 'GET' }).handler(async () => {
|
|
6
|
+
await ensureInit();
|
|
7
|
+
return jobManager.list().map((j) => ({
|
|
8
|
+
...j,
|
|
9
|
+
logLines: (j.logLines ?? []).slice(-20),
|
|
10
|
+
}));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const createJob = createServerFn({ method: 'POST' })
|
|
14
|
+
.inputValidator(
|
|
15
|
+
(d: {
|
|
16
|
+
lang: string;
|
|
17
|
+
version: string;
|
|
18
|
+
max?: number;
|
|
19
|
+
concurrency?: number;
|
|
20
|
+
model?: string;
|
|
21
|
+
modelRotate?: string[];
|
|
22
|
+
md5?: boolean;
|
|
23
|
+
files?: string[];
|
|
24
|
+
}) => d,
|
|
25
|
+
)
|
|
26
|
+
.handler(async ({ data }) => {
|
|
27
|
+
await ensureInit();
|
|
28
|
+
if (!data.lang || !data.version) {
|
|
29
|
+
throw new Error('Missing lang or version');
|
|
30
|
+
}
|
|
31
|
+
return jobManager.start(data);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const fetchJob = createServerFn({ method: 'GET' })
|
|
35
|
+
.inputValidator((d: { id: string }) => d)
|
|
36
|
+
.handler(async ({ data }) => {
|
|
37
|
+
const job = jobManager.get(data.id);
|
|
38
|
+
if (!job) throw new Error('Job not found');
|
|
39
|
+
return job;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const deleteJob = createServerFn({ method: 'POST' })
|
|
43
|
+
.inputValidator((d: { id: string }) => d)
|
|
44
|
+
.handler(async ({ data }) => {
|
|
45
|
+
const job = jobManager.get(data.id);
|
|
46
|
+
if (!job) throw new Error('Job not found');
|
|
47
|
+
if (job.status === 'running') {
|
|
48
|
+
jobManager.cancel(data.id);
|
|
49
|
+
} else {
|
|
50
|
+
jobManager.remove(data.id);
|
|
51
|
+
}
|
|
52
|
+
return { ok: true };
|
|
53
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start';
|
|
2
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
5
|
+
import { getPackageVersion } from 'docs-i18n';
|
|
6
|
+
import { ensureInit, getConfig } from '../init';
|
|
7
|
+
|
|
8
|
+
export const fetchVersion = createServerFn({ method: 'GET' }).handler(async () => {
|
|
9
|
+
return { version: getPackageVersion() };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const fetchLlmConfig = createServerFn({ method: 'GET' }).handler(async () => {
|
|
13
|
+
await ensureInit();
|
|
14
|
+
const config = getConfig();
|
|
15
|
+
const llm = config?.llm;
|
|
16
|
+
|
|
17
|
+
// Read .env from project root for env vars not yet in process.env
|
|
18
|
+
let envVars: Record<string, string> = {};
|
|
19
|
+
try {
|
|
20
|
+
const { getProjectRoot } = await import('../init');
|
|
21
|
+
const projectRoot = getProjectRoot();
|
|
22
|
+
const envPath = resolve(projectRoot, '.env');
|
|
23
|
+
if (existsSync(envPath)) {
|
|
24
|
+
const envContent = readFileSync(envPath, 'utf-8');
|
|
25
|
+
for (const line of envContent.split('\n')) {
|
|
26
|
+
const match = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
27
|
+
if (match) envVars[match[1]] = match[2];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
|
|
32
|
+
// Check if API key is available (from config, env, or .env file)
|
|
33
|
+
const hasApiKey = !!(
|
|
34
|
+
llm?.apiKey ||
|
|
35
|
+
process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY ||
|
|
36
|
+
envVars.OPENROUTER_API_KEY || envVars.OPENAI_API_KEY
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// Resolve the effective model: config > env > .env > hardcoded default
|
|
40
|
+
const DEFAULT_MODEL = 'deepseek/deepseek-chat-v3-0324:free';
|
|
41
|
+
const effectiveModel =
|
|
42
|
+
llm?.model ||
|
|
43
|
+
process.env.OPENROUTER_MODEL ||
|
|
44
|
+
envVars.OPENROUTER_MODEL ||
|
|
45
|
+
DEFAULT_MODEL;
|
|
46
|
+
|
|
47
|
+
const effectiveProvider = llm?.provider || 'openrouter';
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
model: effectiveModel,
|
|
51
|
+
provider: effectiveProvider,
|
|
52
|
+
hasApiKey,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const fetchConfig = createServerFn({ method: 'GET' }).handler(async () => {
|
|
57
|
+
return { projectRoot: process.cwd() };
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const openFile = createServerFn({ method: 'POST' })
|
|
61
|
+
.inputValidator((d: { file: string }) => d)
|
|
62
|
+
.handler(async ({ data }) => {
|
|
63
|
+
const { file } = data;
|
|
64
|
+
if (!file) throw new Error('Missing file');
|
|
65
|
+
const fullPath = resolve(process.cwd(), file);
|
|
66
|
+
if (!fullPath.startsWith(process.cwd())) throw new Error('Invalid path');
|
|
67
|
+
|
|
68
|
+
const candidates = process.env.EDITOR_CMD ? [process.env.EDITOR_CMD] : ['code', 'cursor', 'zed'];
|
|
69
|
+
for (const cmd of candidates) {
|
|
70
|
+
const found = await new Promise<boolean>((r) => {
|
|
71
|
+
const p = nodeSpawn('which', [cmd], { stdio: 'ignore' });
|
|
72
|
+
p.on('exit', (code) => r(code === 0));
|
|
73
|
+
p.on('error', () => r(false));
|
|
74
|
+
});
|
|
75
|
+
if (found) {
|
|
76
|
+
nodeSpawn(cmd, [fullPath], { stdio: 'ignore', detached: true }).unref();
|
|
77
|
+
return { opened: fullPath, editor: cmd };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const platformOpeners: Record<string, string> = { darwin: 'open', win32: 'start' };
|
|
81
|
+
const fallback = platformOpeners[process.platform] || 'xdg-open';
|
|
82
|
+
nodeSpawn(fallback, [fullPath], { stdio: 'ignore', detached: true }).unref();
|
|
83
|
+
return { opened: fullPath, editor: fallback };
|
|
84
|
+
});
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const app = new Hono();
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start';
|
|
4
2
|
|
|
5
3
|
interface OpenRouterModel {
|
|
6
4
|
id: string;
|
|
@@ -27,30 +25,6 @@ let cachedResult: ReturnType<typeof formatModels> | null = null;
|
|
|
27
25
|
let cacheTime = 0;
|
|
28
26
|
const CACHE_TTL = 5 * 60 * 1000;
|
|
29
27
|
|
|
30
|
-
/** GET /api/models — List OpenRouter models */
|
|
31
|
-
app.get('/', async (c) => {
|
|
32
|
-
const now = Date.now();
|
|
33
|
-
if (cachedResult && now - cacheTime < CACHE_TTL) {
|
|
34
|
-
return c.json(cachedResult);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const res = await fetch('https://openrouter.ai/api/v1/models');
|
|
39
|
-
if (!res.ok) {
|
|
40
|
-
return c.json({ error: `OpenRouter API error: ${res.status}` }, 502);
|
|
41
|
-
}
|
|
42
|
-
const { data } = (await res.json()) as { data: OpenRouterModel[] };
|
|
43
|
-
cachedResult = formatModels(data);
|
|
44
|
-
cacheTime = now;
|
|
45
|
-
return c.json(cachedResult);
|
|
46
|
-
} catch (err) {
|
|
47
|
-
return c.json(
|
|
48
|
-
{ error: err instanceof Error ? err.message : 'Failed to fetch models' },
|
|
49
|
-
500,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
28
|
function formatModels(models: OpenRouterModel[]) {
|
|
55
29
|
return models
|
|
56
30
|
.filter((m) => {
|
|
@@ -58,7 +32,6 @@ function formatModels(models: OpenRouterModel[]) {
|
|
|
58
32
|
const pp = Number.parseFloat(m.pricing.prompt);
|
|
59
33
|
const cp = Number.parseFloat(m.pricing.completion);
|
|
60
34
|
if (pp < 0 || cp < 0) return false;
|
|
61
|
-
// Only text→text models
|
|
62
35
|
if (!m.architecture?.modality?.startsWith('text')) return false;
|
|
63
36
|
if (!m.architecture.output_modalities?.includes('text')) return false;
|
|
64
37
|
return true;
|
|
@@ -84,4 +57,18 @@ function formatModels(models: OpenRouterModel[]) {
|
|
|
84
57
|
.sort((a, b) => a.promptPrice - b.promptPrice);
|
|
85
58
|
}
|
|
86
59
|
|
|
87
|
-
export
|
|
60
|
+
export const fetchModels = createServerFn({ method: 'GET' }).handler(async () => {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (cachedResult && now - cacheTime < CACHE_TTL) {
|
|
63
|
+
return cachedResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const res = await fetch('https://openrouter.ai/api/v1/models');
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(`OpenRouter API error: ${res.status}`);
|
|
69
|
+
}
|
|
70
|
+
const { data } = (await res.json()) as { data: OpenRouterModel[] };
|
|
71
|
+
cachedResult = formatModels(data);
|
|
72
|
+
cacheTime = now;
|
|
73
|
+
return cachedResult;
|
|
74
|
+
});
|