@geminilight/mindos 0.5.38 → 0.5.39
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/app/app/api/auth/route.ts +61 -9
- package/app/app/api/health/route.ts +46 -1
- package/app/app/page.tsx +48 -2
- package/app/components/HomeContent.tsx +453 -76
- package/app/lib/actions.ts +35 -0
- package/app/lib/core/fs-ops.ts +2 -0
- package/app/lib/core/space-scaffold.ts +103 -0
- package/app/lib/i18n-en.ts +14 -3
- package/app/lib/i18n-zh.ts +14 -3
- package/app/lib/mcp-agents.ts +86 -7
- package/app/package.json +4 -2
- package/package.json +1 -5
|
@@ -5,33 +5,85 @@ import { signJwt } from '@/lib/jwt';
|
|
|
5
5
|
const COOKIE_NAME = 'mindos-session';
|
|
6
6
|
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
|
|
7
7
|
|
|
8
|
+
// Allowed CORS origins for cross-origin auth (Capacitor, Electron remote).
|
|
9
|
+
// Localhost variants are always allowed; custom origins can be added here.
|
|
10
|
+
const ALLOWED_ORIGIN_PATTERNS = [
|
|
11
|
+
/^https?:\/\/localhost(:\d+)?$/,
|
|
12
|
+
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
|
13
|
+
/^https?:\/\/\[::1\](:\d+)?$/,
|
|
14
|
+
/^capacitor:\/\//,
|
|
15
|
+
/^file:\/\//,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function isAllowedOrigin(origin: string): boolean {
|
|
19
|
+
return ALLOWED_ORIGIN_PATTERNS.some((p) => p.test(origin));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** CORS headers — validate origin against allowlist */
|
|
23
|
+
function getAuthCors(req: NextRequest): Record<string, string> {
|
|
24
|
+
const origin = req.headers.get('origin');
|
|
25
|
+
// No origin header (same-origin browser request or non-browser client) → no CORS needed
|
|
26
|
+
if (!origin) return {};
|
|
27
|
+
// Validate origin
|
|
28
|
+
if (!isAllowedOrigin(origin)) return {};
|
|
29
|
+
return {
|
|
30
|
+
'Access-Control-Allow-Origin': origin,
|
|
31
|
+
'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS',
|
|
32
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
33
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function withCors(res: NextResponse, req: NextRequest): NextResponse {
|
|
38
|
+
for (const [k, v] of Object.entries(getAuthCors(req))) res.headers.set(k, v);
|
|
39
|
+
return res;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// OPTIONS /api/auth — CORS preflight
|
|
43
|
+
export async function OPTIONS(req: NextRequest) {
|
|
44
|
+
const headers = getAuthCors(req);
|
|
45
|
+
if (Object.keys(headers).length === 0) {
|
|
46
|
+
return new Response(null, { status: 204 });
|
|
47
|
+
}
|
|
48
|
+
return new Response(null, { status: 204, headers });
|
|
49
|
+
}
|
|
50
|
+
|
|
8
51
|
// POST /api/auth — validate password and set JWT session cookie
|
|
9
52
|
export async function POST(req: NextRequest) {
|
|
10
53
|
const webPassword = process.env.WEB_PASSWORD;
|
|
11
|
-
if (!webPassword) return NextResponse.json({ ok: false }, { status: 401 });
|
|
54
|
+
if (!webPassword) return withCors(NextResponse.json({ ok: false }, { status: 401 }), req);
|
|
12
55
|
|
|
13
|
-
const body = await req.json().catch(() =>
|
|
14
|
-
if (body
|
|
56
|
+
const body = await req.json().catch(() => null);
|
|
57
|
+
if (!body || typeof body !== 'object') {
|
|
58
|
+
return withCors(NextResponse.json({ error: 'Invalid request body' }, { status: 400 }), req);
|
|
59
|
+
}
|
|
60
|
+
const { password } = body as { password?: string };
|
|
61
|
+
if (password !== webPassword) return withCors(NextResponse.json({ ok: false }, { status: 401 }), req);
|
|
15
62
|
|
|
16
63
|
const token = await signJwt(
|
|
17
64
|
{ sub: 'user', exp: Math.floor(Date.now() / 1000) + COOKIE_MAX_AGE },
|
|
18
65
|
webPassword,
|
|
19
66
|
);
|
|
20
67
|
|
|
21
|
-
// Only set Secure flag when served over HTTPS (x-forwarded-proto set by reverse proxy)
|
|
22
68
|
const isHttps = req.headers.get('x-forwarded-proto') === 'https';
|
|
69
|
+
const origin = req.headers.get('origin');
|
|
70
|
+
// Cross-origin requests need SameSite=None + Secure (HTTPS required by spec).
|
|
71
|
+
// Same-origin or no-origin → use Lax (works over HTTP).
|
|
72
|
+
const isCrossOrigin = !!origin && isAllowedOrigin(origin);
|
|
73
|
+
const sameSite = isCrossOrigin && isHttps ? 'None' : 'Lax';
|
|
23
74
|
const secure = isHttps ? '; Secure' : '';
|
|
75
|
+
|
|
24
76
|
const res = NextResponse.json({ ok: true });
|
|
25
77
|
res.headers.set(
|
|
26
78
|
'Set-Cookie',
|
|
27
|
-
`${COOKIE_NAME}=${token}; HttpOnly; SameSite
|
|
79
|
+
`${COOKIE_NAME}=${token}; HttpOnly; SameSite=${sameSite}; Max-Age=${COOKIE_MAX_AGE}; Path=/${secure}`,
|
|
28
80
|
);
|
|
29
|
-
return res;
|
|
81
|
+
return withCors(res, req);
|
|
30
82
|
}
|
|
31
83
|
|
|
32
84
|
// DELETE /api/auth — clear session cookie (logout)
|
|
33
|
-
export async function DELETE() {
|
|
85
|
+
export async function DELETE(req: NextRequest) {
|
|
34
86
|
const res = NextResponse.json({ ok: true });
|
|
35
|
-
res.headers.set('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=
|
|
36
|
-
return res;
|
|
87
|
+
res.headers.set('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/`);
|
|
88
|
+
return withCors(res, req);
|
|
37
89
|
}
|
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
export const dynamic = 'force-dynamic';
|
|
2
2
|
import { NextResponse } from 'next/server';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read version from root package.json.
|
|
8
|
+
* Tries multiple paths to handle dev, production, and standalone builds.
|
|
9
|
+
*/
|
|
10
|
+
function readVersion(): string {
|
|
11
|
+
// 1. Env var set by CLI (most reliable)
|
|
12
|
+
if (process.env.npm_package_version) return process.env.npm_package_version;
|
|
13
|
+
|
|
14
|
+
// 2. Try known relative paths from Next.js app directory
|
|
15
|
+
const candidates = [
|
|
16
|
+
join(process.cwd(), '..', 'package.json'), // dev: app/ → root
|
|
17
|
+
join(process.cwd(), 'package.json'), // if cwd is root
|
|
18
|
+
join(dirname(process.cwd()), 'package.json'), // standalone edge case
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
for (const p of candidates) {
|
|
22
|
+
try {
|
|
23
|
+
const pkg = JSON.parse(readFileSync(p, 'utf-8'));
|
|
24
|
+
if (pkg.name === '@geminilight/mindos' && pkg.version) return pkg.version;
|
|
25
|
+
} catch { /* try next */ }
|
|
26
|
+
}
|
|
27
|
+
return '0.0.0';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const version = readVersion();
|
|
31
|
+
|
|
32
|
+
const CORS_HEADERS: Record<string, string> = {
|
|
33
|
+
'Access-Control-Allow-Origin': '*',
|
|
34
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
35
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
36
|
+
};
|
|
3
37
|
|
|
4
38
|
export async function GET() {
|
|
5
|
-
|
|
39
|
+
const res = NextResponse.json({
|
|
40
|
+
ok: true,
|
|
41
|
+
service: 'mindos',
|
|
42
|
+
version,
|
|
43
|
+
authRequired: !!process.env.WEB_PASSWORD,
|
|
44
|
+
});
|
|
45
|
+
for (const [k, v] of Object.entries(CORS_HEADERS)) res.headers.set(k, v);
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function OPTIONS() {
|
|
50
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
6
51
|
}
|
package/app/app/page.tsx
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
import { redirect } from 'next/navigation';
|
|
2
2
|
import { readSettings } from '@/lib/settings';
|
|
3
|
-
import { getRecentlyModified, getFileContent } from '@/lib/fs';
|
|
3
|
+
import { getRecentlyModified, getFileContent, getFileTree } from '@/lib/fs';
|
|
4
4
|
import { getAllRenderers } from '@/lib/renderers/registry';
|
|
5
5
|
import '@/lib/renderers/index'; // registers all renderers
|
|
6
6
|
import HomeContent from '@/components/HomeContent';
|
|
7
|
+
import type { FileNode } from '@/lib/core/types';
|
|
7
8
|
|
|
8
9
|
export const dynamic = 'force-dynamic';
|
|
9
10
|
|
|
11
|
+
export interface SpaceInfo {
|
|
12
|
+
name: string;
|
|
13
|
+
path: string;
|
|
14
|
+
fileCount: number;
|
|
15
|
+
description: string; // first paragraph from README.md (after title)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function countFiles(node: FileNode): number {
|
|
19
|
+
if (node.type === 'file') return 1;
|
|
20
|
+
return (node.children ?? []).reduce((sum, c) => sum + countFiles(c), 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Extract the first non-empty paragraph after the title from a README.md */
|
|
24
|
+
function extractDescription(spacePath: string): string {
|
|
25
|
+
try {
|
|
26
|
+
const content = getFileContent(spacePath + 'README.md');
|
|
27
|
+
const lines = content.split('\n');
|
|
28
|
+
// Skip title line (# ...) and blank lines, return first non-empty non-heading line
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const trimmed = line.trim();
|
|
31
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
32
|
+
return trimmed;
|
|
33
|
+
}
|
|
34
|
+
} catch { /* README.md doesn't exist */ }
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getTopLevelDirs(): SpaceInfo[] {
|
|
39
|
+
try {
|
|
40
|
+
const tree = getFileTree();
|
|
41
|
+
return tree
|
|
42
|
+
.filter(n => n.type === 'directory' && !n.name.startsWith('.'))
|
|
43
|
+
.map(n => ({
|
|
44
|
+
name: n.name,
|
|
45
|
+
path: n.path + '/',
|
|
46
|
+
fileCount: countFiles(n),
|
|
47
|
+
description: extractDescription(n.path + '/'),
|
|
48
|
+
}));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
10
54
|
function getExistingFiles(paths: string[]): string[] {
|
|
11
55
|
return paths.filter(p => {
|
|
12
56
|
try { getFileContent(p); return true; } catch { return false; }
|
|
@@ -30,5 +74,7 @@ export default function HomePage() {
|
|
|
30
74
|
.filter((p): p is string => !!p);
|
|
31
75
|
const existingFiles = getExistingFiles(entryPaths);
|
|
32
76
|
|
|
33
|
-
|
|
77
|
+
const spaces = getTopLevelDirs();
|
|
78
|
+
|
|
79
|
+
return <HomeContent recent={recent} existingFiles={existingFiles} spaces={spaces} />;
|
|
34
80
|
}
|
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
-
import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass } from 'lucide-react';
|
|
5
|
-
import { useState, useEffect } from 'react';
|
|
4
|
+
import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass, Folder, Puzzle, Brain, Plus, Loader2 } from 'lucide-react';
|
|
5
|
+
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|
6
6
|
import { useLocale } from '@/lib/LocaleContext';
|
|
7
7
|
import { encodePath, relativeTime } from '@/lib/utils';
|
|
8
8
|
import { getAllRenderers } from '@/lib/renderers/registry';
|
|
9
9
|
import '@/lib/renderers/index'; // registers all renderers
|
|
10
10
|
import OnboardingView from './OnboardingView';
|
|
11
11
|
import GuideCard from './GuideCard';
|
|
12
|
+
import { useRouter } from 'next/navigation';
|
|
13
|
+
import { createSpaceAction } from '@/lib/actions';
|
|
14
|
+
import type { SpaceInfo } from '@/app/page';
|
|
12
15
|
|
|
13
16
|
interface RecentFile {
|
|
14
17
|
path: string;
|
|
15
18
|
mtime: number;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
interface SpaceGroup {
|
|
22
|
+
space: string;
|
|
23
|
+
spacePath: string;
|
|
24
|
+
files: RecentFile[];
|
|
25
|
+
latestMtime: number;
|
|
26
|
+
totalFiles: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
function triggerSearch() {
|
|
19
30
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }));
|
|
20
31
|
}
|
|
@@ -23,9 +34,70 @@ function triggerAsk() {
|
|
|
23
34
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: '/', metaKey: true, bubbles: true }));
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
|
|
37
|
+
/** Group recent files by their top-level directory (Space) */
|
|
38
|
+
function groupBySpace(recent: RecentFile[], spaces: SpaceInfo[]): { groups: SpaceGroup[]; rootFiles: RecentFile[] } {
|
|
39
|
+
const groupMap = new Map<string, SpaceGroup>();
|
|
40
|
+
const rootFiles: RecentFile[] = [];
|
|
41
|
+
|
|
42
|
+
for (const file of recent) {
|
|
43
|
+
const parts = file.path.split('/');
|
|
44
|
+
if (parts.length < 2) {
|
|
45
|
+
rootFiles.push(file);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const spaceName = parts[0];
|
|
49
|
+
const spaceInfo = spaces.find(s => s.name === spaceName);
|
|
50
|
+
|
|
51
|
+
if (!groupMap.has(spaceName)) {
|
|
52
|
+
groupMap.set(spaceName, {
|
|
53
|
+
space: spaceName,
|
|
54
|
+
spacePath: spaceName + '/',
|
|
55
|
+
files: [],
|
|
56
|
+
latestMtime: 0,
|
|
57
|
+
totalFiles: spaceInfo?.fileCount ?? 0,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const g = groupMap.get(spaceName)!;
|
|
61
|
+
g.files.push(file);
|
|
62
|
+
g.latestMtime = Math.max(g.latestMtime, file.mtime);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const groups = [...groupMap.values()].sort((a, b) => b.latestMtime - a.latestMtime);
|
|
66
|
+
return { groups, rootFiles };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Extract leading emoji from a directory name, e.g. "📝 Notes" → "📝" */
|
|
70
|
+
function extractEmoji(name: string): string {
|
|
71
|
+
const match = name.match(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u);
|
|
72
|
+
return match?.[0] ?? '';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Strip leading emoji+space from name for display, e.g. "📝 Notes" → "Notes" */
|
|
76
|
+
function stripEmoji(name: string): string {
|
|
77
|
+
return name.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || name;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── Section Title component (shared across all three sections) ── */
|
|
81
|
+
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex items-center gap-2 mb-4">
|
|
84
|
+
<span className="text-[var(--amber)]">{icon}</span>
|
|
85
|
+
<h2 className="text-xs font-semibold uppercase tracking-[0.08em] font-display text-muted-foreground">
|
|
86
|
+
{children}
|
|
87
|
+
</h2>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const FILES_PER_GROUP = 3;
|
|
93
|
+
const SPACES_PER_ROW = 6; // 3 cols × 2 rows on desktop, show 1 row initially
|
|
94
|
+
const PLUGINS_INITIAL = 6;
|
|
95
|
+
|
|
96
|
+
export default function HomeContent({ recent, existingFiles, spaces }: { recent: RecentFile[]; existingFiles?: string[]; spaces?: SpaceInfo[] }) {
|
|
27
97
|
const { t } = useLocale();
|
|
28
98
|
const [showAll, setShowAll] = useState(false);
|
|
99
|
+
const [showAllSpaces, setShowAllSpaces] = useState(false);
|
|
100
|
+
const [showAllPlugins, setShowAllPlugins] = useState(false);
|
|
29
101
|
const [suggestionIdx, setSuggestionIdx] = useState(0);
|
|
30
102
|
|
|
31
103
|
const suggestions = t.ask?.suggestions ?? [
|
|
@@ -56,10 +128,15 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
56
128
|
|
|
57
129
|
const lastFile = recent[0];
|
|
58
130
|
|
|
131
|
+
// Group recent files by Space
|
|
132
|
+
const spaceList = spaces ?? [];
|
|
133
|
+
const { groups, rootFiles } = useMemo(() => groupBySpace(recent, spaceList), [recent, spaceList]);
|
|
134
|
+
|
|
59
135
|
return (
|
|
60
136
|
<div className="content-width px-4 md:px-6 py-8 md:py-12">
|
|
61
137
|
<GuideCard onNavigate={(path) => { window.location.href = `/view/${encodeURIComponent(path)}`; }} />
|
|
62
|
-
|
|
138
|
+
|
|
139
|
+
{/* ── Hero ── */}
|
|
63
140
|
<div className="mb-10">
|
|
64
141
|
<div className="flex items-center gap-2 mb-3">
|
|
65
142
|
<div className="w-1 h-5 rounded-full bg-[var(--amber)]" />
|
|
@@ -73,7 +150,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
73
150
|
|
|
74
151
|
{/* AI-first command bar */}
|
|
75
152
|
<div className="w-full max-w-[620px] flex flex-col sm:flex-row items-stretch sm:items-center gap-2 ml-4">
|
|
76
|
-
{/* Ask AI (primary) */}
|
|
77
153
|
<button
|
|
78
154
|
onClick={triggerAsk}
|
|
79
155
|
title="⌘/"
|
|
@@ -88,8 +164,6 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
88
164
|
⌘/
|
|
89
165
|
</kbd>
|
|
90
166
|
</button>
|
|
91
|
-
|
|
92
|
-
{/* Search files (secondary) */}
|
|
93
167
|
<button
|
|
94
168
|
onClick={triggerSearch}
|
|
95
169
|
title="⌘K"
|
|
@@ -132,98 +206,253 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
132
206
|
<span>{t.explore.title}</span>
|
|
133
207
|
</Link>
|
|
134
208
|
</div>
|
|
209
|
+
</div>
|
|
135
210
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
211
|
+
{/* ── Section 1: Plugins ── */}
|
|
212
|
+
{availablePlugins.length > 0 && (
|
|
213
|
+
<section className="mb-8">
|
|
214
|
+
<SectionTitle icon={<Puzzle size={13} />}>{t.home.plugins}</SectionTitle>
|
|
215
|
+
<div className="flex flex-wrap gap-2">
|
|
216
|
+
{(showAllPlugins ? availablePlugins : availablePlugins.slice(0, PLUGINS_INITIAL)).map(r => (
|
|
140
217
|
<Link
|
|
141
218
|
key={r.id}
|
|
142
219
|
href={`/view/${encodePath(r.entryPath!)}`}
|
|
143
|
-
className="inline-flex items-center gap-
|
|
220
|
+
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-xs transition-all duration-150 hover:border-amber-500/30 hover:bg-muted/60"
|
|
144
221
|
>
|
|
145
222
|
<span className="text-sm leading-none" suppressHydrationWarning>{r.icon}</span>
|
|
146
|
-
<span>{r.name}</span>
|
|
223
|
+
<span className="font-medium text-foreground">{r.name}</span>
|
|
147
224
|
</Link>
|
|
148
225
|
))}
|
|
149
226
|
</div>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
227
|
+
{availablePlugins.length > PLUGINS_INITIAL && (
|
|
228
|
+
<button
|
|
229
|
+
onClick={() => setShowAllPlugins(v => !v)}
|
|
230
|
+
className="flex items-center gap-1.5 mt-2 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
|
|
231
|
+
>
|
|
232
|
+
<ChevronDown size={12} className={`transition-transform duration-200 ${showAllPlugins ? 'rotate-180' : ''}`} />
|
|
233
|
+
<span>{showAllPlugins ? t.home.showLess : t.home.showMore}</span>
|
|
234
|
+
</button>
|
|
235
|
+
)}
|
|
236
|
+
</section>
|
|
237
|
+
)}
|
|
158
238
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
239
|
+
{/* ── Section 2: Spaces ── */}
|
|
240
|
+
{spaceList.length > 0 ? (
|
|
241
|
+
<section className="mb-8">
|
|
242
|
+
<SectionTitle icon={<Brain size={13} />}>{t.home.spaces}</SectionTitle>
|
|
243
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
244
|
+
{(showAllSpaces ? spaceList : spaceList.slice(0, SPACES_PER_ROW)).map(s => {
|
|
245
|
+
const emoji = extractEmoji(s.name);
|
|
246
|
+
const label = stripEmoji(s.name);
|
|
247
|
+
const isEmpty = s.fileCount === 0;
|
|
248
|
+
return (
|
|
249
|
+
<Link
|
|
250
|
+
key={s.name}
|
|
251
|
+
href={`/view/${encodePath(s.path)}`}
|
|
252
|
+
className={`flex items-start gap-3 px-3.5 py-3 rounded-xl border transition-all duration-150 hover:translate-x-0.5 ${
|
|
253
|
+
isEmpty
|
|
254
|
+
? 'border-dashed border-border/50 opacity-50 hover:opacity-70'
|
|
255
|
+
: 'border-border hover:border-amber-500/30 hover:bg-muted/40'
|
|
256
|
+
}`}
|
|
257
|
+
>
|
|
258
|
+
{emoji ? (
|
|
259
|
+
<span className="text-lg leading-none shrink-0 mt-0.5" suppressHydrationWarning>{emoji}</span>
|
|
260
|
+
) : (
|
|
261
|
+
<Folder size={16} className="shrink-0 text-[var(--amber)] mt-0.5" />
|
|
262
|
+
)}
|
|
263
|
+
<div className="min-w-0 flex-1">
|
|
264
|
+
<span className="text-sm font-medium truncate block text-foreground">{label}</span>
|
|
265
|
+
{s.description && (
|
|
266
|
+
<span className="text-xs text-muted-foreground line-clamp-1 mt-0.5" suppressHydrationWarning>{s.description}</span>
|
|
267
|
+
)}
|
|
268
|
+
<span className="text-xs text-muted-foreground opacity-50 mt-0.5 block">
|
|
269
|
+
{t.home.nFiles(s.fileCount)}
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
</Link>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
275
|
+
{/* Show "+" card inline only when it won't be orphaned on its own row */}
|
|
276
|
+
{(showAllSpaces || spaceList.length < SPACES_PER_ROW) && <CreateSpaceCard t={t} />}
|
|
277
|
+
</div>
|
|
278
|
+
{spaceList.length > SPACES_PER_ROW && (
|
|
279
|
+
<div className="flex items-center gap-3 mt-2">
|
|
280
|
+
<button
|
|
281
|
+
onClick={() => setShowAllSpaces(v => !v)}
|
|
282
|
+
className="flex items-center gap-1.5 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
|
|
283
|
+
>
|
|
284
|
+
<ChevronDown size={12} className={`transition-transform duration-200 ${showAllSpaces ? 'rotate-180' : ''}`} />
|
|
285
|
+
<span>{showAllSpaces ? t.home.showLess : t.home.showMore}</span>
|
|
286
|
+
</button>
|
|
287
|
+
{/* When collapsed and "+" card is hidden from grid, show as text link */}
|
|
288
|
+
{!showAllSpaces && <CreateSpaceTextLink t={t} />}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
</section>
|
|
292
|
+
) : (
|
|
293
|
+
/* Show create card even when no spaces exist */
|
|
294
|
+
<section className="mb-8">
|
|
295
|
+
<SectionTitle icon={<Brain size={13} />}>{t.home.spaces}</SectionTitle>
|
|
296
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
297
|
+
<CreateSpaceCard t={t} />
|
|
166
298
|
</div>
|
|
299
|
+
</section>
|
|
300
|
+
)}
|
|
167
301
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
302
|
+
{/* ── Section 3: Recently Edited ── */}
|
|
303
|
+
{recent.length > 0 && (
|
|
304
|
+
<section className="mb-12">
|
|
305
|
+
<SectionTitle icon={<Clock size={13} />}>{t.home.recentlyEdited}</SectionTitle>
|
|
171
306
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
307
|
+
{groups.length > 0 ? (
|
|
308
|
+
/* Space-Grouped View */
|
|
309
|
+
<div className="flex flex-col gap-4">
|
|
310
|
+
{groups.map((group) => {
|
|
311
|
+
const visibleFiles = showAll ? group.files : group.files.slice(0, FILES_PER_GROUP);
|
|
312
|
+
const hasMoreFiles = group.files.length > FILES_PER_GROUP;
|
|
177
313
|
return (
|
|
178
|
-
<div key={
|
|
179
|
-
{/* Timeline dot */}
|
|
180
|
-
<div
|
|
181
|
-
aria-hidden="true"
|
|
182
|
-
className={`absolute -left-4 top-1/2 -translate-y-1/2 rounded-full transition-all duration-150 group-hover:scale-150 ${idx === 0 ? 'w-2 h-2' : 'w-1.5 h-1.5'}`}
|
|
183
|
-
style={{
|
|
184
|
-
background: idx === 0 ? 'var(--amber)' : 'var(--border)',
|
|
185
|
-
outline: idx === 0 ? '2px solid var(--amber-dim)' : 'none',
|
|
186
|
-
}}
|
|
187
|
-
/>
|
|
314
|
+
<div key={group.space}>
|
|
188
315
|
<Link
|
|
189
|
-
href={`/view/${encodePath(
|
|
190
|
-
className="flex items-center gap-
|
|
316
|
+
href={`/view/${encodePath(group.spacePath)}`}
|
|
317
|
+
className="flex items-center gap-2 px-1 py-1.5 rounded-lg group transition-colors hover:bg-muted/50"
|
|
191
318
|
>
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
<div className="flex-1 min-w-0">
|
|
197
|
-
<span className="text-sm font-medium truncate block text-foreground" suppressHydrationWarning>{name}</span>
|
|
198
|
-
{dir && <span className="text-xs truncate block text-muted-foreground opacity-60" suppressHydrationWarning>{dir}</span>}
|
|
199
|
-
</div>
|
|
200
|
-
<span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-50" suppressHydrationWarning>
|
|
201
|
-
{formatTime(mtime)}
|
|
319
|
+
<Folder size={13} className="shrink-0 text-[var(--amber)]" />
|
|
320
|
+
<span className="text-xs font-semibold font-display text-foreground group-hover:text-[var(--amber)] transition-colors" suppressHydrationWarning>
|
|
321
|
+
{group.space}
|
|
202
322
|
</span>
|
|
323
|
+
<span className="text-xs text-muted-foreground opacity-60 tabular-nums" suppressHydrationWarning>
|
|
324
|
+
{t.home.nFiles(group.totalFiles)} · {formatTime(group.latestMtime)}
|
|
325
|
+
</span>
|
|
326
|
+
{hasMoreFiles && !showAll && (
|
|
327
|
+
<span className="text-xs text-muted-foreground opacity-40">
|
|
328
|
+
+{group.files.length - FILES_PER_GROUP}
|
|
329
|
+
</span>
|
|
330
|
+
)}
|
|
203
331
|
</Link>
|
|
332
|
+
<div className="flex flex-col gap-0.5 ml-2 border-l border-border pl-3">
|
|
333
|
+
{visibleFiles.map(({ path: filePath, mtime }) => {
|
|
334
|
+
const isCSV = filePath.endsWith('.csv');
|
|
335
|
+
const name = filePath.split('/').pop() || filePath;
|
|
336
|
+
const subPath = filePath.split('/').slice(1, -1).join('/');
|
|
337
|
+
return (
|
|
338
|
+
<Link
|
|
339
|
+
key={filePath}
|
|
340
|
+
href={`/view/${encodePath(filePath)}`}
|
|
341
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-100 hover:translate-x-0.5 hover:bg-muted group"
|
|
342
|
+
>
|
|
343
|
+
{isCSV
|
|
344
|
+
? <Table size={12} className="shrink-0 text-success" />
|
|
345
|
+
: <FileText size={12} className="shrink-0 text-muted-foreground" />
|
|
346
|
+
}
|
|
347
|
+
<div className="flex-1 min-w-0">
|
|
348
|
+
<span className="text-sm truncate block text-foreground" suppressHydrationWarning>{name}</span>
|
|
349
|
+
{subPath && <span className="text-xs truncate block text-muted-foreground opacity-50" suppressHydrationWarning>{subPath}</span>}
|
|
350
|
+
</div>
|
|
351
|
+
<span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-40" suppressHydrationWarning>
|
|
352
|
+
{formatTime(mtime)}
|
|
353
|
+
</span>
|
|
354
|
+
</Link>
|
|
355
|
+
);
|
|
356
|
+
})}
|
|
357
|
+
</div>
|
|
204
358
|
</div>
|
|
205
359
|
);
|
|
206
360
|
})}
|
|
207
|
-
</div>
|
|
208
361
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
className=
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
362
|
+
{/* Root-level files (Other) */}
|
|
363
|
+
{rootFiles.length > 0 && (
|
|
364
|
+
<div>
|
|
365
|
+
<div className="flex items-center gap-2 px-1 py-1.5">
|
|
366
|
+
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
|
367
|
+
<span className="text-xs font-semibold font-display text-muted-foreground">
|
|
368
|
+
{t.home.other}
|
|
369
|
+
</span>
|
|
370
|
+
</div>
|
|
371
|
+
<div className="flex flex-col gap-0.5 ml-2 border-l border-border pl-3">
|
|
372
|
+
{rootFiles.map(({ path: filePath, mtime }) => (
|
|
373
|
+
<Link
|
|
374
|
+
key={filePath}
|
|
375
|
+
href={`/view/${encodePath(filePath)}`}
|
|
376
|
+
className="flex items-center gap-3 px-3 py-2 rounded-lg transition-all duration-100 hover:translate-x-0.5 hover:bg-muted"
|
|
377
|
+
>
|
|
378
|
+
<FileText size={12} className="shrink-0 text-muted-foreground" />
|
|
379
|
+
<span className="text-sm flex-1 min-w-0 truncate text-foreground" suppressHydrationWarning>
|
|
380
|
+
{filePath.split('/').pop() || filePath}
|
|
381
|
+
</span>
|
|
382
|
+
<span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-40" suppressHydrationWarning>
|
|
383
|
+
{formatTime(mtime)}
|
|
384
|
+
</span>
|
|
385
|
+
</Link>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{/* Show more / less */}
|
|
392
|
+
{groups.some(g => g.files.length > FILES_PER_GROUP) && (
|
|
393
|
+
<button
|
|
394
|
+
onClick={() => setShowAll(v => !v)}
|
|
395
|
+
aria-expanded={showAll}
|
|
396
|
+
className="flex items-center gap-1.5 mt-1 ml-1 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
|
|
397
|
+
>
|
|
398
|
+
<ChevronDown size={12} className={`transition-transform duration-200 ${showAll ? 'rotate-180' : ''}`} />
|
|
399
|
+
<span>{showAll ? t.home.showLess : t.home.showMore}</span>
|
|
400
|
+
</button>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
) : (
|
|
404
|
+
/* Flat Timeline Fallback */
|
|
405
|
+
<div className="relative pl-4">
|
|
406
|
+
<div className="absolute left-0 top-1 bottom-1 w-px bg-border" />
|
|
407
|
+
<div className="flex flex-col gap-0.5">
|
|
408
|
+
{(showAll ? recent : recent.slice(0, 5)).map(({ path: filePath, mtime }, idx) => {
|
|
409
|
+
const isCSV = filePath.endsWith('.csv');
|
|
410
|
+
const name = filePath.split('/').pop() || filePath;
|
|
411
|
+
const dir = filePath.split('/').slice(0, -1).join('/');
|
|
412
|
+
return (
|
|
413
|
+
<div key={filePath} className="relative group">
|
|
414
|
+
<div
|
|
415
|
+
aria-hidden="true"
|
|
416
|
+
className={`absolute -left-4 top-1/2 -translate-y-1/2 rounded-full transition-all duration-150 group-hover:scale-150 ${idx === 0 ? 'w-2 h-2' : 'w-1.5 h-1.5'}`}
|
|
417
|
+
style={{
|
|
418
|
+
background: idx === 0 ? 'var(--amber)' : 'var(--border)',
|
|
419
|
+
outline: idx === 0 ? '2px solid var(--amber-dim)' : 'none',
|
|
420
|
+
}}
|
|
421
|
+
/>
|
|
422
|
+
<Link
|
|
423
|
+
href={`/view/${encodePath(filePath)}`}
|
|
424
|
+
className="flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-100 group-hover:translate-x-0.5 hover:bg-muted"
|
|
425
|
+
>
|
|
426
|
+
{isCSV
|
|
427
|
+
? <Table size={13} className="shrink-0 text-success" />
|
|
428
|
+
: <FileText size={13} className="shrink-0 text-muted-foreground" />
|
|
429
|
+
}
|
|
430
|
+
<div className="flex-1 min-w-0">
|
|
431
|
+
<span className="text-sm font-medium truncate block text-foreground" suppressHydrationWarning>{name}</span>
|
|
432
|
+
{dir && <span className="text-xs truncate block text-muted-foreground opacity-60" suppressHydrationWarning>{dir}</span>}
|
|
433
|
+
</div>
|
|
434
|
+
<span className="text-xs shrink-0 tabular-nums font-display text-muted-foreground opacity-50" suppressHydrationWarning>
|
|
435
|
+
{formatTime(mtime)}
|
|
436
|
+
</span>
|
|
437
|
+
</Link>
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
})}
|
|
441
|
+
</div>
|
|
442
|
+
{recent.length > 5 && (
|
|
443
|
+
<button
|
|
444
|
+
onClick={() => setShowAll(v => !v)}
|
|
445
|
+
aria-expanded={showAll}
|
|
446
|
+
className="flex items-center gap-1.5 mt-2 ml-3 text-xs font-medium text-[var(--amber)] transition-colors hover:opacity-80 cursor-pointer font-display"
|
|
447
|
+
>
|
|
448
|
+
<ChevronDown size={12} className={`transition-transform duration-200 ${showAll ? 'rotate-180' : ''}`} />
|
|
449
|
+
<span>{showAll ? t.home.showLess : t.home.showMore}</span>
|
|
450
|
+
</button>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
224
454
|
</section>
|
|
225
|
-
|
|
226
|
-
})()}
|
|
455
|
+
)}
|
|
227
456
|
|
|
228
457
|
{/* Footer */}
|
|
229
458
|
<div className="mt-16 flex items-center gap-1.5 text-xs font-display text-muted-foreground opacity-60">
|
|
@@ -233,3 +462,151 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
|
|
|
233
462
|
</div>
|
|
234
463
|
);
|
|
235
464
|
}
|
|
465
|
+
|
|
466
|
+
/* ── Create Space inline card ── */
|
|
467
|
+
function CreateSpaceCard({ t }: { t: ReturnType<typeof useLocale>['t'] }) {
|
|
468
|
+
const router = useRouter();
|
|
469
|
+
const [editing, setEditing] = useState(false);
|
|
470
|
+
const [name, setName] = useState('');
|
|
471
|
+
const [description, setDescription] = useState('');
|
|
472
|
+
const [loading, setLoading] = useState(false);
|
|
473
|
+
const [error, setError] = useState('');
|
|
474
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
475
|
+
|
|
476
|
+
const open = useCallback(() => {
|
|
477
|
+
setEditing(true);
|
|
478
|
+
setError('');
|
|
479
|
+
setTimeout(() => inputRef.current?.focus(), 50);
|
|
480
|
+
}, []);
|
|
481
|
+
|
|
482
|
+
const close = useCallback(() => {
|
|
483
|
+
setEditing(false);
|
|
484
|
+
setName('');
|
|
485
|
+
setDescription('');
|
|
486
|
+
setError('');
|
|
487
|
+
}, []);
|
|
488
|
+
|
|
489
|
+
const handleCreate = useCallback(async () => {
|
|
490
|
+
if (!name.trim() || loading) return;
|
|
491
|
+
setLoading(true);
|
|
492
|
+
setError('');
|
|
493
|
+
const result = await createSpaceAction(name, description);
|
|
494
|
+
setLoading(false);
|
|
495
|
+
if (result.success) {
|
|
496
|
+
close();
|
|
497
|
+
router.refresh();
|
|
498
|
+
} else {
|
|
499
|
+
setError(result.error ?? 'Failed to create space');
|
|
500
|
+
}
|
|
501
|
+
}, [name, description, loading, close, router]);
|
|
502
|
+
|
|
503
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
504
|
+
if (e.key === 'Escape') close();
|
|
505
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCreate(); }
|
|
506
|
+
}, [close, handleCreate]);
|
|
507
|
+
|
|
508
|
+
if (!editing) {
|
|
509
|
+
return (
|
|
510
|
+
<button
|
|
511
|
+
onClick={open}
|
|
512
|
+
aria-label={t.home.newSpace}
|
|
513
|
+
className="flex items-center justify-center gap-2 px-3.5 py-3 rounded-xl border border-dashed border-border/50 text-muted-foreground opacity-60 transition-all duration-150 hover:opacity-100 hover:border-amber-500/30 cursor-pointer"
|
|
514
|
+
>
|
|
515
|
+
<Plus size={16} />
|
|
516
|
+
<span className="text-sm font-medium">{t.home.newSpace}</span>
|
|
517
|
+
</button>
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return (
|
|
522
|
+
<div className="flex flex-col gap-2 px-3.5 py-3 rounded-xl border border-[var(--amber)] bg-[var(--amber-subtle)]" onKeyDown={handleKeyDown}>
|
|
523
|
+
<input
|
|
524
|
+
ref={inputRef}
|
|
525
|
+
type="text"
|
|
526
|
+
value={name}
|
|
527
|
+
onChange={e => { setName(e.target.value); setError(''); }}
|
|
528
|
+
placeholder={t.home.spaceName}
|
|
529
|
+
maxLength={80}
|
|
530
|
+
className="text-sm font-medium bg-transparent border-b border-border pb-1 outline-none placeholder:text-muted-foreground/50 text-foreground focus:border-[var(--amber)]"
|
|
531
|
+
/>
|
|
532
|
+
<input
|
|
533
|
+
type="text"
|
|
534
|
+
value={description}
|
|
535
|
+
onChange={e => setDescription(e.target.value)}
|
|
536
|
+
placeholder={t.home.spaceDescription}
|
|
537
|
+
maxLength={200}
|
|
538
|
+
className="text-xs bg-transparent border-b border-border/50 pb-1 outline-none placeholder:text-muted-foreground/40 text-muted-foreground focus:border-[var(--amber)]"
|
|
539
|
+
/>
|
|
540
|
+
{error && <span className="text-xs text-error">{error}</span>}
|
|
541
|
+
<div className="flex items-center gap-2 mt-1">
|
|
542
|
+
<button
|
|
543
|
+
onClick={handleCreate}
|
|
544
|
+
disabled={!name.trim() || loading}
|
|
545
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--amber)] text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
546
|
+
>
|
|
547
|
+
{loading && <Loader2 size={12} className="animate-spin" />}
|
|
548
|
+
{t.home.createSpace}
|
|
549
|
+
</button>
|
|
550
|
+
<button
|
|
551
|
+
onClick={close}
|
|
552
|
+
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
|
|
553
|
+
>
|
|
554
|
+
{t.home.cancelCreate}
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Compact "+ New Space" text link shown next to "Show more" when grid is collapsed */
|
|
562
|
+
function CreateSpaceTextLink({ t }: { t: ReturnType<typeof useLocale>['t'] }) {
|
|
563
|
+
const router = useRouter();
|
|
564
|
+
const [editing, setEditing] = useState(false);
|
|
565
|
+
const [name, setName] = useState('');
|
|
566
|
+
const [loading, setLoading] = useState(false);
|
|
567
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
568
|
+
|
|
569
|
+
if (!editing) {
|
|
570
|
+
return (
|
|
571
|
+
<button
|
|
572
|
+
onClick={() => { setEditing(true); setTimeout(() => inputRef.current?.focus(), 50); }}
|
|
573
|
+
className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-[var(--amber)] cursor-pointer"
|
|
574
|
+
>
|
|
575
|
+
<Plus size={11} />
|
|
576
|
+
<span>{t.home.newSpace}</span>
|
|
577
|
+
</button>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<form
|
|
583
|
+
className="flex items-center gap-2"
|
|
584
|
+
onSubmit={async (e) => {
|
|
585
|
+
e.preventDefault();
|
|
586
|
+
if (!name.trim() || loading) return;
|
|
587
|
+
setLoading(true);
|
|
588
|
+
const result = await createSpaceAction(name, '');
|
|
589
|
+
setLoading(false);
|
|
590
|
+
if (result.success) { setEditing(false); setName(''); router.refresh(); }
|
|
591
|
+
}}
|
|
592
|
+
>
|
|
593
|
+
<input
|
|
594
|
+
ref={inputRef}
|
|
595
|
+
type="text"
|
|
596
|
+
value={name}
|
|
597
|
+
onChange={e => setName(e.target.value)}
|
|
598
|
+
onKeyDown={e => { if (e.key === 'Escape') { setEditing(false); setName(''); } }}
|
|
599
|
+
placeholder={t.home.spaceName}
|
|
600
|
+
maxLength={80}
|
|
601
|
+
className="text-xs bg-transparent border-b border-border pb-0.5 outline-none w-28 placeholder:text-muted-foreground/40 text-foreground focus:border-[var(--amber)]"
|
|
602
|
+
/>
|
|
603
|
+
<button
|
|
604
|
+
type="submit"
|
|
605
|
+
disabled={!name.trim() || loading}
|
|
606
|
+
className="text-xs font-medium text-[var(--amber)] disabled:opacity-40"
|
|
607
|
+
>
|
|
608
|
+
{loading ? '...' : t.home.createSpace}
|
|
609
|
+
</button>
|
|
610
|
+
</form>
|
|
611
|
+
);
|
|
612
|
+
}
|
package/app/lib/actions.ts
CHANGED
|
@@ -38,3 +38,38 @@ export async function renameFileAction(oldPath: string, newName: string): Promis
|
|
|
38
38
|
return { success: false, error: err instanceof Error ? err.message : 'Failed to rename file' };
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create a new Mind Space (top-level directory) with README.md + auto-scaffolded INSTRUCTION.md.
|
|
44
|
+
* The description is written into README.md so it appears on the homepage Space card
|
|
45
|
+
* and is loaded by Agent bootstrap as directory context.
|
|
46
|
+
*/
|
|
47
|
+
export async function createSpaceAction(
|
|
48
|
+
name: string,
|
|
49
|
+
description: string
|
|
50
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
51
|
+
try {
|
|
52
|
+
const trimmed = name.trim();
|
|
53
|
+
if (!trimmed) return { success: false, error: 'Space name is required' };
|
|
54
|
+
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
|
55
|
+
return { success: false, error: 'Space name must not contain path separators' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Strip emoji for clean title in README content
|
|
59
|
+
const cleanName = trimmed.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || trimmed;
|
|
60
|
+
const desc = description.trim() || '(Describe the purpose and usage of this space.)';
|
|
61
|
+
const readmeContent = `# ${cleanName}\n\n${desc}\n\n## 📁 Structure\n\n\`\`\`bash\n${trimmed}/\n├── INSTRUCTION.md\n├── README.md\n└── (your files here)\n\`\`\`\n\n## 💡 Usage\n\n(Add usage guidelines for this space.)\n`;
|
|
62
|
+
|
|
63
|
+
// createFile triggers scaffoldIfNewSpace → auto-generates INSTRUCTION.md
|
|
64
|
+
createFile(`${trimmed}/README.md`, readmeContent);
|
|
65
|
+
revalidatePath('/', 'layout');
|
|
66
|
+
return { success: true };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const msg = err instanceof Error ? err.message : 'Failed to create space';
|
|
69
|
+
// Make "already exists" error more user-friendly
|
|
70
|
+
if (msg.includes('already exists')) {
|
|
71
|
+
return { success: false, error: 'A space with this name already exists' };
|
|
72
|
+
}
|
|
73
|
+
return { success: false, error: msg };
|
|
74
|
+
}
|
|
75
|
+
}
|
package/app/lib/core/fs-ops.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { resolveSafe, assertWithinRoot } from './security';
|
|
4
4
|
import { MindOSError, ErrorCodes } from '@/lib/errors';
|
|
5
|
+
import { scaffoldIfNewSpace } from './space-scaffold';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Reads the content of a file given a relative path from mindRoot.
|
|
@@ -40,6 +41,7 @@ export function createFile(mindRoot: string, filePath: string, initialContent =
|
|
|
40
41
|
}
|
|
41
42
|
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
42
43
|
fs.writeFileSync(resolved, initialContent, 'utf-8');
|
|
44
|
+
scaffoldIfNewSpace(mindRoot, filePath);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-scaffolding for new top-level directories (Spaces).
|
|
3
|
+
*
|
|
4
|
+
* When a file is created inside a new first-level directory that lacks
|
|
5
|
+
* INSTRUCTION.md, this module generates lightweight scaffolding files
|
|
6
|
+
* so that MindOS Agents can bootstrap correctly in any Space.
|
|
7
|
+
*
|
|
8
|
+
* Design decisions:
|
|
9
|
+
* - Only acts on first-level directories (direct children of mindRoot)
|
|
10
|
+
* - Idempotent: never overwrites existing files
|
|
11
|
+
* - Fail-safe: errors are silently caught (scaffold must never block file ops)
|
|
12
|
+
* - Does NOT modify root README.md (that's the Agent's job per INSTRUCTION.md §5.1)
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
export const INSTRUCTION_TEMPLATE = (dirName: string) =>
|
|
18
|
+
`# ${dirName} Instruction Set
|
|
19
|
+
|
|
20
|
+
## Goal
|
|
21
|
+
|
|
22
|
+
- Define local execution rules for this directory.
|
|
23
|
+
|
|
24
|
+
## Local Rules
|
|
25
|
+
|
|
26
|
+
- Read root \`INSTRUCTION.md\` first.
|
|
27
|
+
- Then read this directory \`README.md\` for navigation.
|
|
28
|
+
- Keep edits minimal, structured, and traceable.
|
|
29
|
+
|
|
30
|
+
## Execution Order
|
|
31
|
+
|
|
32
|
+
1. Root \`INSTRUCTION.md\`
|
|
33
|
+
2. This directory \`INSTRUCTION.md\`
|
|
34
|
+
3. This directory \`README.md\` and target files
|
|
35
|
+
|
|
36
|
+
## Boundary
|
|
37
|
+
|
|
38
|
+
- Root rules win on conflict.
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
export const README_TEMPLATE = (dirName: string) =>
|
|
42
|
+
`# ${dirName}
|
|
43
|
+
|
|
44
|
+
## 📁 Structure
|
|
45
|
+
|
|
46
|
+
\`\`\`bash
|
|
47
|
+
${dirName}/
|
|
48
|
+
├── INSTRUCTION.md
|
|
49
|
+
├── README.md
|
|
50
|
+
└── (your files here)
|
|
51
|
+
\`\`\`
|
|
52
|
+
|
|
53
|
+
## 💡 Usage
|
|
54
|
+
|
|
55
|
+
(Describe the purpose and usage of this space.)
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Strip leading emoji and whitespace from a directory name.
|
|
60
|
+
* e.g. "📖 Learning" → "Learning", "🔄 Workflows" → "Workflows"
|
|
61
|
+
*/
|
|
62
|
+
export function cleanDirName(dirName: string): string {
|
|
63
|
+
// Match leading emoji (Unicode emoji properties) + whitespace
|
|
64
|
+
const cleaned = dirName.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '');
|
|
65
|
+
return cleaned || dirName; // fallback to original if everything was stripped
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* If filePath is inside a top-level directory that lacks INSTRUCTION.md,
|
|
70
|
+
* auto-generate scaffolding files.
|
|
71
|
+
*
|
|
72
|
+
* - Only triggered by createFile (not writeFile) — see spec for rationale
|
|
73
|
+
* - Idempotent: won't overwrite existing files
|
|
74
|
+
* - Only acts on first-level directories (direct children of mindRoot)
|
|
75
|
+
*/
|
|
76
|
+
export function scaffoldIfNewSpace(mindRoot: string, filePath: string): void {
|
|
77
|
+
try {
|
|
78
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
79
|
+
if (parts.length < 2) return; // root-level file, not inside a Space
|
|
80
|
+
|
|
81
|
+
const topDir = parts[0];
|
|
82
|
+
if (topDir.startsWith('.')) return; // skip hidden directories
|
|
83
|
+
|
|
84
|
+
const topDirAbs = path.join(mindRoot, topDir);
|
|
85
|
+
const instructionPath = path.join(topDirAbs, 'INSTRUCTION.md');
|
|
86
|
+
|
|
87
|
+
// Already has INSTRUCTION.md → not a new Space, skip
|
|
88
|
+
if (fs.existsSync(instructionPath)) return;
|
|
89
|
+
|
|
90
|
+
const name = cleanDirName(topDir);
|
|
91
|
+
|
|
92
|
+
// Generate INSTRUCTION.md
|
|
93
|
+
fs.writeFileSync(instructionPath, INSTRUCTION_TEMPLATE(name), 'utf-8');
|
|
94
|
+
|
|
95
|
+
// Generate README.md only if it doesn't exist
|
|
96
|
+
const readmePath = path.join(topDirAbs, 'README.md');
|
|
97
|
+
if (!fs.existsSync(readmePath)) {
|
|
98
|
+
fs.writeFileSync(readmePath, README_TEMPLATE(name), 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Scaffold failure must never block the primary file operation
|
|
102
|
+
}
|
|
103
|
+
}
|
package/app/lib/i18n-en.ts
CHANGED
|
@@ -3,11 +3,22 @@ export const en = {
|
|
|
3
3
|
relatedFiles: 'Related Files',
|
|
4
4
|
},
|
|
5
5
|
app: {
|
|
6
|
-
tagline: '
|
|
7
|
-
footer: 'MindOS ·
|
|
6
|
+
tagline: 'You think here, Agents act there.',
|
|
7
|
+
footer: 'MindOS · human-agent collaborative mind system',
|
|
8
8
|
},
|
|
9
9
|
home: {
|
|
10
10
|
recentlyModified: 'Recently Modified',
|
|
11
|
+
recentlyActive: 'Recently Active',
|
|
12
|
+
recentlyEdited: 'Recently Edited',
|
|
13
|
+
allSpaces: 'All Spaces',
|
|
14
|
+
spaces: 'Spaces',
|
|
15
|
+
nFiles: (n: number) => `${n} file${n === 1 ? '' : 's'}`,
|
|
16
|
+
other: 'Other',
|
|
17
|
+
newSpace: 'New Space',
|
|
18
|
+
spaceName: 'Space name',
|
|
19
|
+
spaceDescription: 'What is this space for?',
|
|
20
|
+
createSpace: 'Create',
|
|
21
|
+
cancelCreate: 'Cancel',
|
|
11
22
|
continueEditing: 'Continue editing',
|
|
12
23
|
newNote: 'New Notes',
|
|
13
24
|
plugins: 'Plugins',
|
|
@@ -29,7 +40,7 @@ export const en = {
|
|
|
29
40
|
},
|
|
30
41
|
},
|
|
31
42
|
sidebar: {
|
|
32
|
-
files: '
|
|
43
|
+
files: 'Spaces',
|
|
33
44
|
search: 'Search',
|
|
34
45
|
searchTitle: 'Search',
|
|
35
46
|
askTitle: 'MindOS Agent',
|
package/app/lib/i18n-zh.ts
CHANGED
|
@@ -28,11 +28,22 @@ export const zh = {
|
|
|
28
28
|
relatedFiles: '关联视图',
|
|
29
29
|
},
|
|
30
30
|
app: {
|
|
31
|
-
tagline: '
|
|
32
|
-
footer: 'MindOS ·
|
|
31
|
+
tagline: '你在此思考,Agent 依此行动。',
|
|
32
|
+
footer: 'MindOS · 人机共生知识系统',
|
|
33
33
|
},
|
|
34
34
|
home: {
|
|
35
35
|
recentlyModified: '最近修改',
|
|
36
|
+
recentlyActive: '最近活跃',
|
|
37
|
+
recentlyEdited: '最近编辑',
|
|
38
|
+
allSpaces: '所有空间',
|
|
39
|
+
spaces: '心智空间',
|
|
40
|
+
nFiles: (n: number) => `${n} 个文件`,
|
|
41
|
+
other: '其他',
|
|
42
|
+
newSpace: '新建空间',
|
|
43
|
+
spaceName: '空间名称',
|
|
44
|
+
spaceDescription: '这个空间用来做什么?',
|
|
45
|
+
createSpace: '创建',
|
|
46
|
+
cancelCreate: '取消',
|
|
36
47
|
continueEditing: '继续编辑',
|
|
37
48
|
newNote: '新建笔记',
|
|
38
49
|
plugins: '插件',
|
|
@@ -54,7 +65,7 @@ export const zh = {
|
|
|
54
65
|
},
|
|
55
66
|
},
|
|
56
67
|
sidebar: {
|
|
57
|
-
files: '
|
|
68
|
+
files: '空间',
|
|
58
69
|
search: '搜索',
|
|
59
70
|
searchTitle: '搜索',
|
|
60
71
|
askTitle: 'MindOS Agent',
|
package/app/lib/mcp-agents.ts
CHANGED
|
@@ -211,17 +211,29 @@ export function detectInstalled(agentKey: string): { installed: boolean; scope?:
|
|
|
211
211
|
const agent = MCP_AGENTS[agentKey];
|
|
212
212
|
if (!agent) return { installed: false };
|
|
213
213
|
|
|
214
|
-
for (const [
|
|
214
|
+
for (const [scopeType, cfgPath] of [['global', agent.global], ['project', agent.project]] as [string, string | null][]) {
|
|
215
215
|
if (!cfgPath) continue;
|
|
216
216
|
const absPath = expandHome(cfgPath);
|
|
217
217
|
if (!fs.existsSync(absPath)) continue;
|
|
218
218
|
try {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
if (
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
219
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
220
|
+
// Handle TOML format (e.g., codex)
|
|
221
|
+
if (agent.format === 'toml') {
|
|
222
|
+
const result = parseTomlMcpEntry(content, agent.key, 'mindos');
|
|
223
|
+
if (result.found && result.entry) {
|
|
224
|
+
const entry = result.entry;
|
|
225
|
+
const transport = entry.type === 'stdio' ? 'stdio' : entry.url ? 'http' : 'unknown';
|
|
226
|
+
return { installed: true, scope: scopeType, transport, configPath: cfgPath };
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// JSON format (default)
|
|
230
|
+
const config = JSON.parse(content);
|
|
231
|
+
const servers = config[agent.key];
|
|
232
|
+
if (servers?.mindos) {
|
|
233
|
+
const entry = servers.mindos;
|
|
234
|
+
const transport = entry.type === 'stdio' ? 'stdio' : entry.url ? 'http' : 'unknown';
|
|
235
|
+
return { installed: true, scope: scopeType, transport, configPath: cfgPath };
|
|
236
|
+
}
|
|
225
237
|
}
|
|
226
238
|
} catch { /* ignore parse errors */ }
|
|
227
239
|
}
|
|
@@ -229,6 +241,73 @@ export function detectInstalled(agentKey: string): { installed: boolean; scope?:
|
|
|
229
241
|
return { installed: false };
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
// Parse TOML to find MCP server entry without external library
|
|
245
|
+
function parseTomlMcpEntry(content: string, sectionKey: string, serverName: string): { found: boolean; entry?: { type?: string; url?: string } } {
|
|
246
|
+
const lines = content.split('\n');
|
|
247
|
+
const targetSection = `[${sectionKey}.${serverName}]`;
|
|
248
|
+
const genericSection = `[${sectionKey}]`;
|
|
249
|
+
|
|
250
|
+
let inTargetSection = false;
|
|
251
|
+
let inGenericSection = false;
|
|
252
|
+
let foundInGeneric = false;
|
|
253
|
+
let entry: { type?: string; url?: string } = {};
|
|
254
|
+
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
const trimmed = line.trim();
|
|
257
|
+
|
|
258
|
+
// Check for section headers
|
|
259
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
260
|
+
// Save previous section result if we were in the target
|
|
261
|
+
if (inTargetSection && (entry.type || entry.url)) {
|
|
262
|
+
return { found: true, entry };
|
|
263
|
+
}
|
|
264
|
+
if (foundInGeneric && (entry.type || entry.url)) {
|
|
265
|
+
return { found: true, entry };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
inTargetSection = trimmed === targetSection;
|
|
269
|
+
inGenericSection = trimmed === genericSection;
|
|
270
|
+
foundInGeneric = false;
|
|
271
|
+
entry = {};
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Parse key-value pairs
|
|
276
|
+
const match = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=\s*(.+)$/);
|
|
277
|
+
if (match) {
|
|
278
|
+
const [, key, rawValue] = match;
|
|
279
|
+
// Remove quotes from value
|
|
280
|
+
const value = rawValue.replace(/^["'](.+)["']$/, '$1');
|
|
281
|
+
|
|
282
|
+
if (inTargetSection) {
|
|
283
|
+
if (key === 'type') entry.type = value;
|
|
284
|
+
if (key === 'url') entry.url = value;
|
|
285
|
+
} else if (inGenericSection && key === serverName) {
|
|
286
|
+
// Check if it's a table reference like mindos = { type = "stdio" }
|
|
287
|
+
const tableMatch = rawValue.match(/\{\s*type\s*=\s*["']([^"']+)["'].*?\}/);
|
|
288
|
+
if (tableMatch) {
|
|
289
|
+
entry.type = tableMatch[1];
|
|
290
|
+
}
|
|
291
|
+
const urlMatch = rawValue.match(/url\s*=\s*["']([^"']+)["']/);
|
|
292
|
+
if (urlMatch) {
|
|
293
|
+
entry.url = urlMatch[1];
|
|
294
|
+
}
|
|
295
|
+
foundInGeneric = true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check at end of file
|
|
301
|
+
if (inTargetSection && (entry.type || entry.url)) {
|
|
302
|
+
return { found: true, entry };
|
|
303
|
+
}
|
|
304
|
+
if (foundInGeneric && (entry.type || entry.url)) {
|
|
305
|
+
return { found: true, entry };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { found: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
232
311
|
/* ── Agent Presence Detection ──────────────────────────────────────────── */
|
|
233
312
|
|
|
234
313
|
export function detectAgentPresence(agentKey: string): boolean {
|
package/app/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"lint": "eslint",
|
|
11
11
|
"test": "vitest run"
|
|
12
12
|
},
|
|
13
|
-
|
|
13
|
+
"dependencies": {
|
|
14
14
|
"@base-ui/react": "^1.2.0",
|
|
15
15
|
"@codemirror/lang-markdown": "^6.5.0",
|
|
16
16
|
"@codemirror/state": "^6.5.4",
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
"class-variance-authority": "^0.7.1",
|
|
35
35
|
"clsx": "^2.1.1",
|
|
36
36
|
"codemirror": "^6.0.2",
|
|
37
|
+
"extend": "^3.0.2",
|
|
37
38
|
"fuse.js": "^7.1.0",
|
|
38
39
|
"github-slugger": "^2.0.0",
|
|
39
40
|
"lucide-react": "^0.577.0",
|
|
41
|
+
"nanoid": "^5.1.0",
|
|
40
42
|
"next": "16.1.6",
|
|
41
43
|
"papaparse": "^5.5.3",
|
|
42
44
|
"pdfjs-dist": "^4.10.38",
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"tailwind-merge": "^3.5.0",
|
|
53
55
|
"tiptap-markdown": "^0.9.0",
|
|
54
56
|
"tw-animate-css": "^1.4.0",
|
|
55
|
-
|
|
57
|
+
"zod": "^3.23.8"
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@tailwindcss/postcss": "^4",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geminilight/mindos",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.39",
|
|
4
4
|
"description": "MindOS — Human-Agent Collaborative Mind System. Local-first knowledge base that syncs your mind to all AI Agents via MCP.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mindos",
|
|
@@ -22,10 +22,6 @@
|
|
|
22
22
|
"bin": {
|
|
23
23
|
"mindos": "bin/cli.js"
|
|
24
24
|
},
|
|
25
|
-
"workspaces": [
|
|
26
|
-
"app",
|
|
27
|
-
"mcp"
|
|
28
|
-
],
|
|
29
25
|
"files": [
|
|
30
26
|
"app/",
|
|
31
27
|
"mcp/",
|