@jant/core 0.3.32 → 0.3.34
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/dist/client/client.css +1 -1
- package/dist/client/client.js +1442 -989
- package/dist/index.js +1431 -1057
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +6 -3
- package/src/__tests__/helpers/db.ts +3 -0
- package/src/app.tsx +1 -1
- package/src/client.ts +2 -1
- package/src/db/migrations/0011_add_path_registry.sql +23 -0
- package/src/db/schema.ts +12 -1
- package/src/i18n/locales/en.po +225 -91
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +201 -152
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +201 -152
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/excerpt.test.ts +25 -0
- package/src/lib/__tests__/resolve-config.test.ts +26 -2
- package/src/lib/__tests__/timeline.test.ts +2 -1
- package/src/lib/compose-bridge.ts +30 -1
- package/src/lib/excerpt.ts +16 -7
- package/src/lib/nav-manager-bridge.ts +54 -0
- package/src/lib/navigation.ts +7 -4
- package/src/lib/render.tsx +5 -2
- package/src/lib/resolve-config.ts +7 -0
- package/src/lib/view.ts +42 -10
- package/src/middleware/error-handler.ts +16 -0
- package/src/routes/api/__tests__/posts.test.ts +80 -0
- package/src/routes/api/__tests__/settings.test.ts +1 -1
- package/src/routes/api/posts.ts +6 -29
- package/src/routes/api/upload.ts +2 -14
- package/src/routes/auth/__tests__/setup.test.ts +3 -2
- package/src/routes/auth/setup.tsx +1 -1
- package/src/routes/compose.tsx +13 -5
- package/src/routes/dash/__tests__/pages.test.ts +2 -1
- package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
- package/src/routes/dash/appearance.tsx +71 -4
- package/src/routes/dash/collections.tsx +15 -21
- package/src/routes/dash/media.tsx +1 -13
- package/src/routes/dash/pages.tsx +5 -150
- package/src/routes/dash/posts.tsx +25 -32
- package/src/routes/dash/redirects.tsx +9 -11
- package/src/routes/dash/settings.tsx +29 -111
- package/src/routes/feed/__tests__/rss.test.ts +5 -1
- package/src/routes/pages/__tests__/collections.test.ts +2 -1
- package/src/routes/pages/__tests__/featured.test.ts +2 -1
- package/src/routes/pages/page.tsx +20 -25
- package/src/services/__tests__/collection.test.ts +2 -1
- package/src/services/__tests__/media.test.ts +78 -1
- package/src/services/__tests__/navigation.test.ts +2 -1
- package/src/services/__tests__/page.test.ts +78 -1
- package/src/services/__tests__/path-registry.test.ts +165 -0
- package/src/services/__tests__/post-timeline.test.ts +2 -1
- package/src/services/__tests__/post.test.ts +103 -1
- package/src/services/__tests__/redirect.test.ts +53 -4
- package/src/services/__tests__/search.test.ts +2 -1
- package/src/services/__tests__/settings.test.ts +153 -0
- package/src/services/index.ts +12 -4
- package/src/services/media.ts +72 -4
- package/src/services/page.ts +64 -17
- package/src/services/path-registry.ts +160 -0
- package/src/services/post.ts +119 -24
- package/src/services/redirect.ts +23 -3
- package/src/services/settings.ts +181 -0
- package/src/styles/components.css +135 -0
- package/src/styles/tokens.css +6 -1
- package/src/styles/ui.css +70 -26
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +7 -2
- package/src/types/constants.ts +9 -1
- package/src/types/sortablejs.d.ts +8 -2
- package/src/types/views.ts +1 -1
- package/src/ui/color-themes.ts +31 -31
- package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
- package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
- package/src/ui/components/jant-compose-dialog.ts +3 -2
- package/src/ui/components/jant-compose-editor.ts +17 -2
- package/src/ui/components/jant-nav-manager.ts +1067 -0
- package/src/ui/components/jant-settings-general.ts +2 -35
- package/src/ui/components/nav-manager-types.ts +72 -0
- package/src/ui/components/settings-types.ts +0 -3
- package/src/ui/compose/ComposePrompt.tsx +3 -11
- package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
- package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
- package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
- package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
- package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
- package/src/ui/dash/pages/PagesContent.tsx +74 -0
- package/src/ui/dash/settings/AccountContent.tsx +0 -3
- package/src/ui/dash/settings/GeneralContent.tsx +1 -19
- package/src/ui/dash/settings/SettingsNav.tsx +2 -6
- package/src/ui/feed/NoteCard.tsx +2 -2
- package/src/ui/layouts/DashLayout.tsx +83 -86
- package/src/ui/layouts/SiteLayout.tsx +82 -21
- package/src/lib/nav-reorder.ts +0 -26
- package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
package/src/services/post.ts
CHANGED
|
@@ -12,7 +12,17 @@ import type { Database } from "../db/index.js";
|
|
|
12
12
|
import { posts, postCollections } from "../db/schema.js";
|
|
13
13
|
import { now } from "../lib/time.js";
|
|
14
14
|
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
15
|
+
import type { StorageDriver } from "../lib/storage.js";
|
|
16
|
+
import type { MediaService } from "./media.js";
|
|
15
17
|
import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
|
|
18
|
+
import type { PathRegistryService } from "./path-registry.js";
|
|
19
|
+
import { ConflictError } from "../lib/errors.js";
|
|
20
|
+
|
|
21
|
+
/** Dependencies for operations that coordinate with other services */
|
|
22
|
+
export interface PostDeleteDeps {
|
|
23
|
+
media: MediaService;
|
|
24
|
+
storage?: StorageDriver | null;
|
|
25
|
+
}
|
|
16
26
|
|
|
17
27
|
export interface PostFilters {
|
|
18
28
|
format?: Format;
|
|
@@ -37,7 +47,14 @@ export interface PostService {
|
|
|
37
47
|
count(filters?: PostFilters): Promise<number>;
|
|
38
48
|
create(data: CreatePost): Promise<Post>;
|
|
39
49
|
update(id: number, data: UpdatePost): Promise<Post | null>;
|
|
40
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Soft-delete a post and clean up its media (storage files + DB records).
|
|
52
|
+
* Thread roots cascade to all replies.
|
|
53
|
+
*
|
|
54
|
+
* @param id - Post ID
|
|
55
|
+
* @param deps - Media service and optional storage driver for file cleanup
|
|
56
|
+
*/
|
|
57
|
+
delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
|
|
41
58
|
getThread(rootId: number): Promise<Post[]>;
|
|
42
59
|
updateThreadStatusAndFeatured(
|
|
43
60
|
rootId: number,
|
|
@@ -53,7 +70,16 @@ export interface PostService {
|
|
|
53
70
|
): Promise<Map<number, Post[]>>;
|
|
54
71
|
}
|
|
55
72
|
|
|
56
|
-
|
|
73
|
+
/** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
|
|
74
|
+
function isUniqueConstraintError(err: unknown): boolean {
|
|
75
|
+
const msg = String(err);
|
|
76
|
+
return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createPostService(
|
|
80
|
+
db: Database,
|
|
81
|
+
pathRegistry: PathRegistryService,
|
|
82
|
+
): PostService {
|
|
57
83
|
/** Build WHERE conditions from filters (shared by list and count) */
|
|
58
84
|
function buildFilterConditions(filters: PostFilters) {
|
|
59
85
|
const conditions = [];
|
|
@@ -189,31 +215,53 @@ export function createPostService(db: Database): PostService {
|
|
|
189
215
|
}
|
|
190
216
|
}
|
|
191
217
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
218
|
+
// Validate path availability before DB insert — throws friendly
|
|
219
|
+
// ConflictError/ValidationError instead of a raw UNIQUE constraint error.
|
|
220
|
+
// Uses placeholder owner ID; corrected to real ID after insert.
|
|
221
|
+
if (data.path) {
|
|
222
|
+
await pathRegistry.claim(data.path, "post", 0);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let result;
|
|
226
|
+
try {
|
|
227
|
+
result = await db
|
|
228
|
+
.insert(posts)
|
|
229
|
+
.values({
|
|
230
|
+
format: data.format,
|
|
231
|
+
status,
|
|
232
|
+
featured: featured ? 1 : 0,
|
|
233
|
+
pinned: data.pinned ? 1 : 0,
|
|
234
|
+
path: data.path ?? null,
|
|
235
|
+
title: data.title ?? null,
|
|
236
|
+
url: data.url ?? null,
|
|
237
|
+
body: data.body ?? null,
|
|
238
|
+
bodyHtml,
|
|
239
|
+
quoteText: data.quoteText ?? null,
|
|
240
|
+
rating: data.rating ?? null,
|
|
241
|
+
replyToId: data.replyToId ?? null,
|
|
242
|
+
threadId,
|
|
243
|
+
publishedAt: data.publishedAt ?? timestamp,
|
|
244
|
+
createdAt: timestamp,
|
|
245
|
+
updatedAt: timestamp,
|
|
246
|
+
})
|
|
247
|
+
.returning();
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (data.path) await pathRegistry.release(data.path);
|
|
250
|
+
if (isUniqueConstraintError(err)) {
|
|
251
|
+
throw new ConflictError(`Path "${data.path}" is already in use`);
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
213
255
|
|
|
214
256
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
215
257
|
const post = toPost(result[0]!);
|
|
216
258
|
|
|
259
|
+
// Update registry with actual post ID
|
|
260
|
+
if (post.path) {
|
|
261
|
+
await pathRegistry.release(post.path);
|
|
262
|
+
await pathRegistry.claim(post.path, "post", post.id);
|
|
263
|
+
}
|
|
264
|
+
|
|
217
265
|
// Sync collection memberships if provided
|
|
218
266
|
if (data.collectionIds && data.collectionIds.length > 0) {
|
|
219
267
|
await db.insert(postCollections).values(
|
|
@@ -231,6 +279,20 @@ export function createPostService(db: Database): PostService {
|
|
|
231
279
|
const existing = await this.getById(id);
|
|
232
280
|
if (!existing) return null;
|
|
233
281
|
|
|
282
|
+
// Handle path changes in the registry before modifying the post
|
|
283
|
+
const pathChanging =
|
|
284
|
+
data.path !== undefined && data.path !== existing.path;
|
|
285
|
+
if (pathChanging) {
|
|
286
|
+
// Claim new path (if non-null) before releasing old
|
|
287
|
+
if (data.path) {
|
|
288
|
+
await pathRegistry.claim(data.path, "post", id);
|
|
289
|
+
}
|
|
290
|
+
// Release old path (if it existed)
|
|
291
|
+
if (existing.path) {
|
|
292
|
+
await pathRegistry.release(existing.path);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
234
296
|
const timestamp = now();
|
|
235
297
|
const updates: Partial<typeof posts.$inferInsert> = {
|
|
236
298
|
updatedAt: timestamp,
|
|
@@ -335,10 +397,43 @@ export function createPostService(db: Database): PostService {
|
|
|
335
397
|
return updateResult?.[0] ? toPost(updateResult[0]) : null;
|
|
336
398
|
},
|
|
337
399
|
|
|
338
|
-
async delete(id) {
|
|
400
|
+
async delete(id, deps) {
|
|
339
401
|
const existing = await this.getById(id);
|
|
340
402
|
if (!existing) return false;
|
|
341
403
|
|
|
404
|
+
// Clean up media for all affected posts
|
|
405
|
+
if (deps?.media) {
|
|
406
|
+
let postIds: number[];
|
|
407
|
+
if (!existing.threadId) {
|
|
408
|
+
const thread = await this.getThread(id);
|
|
409
|
+
postIds = thread.map((p) => p.id);
|
|
410
|
+
} else {
|
|
411
|
+
postIds = [id];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const mediaMap = await deps.media.getByPostIds(postIds);
|
|
415
|
+
const allMedia = [...mediaMap.values()].flat();
|
|
416
|
+
if (allMedia.length > 0) {
|
|
417
|
+
await deps.media.deleteByIds(
|
|
418
|
+
allMedia.map((m) => m.id),
|
|
419
|
+
deps.storage,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Release paths from registry
|
|
425
|
+
if (!existing.threadId) {
|
|
426
|
+
// Thread root: release paths for all posts in thread
|
|
427
|
+
const thread = await this.getThread(id);
|
|
428
|
+
for (const post of thread) {
|
|
429
|
+
if (post.path) {
|
|
430
|
+
await pathRegistry.release(post.path);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} else if (existing.path) {
|
|
434
|
+
await pathRegistry.release(existing.path);
|
|
435
|
+
}
|
|
436
|
+
|
|
342
437
|
const timestamp = now();
|
|
343
438
|
|
|
344
439
|
// If this is a thread root, soft delete all posts in the thread
|
package/src/services/redirect.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { redirects } from "../db/schema.js";
|
|
|
10
10
|
import { now } from "../lib/time.js";
|
|
11
11
|
import { normalizePath } from "../lib/url.js";
|
|
12
12
|
import type { Redirect } from "../types.js";
|
|
13
|
+
import type { PathRegistryService } from "./path-registry.js";
|
|
14
|
+
import { ConflictError } from "../lib/errors.js";
|
|
13
15
|
|
|
14
16
|
export interface RedirectService {
|
|
15
17
|
getByPath(fromPath: string): Promise<Redirect | null>;
|
|
@@ -18,7 +20,10 @@ export interface RedirectService {
|
|
|
18
20
|
list(): Promise<Redirect[]>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
export function createRedirectService(
|
|
23
|
+
export function createRedirectService(
|
|
24
|
+
db: Database,
|
|
25
|
+
pathRegistry: PathRegistryService,
|
|
26
|
+
): RedirectService {
|
|
22
27
|
function toRedirect(row: typeof redirects.$inferSelect): Redirect {
|
|
23
28
|
return {
|
|
24
29
|
id: row.id,
|
|
@@ -44,7 +49,16 @@ export function createRedirectService(db: Database): RedirectService {
|
|
|
44
49
|
const timestamp = now();
|
|
45
50
|
const normalizedFrom = normalizePath(fromPath);
|
|
46
51
|
|
|
47
|
-
//
|
|
52
|
+
// Check if path is claimed by a non-redirect entity
|
|
53
|
+
const existingClaim = await pathRegistry.getByPath(normalizedFrom);
|
|
54
|
+
if (existingClaim && existingClaim.ownerType !== "redirect") {
|
|
55
|
+
throw new ConflictError(`Path "${normalizedFrom}" is already in use`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Delete existing redirect from this path if any (upsert behavior)
|
|
59
|
+
if (existingClaim?.ownerType === "redirect") {
|
|
60
|
+
await pathRegistry.release(normalizedFrom);
|
|
61
|
+
}
|
|
48
62
|
await db.delete(redirects).where(eq(redirects.fromPath, normalizedFrom));
|
|
49
63
|
|
|
50
64
|
const result = await db
|
|
@@ -58,10 +72,16 @@ export function createRedirectService(db: Database): RedirectService {
|
|
|
58
72
|
.returning();
|
|
59
73
|
|
|
60
74
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
61
|
-
|
|
75
|
+
const redirect = toRedirect(result[0]!);
|
|
76
|
+
|
|
77
|
+
await pathRegistry.claim(normalizedFrom, "redirect", redirect.id);
|
|
78
|
+
|
|
79
|
+
return redirect;
|
|
62
80
|
},
|
|
63
81
|
|
|
64
82
|
async delete(id) {
|
|
83
|
+
// Release path registry entries for this redirect
|
|
84
|
+
await pathRegistry.releaseByOwner("redirect", id);
|
|
65
85
|
const result = await db
|
|
66
86
|
.delete(redirects)
|
|
67
87
|
.where(eq(redirects.id, id))
|
package/src/services/settings.ts
CHANGED
|
@@ -13,6 +13,38 @@ import {
|
|
|
13
13
|
ONBOARDING_STATUS,
|
|
14
14
|
type SettingsKey,
|
|
15
15
|
} from "../lib/constants.js";
|
|
16
|
+
import type { StorageDriver } from "../lib/storage.js";
|
|
17
|
+
import type { MediaService } from "./media.js";
|
|
18
|
+
import { validateUploadFile, generateStorageKey } from "../lib/upload.js";
|
|
19
|
+
import { arrayBufferToBase64 } from "../lib/favicon.js";
|
|
20
|
+
import { ValidationError } from "../lib/errors.js";
|
|
21
|
+
|
|
22
|
+
export interface GeneralSettingsData {
|
|
23
|
+
siteName: string;
|
|
24
|
+
siteDescription: string;
|
|
25
|
+
siteFooter: string;
|
|
26
|
+
siteLanguage: string;
|
|
27
|
+
homeDefaultView?: string;
|
|
28
|
+
headerNavMaxVisible?: string;
|
|
29
|
+
timeZone: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GeneralSettingsResult {
|
|
33
|
+
languageChanged: boolean;
|
|
34
|
+
displayName: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AvatarUploadData {
|
|
38
|
+
file: { stream(): ReadableStream; name: string; type: string; size: number };
|
|
39
|
+
faviconIco?: ArrayBuffer;
|
|
40
|
+
appleTouchIcon?: ArrayBuffer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AvatarUploadDeps {
|
|
44
|
+
media: MediaService;
|
|
45
|
+
storage: StorageDriver;
|
|
46
|
+
storageProvider: string;
|
|
47
|
+
}
|
|
16
48
|
|
|
17
49
|
export interface SettingsService {
|
|
18
50
|
get(key: SettingsKey): Promise<string | null>;
|
|
@@ -22,6 +54,34 @@ export interface SettingsService {
|
|
|
22
54
|
remove(key: SettingsKey): Promise<void>;
|
|
23
55
|
isOnboardingComplete(): Promise<boolean>;
|
|
24
56
|
completeOnboarding(): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Update general site settings with trim/set/remove logic.
|
|
59
|
+
* Empty strings are removed. Default values are removed to keep the DB clean.
|
|
60
|
+
*
|
|
61
|
+
* @param data - Form data from the settings page
|
|
62
|
+
* @param opts - Old language (for change detection) and fallback site name
|
|
63
|
+
* @returns Whether the language changed and the display name for the site
|
|
64
|
+
*/
|
|
65
|
+
updateGeneral(
|
|
66
|
+
data: GeneralSettingsData,
|
|
67
|
+
opts: { oldLanguage: string; fallbackSiteName: string },
|
|
68
|
+
): Promise<GeneralSettingsResult>;
|
|
69
|
+
/**
|
|
70
|
+
* Upload an avatar image: validates file, stores in storage, creates media record,
|
|
71
|
+
* updates settings (SITE_AVATAR, SITE_FAVICON_ICO, SITE_FAVICON_APPLE_TOUCH, SITE_FAVICON_VERSION).
|
|
72
|
+
*
|
|
73
|
+
* @param data - Avatar file and optional favicon variants
|
|
74
|
+
* @param deps - Media service and storage driver dependencies
|
|
75
|
+
* @throws {ValidationError} When file validation fails
|
|
76
|
+
*/
|
|
77
|
+
uploadAvatar(data: AvatarUploadData, deps: AvatarUploadDeps): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Remove avatar and all favicon-related settings.
|
|
80
|
+
* Deletes the apple-touch-icon from storage if it exists.
|
|
81
|
+
*
|
|
82
|
+
* @param storage - Optional storage driver for deleting the apple-touch-icon file
|
|
83
|
+
*/
|
|
84
|
+
removeAvatar(storage?: StorageDriver | null): Promise<void>;
|
|
25
85
|
}
|
|
26
86
|
|
|
27
87
|
export function createSettingsService(db: Database): SettingsService {
|
|
@@ -96,5 +156,126 @@ export function createSettingsService(db: Database): SettingsService {
|
|
|
96
156
|
ONBOARDING_STATUS.COMPLETED,
|
|
97
157
|
);
|
|
98
158
|
},
|
|
159
|
+
|
|
160
|
+
async updateGeneral(data, opts) {
|
|
161
|
+
// Site name: set if non-empty, remove otherwise
|
|
162
|
+
if (data.siteName.trim()) {
|
|
163
|
+
await this.set("SITE_NAME", data.siteName.trim());
|
|
164
|
+
} else {
|
|
165
|
+
await this.remove("SITE_NAME");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Site description: set if non-empty, remove otherwise
|
|
169
|
+
if (data.siteDescription.trim()) {
|
|
170
|
+
await this.set("SITE_DESCRIPTION", data.siteDescription.trim());
|
|
171
|
+
} else {
|
|
172
|
+
await this.remove("SITE_DESCRIPTION");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Footer: set if non-empty, remove otherwise
|
|
176
|
+
if (data.siteFooter?.trim()) {
|
|
177
|
+
await this.set("SITE_FOOTER", data.siteFooter.trim());
|
|
178
|
+
} else {
|
|
179
|
+
await this.remove("SITE_FOOTER");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Language is always stored
|
|
183
|
+
await this.set("SITE_LANGUAGE", data.siteLanguage);
|
|
184
|
+
|
|
185
|
+
// Homepage default view: only update if provided (may be managed separately)
|
|
186
|
+
if (data.homeDefaultView !== undefined) {
|
|
187
|
+
if (data.homeDefaultView === "featured") {
|
|
188
|
+
await this.set("HOME_DEFAULT_VIEW", data.homeDefaultView);
|
|
189
|
+
} else {
|
|
190
|
+
await this.remove("HOME_DEFAULT_VIEW");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Header nav max visible: only update if provided (may be managed separately)
|
|
195
|
+
if (data.headerNavMaxVisible !== undefined) {
|
|
196
|
+
const navMax = parseInt(String(data.headerNavMaxVisible), 10);
|
|
197
|
+
if (!isNaN(navMax) && navMax !== 3) {
|
|
198
|
+
await this.set("HEADER_NAV_MAX_VISIBLE", String(navMax));
|
|
199
|
+
} else {
|
|
200
|
+
await this.remove("HEADER_NAV_MAX_VISIBLE");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Timezone: only store non-default (default is UTC)
|
|
205
|
+
if (data.timeZone && data.timeZone !== "UTC") {
|
|
206
|
+
await this.set("TIME_ZONE", data.timeZone);
|
|
207
|
+
} else {
|
|
208
|
+
await this.remove("TIME_ZONE");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
languageChanged: opts.oldLanguage !== data.siteLanguage,
|
|
213
|
+
displayName: data.siteName.trim() || opts.fallbackSiteName,
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async uploadAvatar(data, deps) {
|
|
218
|
+
const uploadError = validateUploadFile(data.file as unknown as File);
|
|
219
|
+
if (uploadError) {
|
|
220
|
+
throw new ValidationError(uploadError);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { id, filename, storageKey } = generateStorageKey(data.file.name);
|
|
224
|
+
|
|
225
|
+
await deps.storage.put(storageKey, data.file.stream(), {
|
|
226
|
+
contentType: data.file.type,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await deps.media.create({
|
|
230
|
+
id,
|
|
231
|
+
filename,
|
|
232
|
+
originalName: data.file.name,
|
|
233
|
+
mimeType: data.file.type,
|
|
234
|
+
size: data.file.size,
|
|
235
|
+
storageKey,
|
|
236
|
+
provider: deps.storageProvider,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await this.set("SITE_AVATAR", storageKey);
|
|
240
|
+
|
|
241
|
+
// Store favicon ICO as base64 in settings (tiny file, accessed every page load)
|
|
242
|
+
if (data.faviconIco) {
|
|
243
|
+
const b64 = arrayBufferToBase64(data.faviconIco);
|
|
244
|
+
await this.set("SITE_FAVICON_ICO", b64);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Store apple-touch-icon in storage (180x180 PNG, not tiny enough for base64)
|
|
248
|
+
if (data.appleTouchIcon) {
|
|
249
|
+
const appleTouchKey = "favicon/apple-touch-icon.png";
|
|
250
|
+
await deps.storage.put(
|
|
251
|
+
appleTouchKey,
|
|
252
|
+
new Uint8Array(data.appleTouchIcon),
|
|
253
|
+
{ contentType: "image/png" },
|
|
254
|
+
);
|
|
255
|
+
await this.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Set favicon version for cache-busting
|
|
259
|
+
const ts = new Date();
|
|
260
|
+
const version =
|
|
261
|
+
String(ts.getUTCFullYear()) +
|
|
262
|
+
String(ts.getUTCMonth() + 1).padStart(2, "0") +
|
|
263
|
+
String(ts.getUTCDate()).padStart(2, "0") +
|
|
264
|
+
String(ts.getUTCHours()).padStart(2, "0") +
|
|
265
|
+
String(ts.getUTCMinutes()).padStart(2, "0");
|
|
266
|
+
await this.set("SITE_FAVICON_VERSION", version);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
async removeAvatar(storage) {
|
|
270
|
+
const appleTouchKey = await this.get("SITE_FAVICON_APPLE_TOUCH");
|
|
271
|
+
if (storage && appleTouchKey) {
|
|
272
|
+
await storage.delete(appleTouchKey);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await this.remove("SITE_AVATAR");
|
|
276
|
+
await this.remove("SITE_FAVICON_ICO");
|
|
277
|
+
await this.remove("SITE_FAVICON_APPLE_TOUCH");
|
|
278
|
+
await this.remove("SITE_FAVICON_VERSION");
|
|
279
|
+
},
|
|
99
280
|
};
|
|
100
281
|
}
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* AFTER Tailwind is initialized in the user's CSS entry.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/* Icon stroke width — CSS property overrides SVG presentational attributes */
|
|
9
|
+
svg[stroke-width] {
|
|
10
|
+
stroke-width: var(--icon-stroke);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
svg[stroke-width].icon-fine {
|
|
14
|
+
stroke-width: var(--icon-stroke-fine);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/* Custom utilities */
|
|
9
18
|
@layer utilities {
|
|
10
19
|
.container {
|
|
@@ -93,6 +102,132 @@
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/* Dashboard header */
|
|
106
|
+
@layer components {
|
|
107
|
+
.dash-header {
|
|
108
|
+
padding: 20px 0 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@media (min-width: 700px) {
|
|
112
|
+
.dash-header {
|
|
113
|
+
padding: 24px 0 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.dash-header-inner {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 16px;
|
|
121
|
+
padding-bottom: 12px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.dash-header-left {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 6px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.dash-header-nav {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
justify-content: center;
|
|
134
|
+
gap: 4px;
|
|
135
|
+
flex: 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.dash-header-logo {
|
|
139
|
+
font-size: 1.125rem;
|
|
140
|
+
font-weight: 800;
|
|
141
|
+
line-height: 1;
|
|
142
|
+
color: var(--color-foreground);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.dash-header-site-link {
|
|
146
|
+
@apply flex items-center justify-center;
|
|
147
|
+
color: var(--color-muted-foreground);
|
|
148
|
+
transition: color 0.15s;
|
|
149
|
+
|
|
150
|
+
&:hover {
|
|
151
|
+
color: var(--color-foreground);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.dash-header-link {
|
|
156
|
+
position: relative;
|
|
157
|
+
font-size: 0.875rem;
|
|
158
|
+
padding: 6px 10px;
|
|
159
|
+
border-radius: var(--radius);
|
|
160
|
+
color: var(--color-muted-foreground);
|
|
161
|
+
transition:
|
|
162
|
+
color 0.15s,
|
|
163
|
+
background-color 0.15s;
|
|
164
|
+
|
|
165
|
+
&:hover {
|
|
166
|
+
color: var(--color-foreground);
|
|
167
|
+
background-color: var(--color-accent);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.dash-header-link-active {
|
|
172
|
+
color: var(--color-foreground);
|
|
173
|
+
font-weight: 600;
|
|
174
|
+
background-color: var(--color-accent);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.dash-header-menu-btn {
|
|
178
|
+
@apply flex items-center justify-center;
|
|
179
|
+
width: 32px;
|
|
180
|
+
height: 32px;
|
|
181
|
+
border-radius: 999px;
|
|
182
|
+
border: none;
|
|
183
|
+
background: transparent;
|
|
184
|
+
color: var(--color-muted-foreground);
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
transition:
|
|
187
|
+
color 0.15s,
|
|
188
|
+
background-color 0.15s;
|
|
189
|
+
|
|
190
|
+
&:hover {
|
|
191
|
+
color: var(--color-foreground);
|
|
192
|
+
background-color: var(--color-accent);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.dash-header-menu-btn + [data-popover] {
|
|
197
|
+
min-width: 170px;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Sub-navigation — underline style for secondary tabs (Settings, Appearance) */
|
|
202
|
+
@layer components {
|
|
203
|
+
.dash-subnav {
|
|
204
|
+
@apply flex gap-4 mb-6;
|
|
205
|
+
|
|
206
|
+
> a {
|
|
207
|
+
position: relative;
|
|
208
|
+
font-size: 0.8125rem;
|
|
209
|
+
padding-bottom: 8px;
|
|
210
|
+
color: var(--color-muted-foreground);
|
|
211
|
+
transition: color 0.15s;
|
|
212
|
+
|
|
213
|
+
&:hover {
|
|
214
|
+
color: var(--color-foreground);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
&.active {
|
|
218
|
+
color: var(--color-foreground);
|
|
219
|
+
font-weight: 500;
|
|
220
|
+
|
|
221
|
+
&::after {
|
|
222
|
+
content: "";
|
|
223
|
+
@apply absolute inset-x-0 bottom-0 h-0.5 rounded-full;
|
|
224
|
+
background-color: var(--color-foreground);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
96
231
|
/* Skeleton screen sizing — used in SSR fallbacks while Lit hydrates */
|
|
97
232
|
@layer components {
|
|
98
233
|
.skel-label {
|
package/src/styles/tokens.css
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
--leading: 1.5;
|
|
18
18
|
|
|
19
19
|
/* Layout */
|
|
20
|
-
--site-width:
|
|
20
|
+
--site-width: 600px;
|
|
21
21
|
--site-padding: 1.5rem;
|
|
22
22
|
--content-gap: 1rem;
|
|
23
23
|
--space-xl: 2rem;
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
--avatar-radius: 50%;
|
|
39
39
|
--media-radius: 0.5rem;
|
|
40
40
|
|
|
41
|
+
/* Icons */
|
|
42
|
+
--icon-stroke: 2;
|
|
43
|
+
--icon-stroke-fine: 1.5;
|
|
44
|
+
|
|
41
45
|
/* Derived color tokens (from BaseCoat variables) */
|
|
42
46
|
--site-column-outline: var(--border);
|
|
43
47
|
--site-threadline: var(--border);
|
|
@@ -46,6 +50,7 @@
|
|
|
46
50
|
--site-nav-hover-bg: var(--accent);
|
|
47
51
|
--site-text-primary: var(--foreground);
|
|
48
52
|
--site-text-secondary: var(--muted-foreground);
|
|
53
|
+
--site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);
|
|
49
54
|
--site-media-outline: var(--border);
|
|
50
55
|
--site-divider: var(--border);
|
|
51
56
|
}
|