@jant/core 0.6.7 → 0.6.9
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/bin/commands/uploads/cleanup.js +2 -0
- package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-CGf2m3qp.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
- package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +110 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/client.ts +1 -0
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +37 -22
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +37 -22
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +37 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/ids.ts +1 -0
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +3 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +16 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/settings.ts +2 -1
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +23 -7
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +274 -30
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +199 -42
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +28 -0
- package/src/styles/tokens.css +7 -5
- package/src/styles/ui.css +163 -34
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +14 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/GeneralContent.tsx +38 -4
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
package/src/services/settings.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../lib/constants.js";
|
|
20
20
|
import {
|
|
21
21
|
baseLocale,
|
|
22
|
+
isLocale,
|
|
22
23
|
isValidContentLanguage,
|
|
23
24
|
normalizeContentLanguage,
|
|
24
25
|
} from "../i18n/locales.js";
|
|
@@ -41,6 +42,8 @@ export interface GeneralSettingsData {
|
|
|
41
42
|
siteDescription: string;
|
|
42
43
|
siteFooter: string;
|
|
43
44
|
siteLanguage: string;
|
|
45
|
+
/** Admin UI locale; empty string follows the content language. */
|
|
46
|
+
dashboardLanguage?: string;
|
|
44
47
|
cjkSerifFont: string;
|
|
45
48
|
showJantBrandingOnHome: boolean;
|
|
46
49
|
homeDefaultView?: FeedKind;
|
|
@@ -61,6 +64,11 @@ export interface SiteSettingsResult {
|
|
|
61
64
|
|
|
62
65
|
export interface LocaleSettingsData {
|
|
63
66
|
siteLanguage: string;
|
|
67
|
+
/**
|
|
68
|
+
* Admin dashboard UI locale. Empty string clears the explicit setting so the
|
|
69
|
+
* dashboard follows the content language. When set, must be a catalog locale.
|
|
70
|
+
*/
|
|
71
|
+
dashboardLanguage?: string;
|
|
64
72
|
cjkSerifFont: string;
|
|
65
73
|
timeZone: string;
|
|
66
74
|
}
|
|
@@ -97,7 +105,11 @@ export interface SettingsService {
|
|
|
97
105
|
): Promise<SiteSettingsResult>;
|
|
98
106
|
updateLocaleSettings(
|
|
99
107
|
data: LocaleSettingsData,
|
|
100
|
-
opts: {
|
|
108
|
+
opts: {
|
|
109
|
+
oldLanguage: string;
|
|
110
|
+
oldCjkSerifFont?: string;
|
|
111
|
+
oldDashboardLanguage?: string;
|
|
112
|
+
},
|
|
101
113
|
): Promise<{ languageChanged: boolean }>;
|
|
102
114
|
updateFeedSettings(data: { mainRssFeed?: FeedKind }): Promise<void>;
|
|
103
115
|
updateHomeBranding(showJantBrandingOnHome: boolean): Promise<void>;
|
|
@@ -131,15 +143,16 @@ export interface SettingsService {
|
|
|
131
143
|
*/
|
|
132
144
|
uploadAvatar(data: AvatarUploadData, deps: AvatarUploadDeps): Promise<void>;
|
|
133
145
|
/**
|
|
134
|
-
* Remove avatar and all favicon-related settings.
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
146
|
+
* Remove avatar and all favicon-related settings. The apple-touch-icon's
|
|
147
|
+
* media row is deleted and its storage object is retired (moved to the
|
|
148
|
+
* recycle bin, or deleted) via the media service when supplied — so removal
|
|
149
|
+
* is recoverable rather than erasing the bytes with no trace.
|
|
138
150
|
*/
|
|
139
|
-
removeAvatar(
|
|
140
|
-
storage?: StorageDriver | null
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
removeAvatar(deps?: {
|
|
152
|
+
storage?: StorageDriver | null;
|
|
153
|
+
media?: MediaService;
|
|
154
|
+
storageProvider?: string;
|
|
155
|
+
}): Promise<void>;
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
export function createSettingsService(
|
|
@@ -301,6 +314,26 @@ export function createSettingsService(
|
|
|
301
314
|
normalizeContentLanguage(trimmedLanguage),
|
|
302
315
|
);
|
|
303
316
|
|
|
317
|
+
// Dashboard UI locale. undefined = leave untouched; "" = clear so the
|
|
318
|
+
// dashboard follows the content language; otherwise it must be one of the
|
|
319
|
+
// translated catalog locales.
|
|
320
|
+
let dashboardChanged = false;
|
|
321
|
+
if (data.dashboardLanguage !== undefined) {
|
|
322
|
+
const dashboardLanguage = data.dashboardLanguage.trim();
|
|
323
|
+
if (dashboardLanguage && !isLocale(dashboardLanguage)) {
|
|
324
|
+
throw new ValidationError(
|
|
325
|
+
"Choose a dashboard language Jant is translated into.",
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (dashboardLanguage) {
|
|
329
|
+
await this.set("DASHBOARD_LANGUAGE", dashboardLanguage);
|
|
330
|
+
} else {
|
|
331
|
+
await this.remove("DASHBOARD_LANGUAGE");
|
|
332
|
+
}
|
|
333
|
+
dashboardChanged =
|
|
334
|
+
(opts.oldDashboardLanguage ?? "") !== dashboardLanguage;
|
|
335
|
+
}
|
|
336
|
+
|
|
304
337
|
// CJK serif font setting
|
|
305
338
|
const cjkFont = data.cjkSerifFont?.trim() ?? "";
|
|
306
339
|
if (cjkFont && isCjkSerifFont(cjkFont) && cjkFont !== "off") {
|
|
@@ -329,7 +362,8 @@ export function createSettingsService(
|
|
|
329
362
|
return {
|
|
330
363
|
languageChanged:
|
|
331
364
|
opts.oldLanguage !== trimmedLanguage ||
|
|
332
|
-
(opts.oldCjkSerifFont ?? "off") !== effectiveCjkFont
|
|
365
|
+
(opts.oldCjkSerifFont ?? "off") !== effectiveCjkFont ||
|
|
366
|
+
dashboardChanged,
|
|
333
367
|
};
|
|
334
368
|
},
|
|
335
369
|
|
|
@@ -469,19 +503,19 @@ export function createSettingsService(
|
|
|
469
503
|
await this.set("SITE_FAVICON_VERSION", version);
|
|
470
504
|
},
|
|
471
505
|
|
|
472
|
-
async removeAvatar(
|
|
506
|
+
async removeAvatar(deps) {
|
|
473
507
|
const appleTouchKey = await this.get("SITE_FAVICON_APPLE_TOUCH");
|
|
474
|
-
if (storage && appleTouchKey) {
|
|
475
|
-
await storage.delete(appleTouchKey);
|
|
476
|
-
}
|
|
477
508
|
|
|
509
|
+
// Retire the apple-touch-icon through the media service so its object goes
|
|
510
|
+
// to the recycle bin (recoverable) rather than being erased now. Also
|
|
511
|
+
// removes its media row.
|
|
478
512
|
if (deps?.media && deps.storageProvider && appleTouchKey) {
|
|
479
513
|
const existing = await deps.media.getByStorageKey(
|
|
480
514
|
appleTouchKey,
|
|
481
515
|
deps.storageProvider,
|
|
482
516
|
);
|
|
483
517
|
if (existing) {
|
|
484
|
-
await deps.media.delete(existing.id);
|
|
518
|
+
await deps.media.delete(existing.id, deps.storage);
|
|
485
519
|
}
|
|
486
520
|
}
|
|
487
521
|
|
|
@@ -52,6 +52,10 @@ const RELAY_MULTIPART_THRESHOLD = 95 * 1024 * 1024;
|
|
|
52
52
|
const RELAY_MULTIPART_PART_SIZE = 50 * 1024 * 1024;
|
|
53
53
|
const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
54
54
|
const DEFAULT_EXPIRED_UPLOAD_CLEANUP_LIMIT = 20;
|
|
55
|
+
// Grace window before a finalized-but-unattached media row (uploaded during
|
|
56
|
+
// compose, `postId IS NULL`) is reaped. Generous enough that an in-progress
|
|
57
|
+
// compose/edit is never affected; based on `createdAt`, no heartbeat needed.
|
|
58
|
+
const ORPHAN_MEDIA_GRACE_SECONDS = 7 * 24 * 60 * 60;
|
|
55
59
|
type CleanupableUploadSessionState = "pending" | "uploaded" | "failed";
|
|
56
60
|
const CLEANUPABLE_UPLOAD_SESSION_STATES = [
|
|
57
61
|
"pending",
|
|
@@ -143,6 +147,8 @@ export interface UploadSessionService {
|
|
|
143
147
|
}): Promise<{
|
|
144
148
|
abortedMultipartUploads: number;
|
|
145
149
|
deletedSessions: number;
|
|
150
|
+
deletedOrphanMedia: number;
|
|
151
|
+
purgedStorageObjects: number;
|
|
146
152
|
}>;
|
|
147
153
|
abort(id: string, deps: { storage: StorageDriver }): Promise<void>;
|
|
148
154
|
}
|
|
@@ -760,9 +766,31 @@ export function createUploadSessionService(
|
|
|
760
766
|
deletedSessions += 1;
|
|
761
767
|
}
|
|
762
768
|
|
|
769
|
+
// Reap finalized media that was uploaded during compose but never
|
|
770
|
+
// attached to a post (`postId IS NULL`) past the grace window. This now
|
|
771
|
+
// soft-deletes via the media service: the DB row is removed and the
|
|
772
|
+
// storage object is enqueued for deferred deletion. Bounded by the same
|
|
773
|
+
// per-run `limit`; any backlog drains over subsequent runs.
|
|
774
|
+
const orphanIds = await media.listOrphanedMediaIds({
|
|
775
|
+
before: now() - ORPHAN_MEDIA_GRACE_SECONDS,
|
|
776
|
+
limit,
|
|
777
|
+
});
|
|
778
|
+
if (orphanIds.length > 0) {
|
|
779
|
+
await media.deleteByIds(orphanIds, deps.storage);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Physically delete storage objects whose recycle window has elapsed.
|
|
783
|
+
// Skips any object a live media row still references (re-uploads).
|
|
784
|
+
const purgedStorageObjects = await media.purgeDueStorageObjects(
|
|
785
|
+
{ before: now(), limit, provider: deps.storageDriver },
|
|
786
|
+
deps.storage,
|
|
787
|
+
);
|
|
788
|
+
|
|
763
789
|
return {
|
|
764
790
|
abortedMultipartUploads,
|
|
765
791
|
deletedSessions,
|
|
792
|
+
deletedOrphanMedia: orphanIds.length,
|
|
793
|
+
purgedStorageObjects,
|
|
766
794
|
};
|
|
767
795
|
},
|
|
768
796
|
};
|
package/src/styles/tokens.css
CHANGED
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
mobile overrides below cap sizes for small screens.
|
|
85
85
|
--type-content-scale uniformly scales all content sizes
|
|
86
86
|
without affecting UI controls. */
|
|
87
|
-
--type-content-scale: 0.
|
|
87
|
+
--type-content-scale: 0.78;
|
|
88
88
|
--type-content-display: calc(var(--type-display) * var(--type-content-scale));
|
|
89
89
|
--type-content-title: calc(var(--type-title) * var(--type-content-scale));
|
|
90
90
|
--type-content-subtitle: calc(
|
|
@@ -467,13 +467,15 @@
|
|
|
467
467
|
|
|
468
468
|
@media (max-width: 760px), (hover: none) and (pointer: coarse) {
|
|
469
469
|
:root {
|
|
470
|
-
/* Content layer — tighter scale for mobile reading
|
|
471
|
-
Ratio ≈ 1.
|
|
472
|
-
|
|
470
|
+
/* Content layer — tighter scale for mobile reading.
|
|
471
|
+
Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).
|
|
472
|
+
At 15px root × 0.78 content-scale: 28.7 / 21.6 / 17.6 / 15.2px.
|
|
473
|
+
Body is floored to a 16px minimum below (max()) for readability;
|
|
474
|
+
the floor still yields to calc() when the user zooms the root up. */
|
|
473
475
|
--type-content-display: calc(2.45rem * var(--type-content-scale));
|
|
474
476
|
--type-content-title: calc(1.85rem * var(--type-content-scale));
|
|
475
477
|
--type-content-subtitle: calc(1.5rem * var(--type-content-scale));
|
|
476
|
-
--type-content-body: calc(1.3rem * var(--type-content-scale));
|
|
478
|
+
--type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));
|
|
477
479
|
|
|
478
480
|
/* UI layer — pixel floors for touch targets */
|
|
479
481
|
--type-ui-title: max(16px, var(--type-secondary));
|
package/src/styles/ui.css
CHANGED
|
@@ -2205,8 +2205,16 @@
|
|
|
2205
2205
|
}
|
|
2206
2206
|
|
|
2207
2207
|
.feed-continue-link {
|
|
2208
|
-
|
|
2209
|
-
|
|
2208
|
+
/* Block so the whole content-width row is the tap target, not just the few
|
|
2209
|
+
characters of "Read more". On mobile the trailing link on the last
|
|
2210
|
+
summary line sits just above this control, so taps aimed here used to
|
|
2211
|
+
land on that link. The larger `margin-top` is a dead (non-clickable)
|
|
2212
|
+
buffer that separates the prose's trailing link from the control, and
|
|
2213
|
+
`padding-bottom` grows the tap area downward, away from the footer
|
|
2214
|
+
links below. Appearance is unchanged — only the hit area and spacing. */
|
|
2215
|
+
display: block;
|
|
2216
|
+
margin-top: 1rem;
|
|
2217
|
+
padding-bottom: 0.6rem;
|
|
2210
2218
|
font-size: var(--type-ui-meta);
|
|
2211
2219
|
color: var(--site-text-secondary);
|
|
2212
2220
|
}
|
|
@@ -2215,6 +2223,19 @@
|
|
|
2215
2223
|
text-decoration: underline;
|
|
2216
2224
|
}
|
|
2217
2225
|
|
|
2226
|
+
/* Untitled note expand-in-place. The full body is rendered with a zero-width
|
|
2227
|
+
`data-note-break` marker at the summary boundary; clamping hides only the
|
|
2228
|
+
marker's following siblings, leaving the visible summary untouched so the
|
|
2229
|
+
note grows in place without the page jumping. note-expand.ts toggles
|
|
2230
|
+
`data-note-clamp` to reveal/hide the tail. */
|
|
2231
|
+
[data-note-break] {
|
|
2232
|
+
display: none;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
[data-note-clamp] [data-note-break] ~ * {
|
|
2236
|
+
display: none;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2218
2239
|
.post-header-block {
|
|
2219
2240
|
margin-bottom: 1rem;
|
|
2220
2241
|
}
|
|
@@ -2331,16 +2352,39 @@
|
|
|
2331
2352
|
|
|
2332
2353
|
.sidenote,
|
|
2333
2354
|
.marginnote {
|
|
2355
|
+
/* Layout: float into the right margin. A floated note anchors to its containing
|
|
2356
|
+
block's right *content* edge, so if a theme gives that block inline-end padding
|
|
2357
|
+
(e.g. a padded blockquote card) the anchor is inset and the note drifts left.
|
|
2358
|
+
Themes expose that inset via --sidenote-anchor-inset (default 0) — best derived
|
|
2359
|
+
from the same token that sets the padding — so the note still reaches the gutter
|
|
2360
|
+
no matter how the container is indented. Core's own blocks add no inset. */
|
|
2334
2361
|
float: right;
|
|
2335
2362
|
clear: right;
|
|
2336
|
-
margin-right:
|
|
2363
|
+
margin-right: calc(
|
|
2364
|
+
var(--layout-sidenote-margin) - var(--sidenote-anchor-inset, 0px)
|
|
2365
|
+
);
|
|
2337
2366
|
width: var(--layout-sidenote-width);
|
|
2338
2367
|
margin-top: 0.3rem;
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
line-height: 1.3;
|
|
2368
|
+
/* Breathing room so multiple notes from one paragraph don't touch when they stack. */
|
|
2369
|
+
margin-bottom: 1.4rem;
|
|
2342
2370
|
vertical-align: baseline;
|
|
2343
2371
|
position: relative;
|
|
2372
|
+
|
|
2373
|
+
/* Typographic isolation. A footnote is an annotation, not part of the text it
|
|
2374
|
+
hangs off of, so it must render identically no matter what its reference sits
|
|
2375
|
+
inside. The body span is emitted at the reference position (see tiptap-render
|
|
2376
|
+
renderSidenoteReference), so when the [^n] is inside a blockquote / bold run /
|
|
2377
|
+
heading the span inherits that context. Pin every inheritable type property to
|
|
2378
|
+
the canonical note look to cut every such leak — not just the blockquote
|
|
2379
|
+
serif+italic case. */
|
|
2380
|
+
font-family: var(--font-body);
|
|
2381
|
+
font-size: var(--type-sm);
|
|
2382
|
+
font-style: normal;
|
|
2383
|
+
font-weight: 400;
|
|
2384
|
+
font-variant: normal;
|
|
2385
|
+
line-height: 1.3;
|
|
2386
|
+
letter-spacing: var(--type-body-tracking, normal);
|
|
2387
|
+
text-align: left;
|
|
2344
2388
|
color: var(--site-reading-meta);
|
|
2345
2389
|
}
|
|
2346
2390
|
|
|
@@ -2358,12 +2402,16 @@
|
|
|
2358
2402
|
content: counter(sidenote-counter);
|
|
2359
2403
|
font-size: var(--type-code);
|
|
2360
2404
|
top: -0.5rem;
|
|
2361
|
-
left
|
|
2405
|
+
/* Real margins (not `left`, which reserves no space) so the superscript
|
|
2406
|
+
number keeps a gap from the words on both sides — matters for CJK, which
|
|
2407
|
+
has no inter-word spacing. */
|
|
2408
|
+
margin-left: 0.1rem;
|
|
2409
|
+
margin-right: 0.25rem;
|
|
2362
2410
|
}
|
|
2363
2411
|
|
|
2364
2412
|
.sidenote::before {
|
|
2365
2413
|
content: counter(sidenote-counter) " ";
|
|
2366
|
-
font-size: var(--type-
|
|
2414
|
+
font-size: var(--type-xs);
|
|
2367
2415
|
top: -0.5rem;
|
|
2368
2416
|
}
|
|
2369
2417
|
|
|
@@ -2372,13 +2420,6 @@
|
|
|
2372
2420
|
font-size: var(--type-code);
|
|
2373
2421
|
}
|
|
2374
2422
|
|
|
2375
|
-
blockquote .sidenote,
|
|
2376
|
-
blockquote .marginnote {
|
|
2377
|
-
margin-right: -82%;
|
|
2378
|
-
min-width: 59%;
|
|
2379
|
-
text-align: left;
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
2423
|
input.margin-toggle {
|
|
2383
2424
|
display: none;
|
|
2384
2425
|
}
|
|
@@ -2637,13 +2678,28 @@
|
|
|
2637
2678
|
secondary to the focused post but not so much that text becomes gray
|
|
2638
2679
|
and hard to read. Combined with the mask-image bottom fade this gives
|
|
2639
2680
|
a "context drifts into the background" effect without dimming the
|
|
2640
|
-
whole shell to the point of illegibility.
|
|
2641
|
-
|
|
2681
|
+
whole shell to the point of illegibility.
|
|
2682
|
+
|
|
2683
|
+
Fade per item, not the whole shell: a blanket `opacity` on the shell
|
|
2684
|
+
makes it a stacking context that *caps* descendant opacity, so the gap
|
|
2685
|
+
marker ("N more posts") could never read brighter than the faded
|
|
2686
|
+
context around it. Fading each item individually leaves the gap — a
|
|
2687
|
+
real route into the rest of the thread, not background context — at
|
|
2688
|
+
full strength. The items cover everything inside the shell (feed:
|
|
2689
|
+
context + gap; detail page: ancestor detail items), so nothing slips
|
|
2690
|
+
through undimmed. */
|
|
2691
|
+
.thread-context-shell .thread-item {
|
|
2642
2692
|
opacity: 0.6;
|
|
2643
2693
|
transition: opacity 0.22s ease;
|
|
2644
2694
|
}
|
|
2645
2695
|
|
|
2646
|
-
.thread-context-shell:not([data-collapsed]) {
|
|
2696
|
+
.thread-context-shell:not([data-collapsed]) .thread-item {
|
|
2697
|
+
opacity: 1;
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
/* The gap is a structural "thread continues here" marker — never faded,
|
|
2701
|
+
collapsed or expanded. */
|
|
2702
|
+
.thread-context-shell .thread-item.thread-item-gap {
|
|
2647
2703
|
opacity: 1;
|
|
2648
2704
|
}
|
|
2649
2705
|
|
|
@@ -2708,33 +2764,78 @@
|
|
|
2708
2764
|
padding-bottom: 0.35rem;
|
|
2709
2765
|
}
|
|
2710
2766
|
|
|
2767
|
+
/* Gap marker on the rail: swap the single post dot for a short column of
|
|
2768
|
+
small beads (a vertical "⋮") so the thread line visibly "skips" a run
|
|
2769
|
+
of posts — the familiar threaded-conversation cue for collapsed
|
|
2770
|
+
content. Smaller and quieter than the real post dots, sitting on the
|
|
2771
|
+
same rail x-position. The continuous rail line still shows through
|
|
2772
|
+
behind the beads, so thread continuity reads intact. */
|
|
2773
|
+
.thread-group-preview .thread-item-gap::before,
|
|
2774
|
+
.thread-group-detail .thread-item-gap::before {
|
|
2775
|
+
top: 50%;
|
|
2776
|
+
transform: translateY(-50%);
|
|
2777
|
+
left: calc(
|
|
2778
|
+
var(--site-thread-rail-line-left) + var(--site-thread-rail-line-width) /
|
|
2779
|
+
2 - 2px - var(--site-thread-rail-indent)
|
|
2780
|
+
);
|
|
2781
|
+
width: 4px;
|
|
2782
|
+
height: 24px;
|
|
2783
|
+
border: 0;
|
|
2784
|
+
border-radius: 0;
|
|
2785
|
+
background: radial-gradient(
|
|
2786
|
+
circle,
|
|
2787
|
+
var(--site-threadline) 1.75px,
|
|
2788
|
+
transparent 2px
|
|
2789
|
+
);
|
|
2790
|
+
background-size: 4px 8px;
|
|
2791
|
+
background-repeat: repeat-y;
|
|
2792
|
+
background-position: center top;
|
|
2793
|
+
box-shadow: none;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
/* "N more posts" — a calm route into the rest of the thread, styled as a
|
|
2797
|
+
sibling of `.thread-context-toggle` (same muted colour + size) rather
|
|
2798
|
+
than a pill or an underlined link. A small chevron does the "this is
|
|
2799
|
+
tappable / leads somewhere" work an underline does poorly on a short
|
|
2800
|
+
standalone label; it points right because the link navigates into the
|
|
2801
|
+
thread rather than expanding in place. Full opacity keeps it from
|
|
2802
|
+
fading into the context above. */
|
|
2711
2803
|
.thread-gap-link {
|
|
2712
2804
|
display: inline-flex;
|
|
2713
2805
|
align-items: center;
|
|
2714
|
-
gap: 0.
|
|
2715
|
-
padding: 0.
|
|
2716
|
-
border: 1px dashed var(--site-thread-context-border);
|
|
2717
|
-
border-radius: 999px;
|
|
2718
|
-
background: var(--site-thread-gap-bg);
|
|
2806
|
+
gap: 0.4rem;
|
|
2807
|
+
padding: 0.25rem 0;
|
|
2719
2808
|
color: var(--site-text-secondary);
|
|
2720
2809
|
font-size: var(--type-thread-context-meta);
|
|
2721
|
-
|
|
2810
|
+
font-weight: 500;
|
|
2811
|
+
line-height: 1.2;
|
|
2722
2812
|
text-decoration: none;
|
|
2813
|
+
transition: color 0.18s ease;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
.thread-gap-link::after {
|
|
2817
|
+
content: "";
|
|
2818
|
+
width: 0.36em;
|
|
2819
|
+
height: 0.36em;
|
|
2820
|
+
border: 1.5px solid currentColor;
|
|
2821
|
+
border-left: 0;
|
|
2822
|
+
border-bottom: 0;
|
|
2823
|
+
opacity: 0.65;
|
|
2824
|
+
transform: rotate(45deg);
|
|
2723
2825
|
transition:
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
background-color 0.18s ease;
|
|
2826
|
+
transform 0.18s ease,
|
|
2827
|
+
opacity 0.18s ease;
|
|
2727
2828
|
}
|
|
2728
2829
|
|
|
2729
2830
|
.thread-gap-link:hover {
|
|
2730
|
-
border-color: color-mix(
|
|
2731
|
-
in srgb,
|
|
2732
|
-
var(--site-accent) 22%,
|
|
2733
|
-
var(--site-divider)
|
|
2734
|
-
);
|
|
2735
2831
|
color: var(--site-text-primary);
|
|
2736
2832
|
}
|
|
2737
2833
|
|
|
2834
|
+
.thread-gap-link:hover::after {
|
|
2835
|
+
opacity: 1;
|
|
2836
|
+
transform: translateX(2px) rotate(45deg);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2738
2839
|
.thread-item-hero {
|
|
2739
2840
|
padding-top: 0.95rem;
|
|
2740
2841
|
}
|
|
@@ -7739,9 +7840,12 @@
|
|
|
7739
7840
|
min-height: 72px;
|
|
7740
7841
|
}
|
|
7741
7842
|
|
|
7742
|
-
/* Focused thread post gets a subtle left accent
|
|
7843
|
+
/* Focused thread post gets a subtle left accent. Use an inset box-shadow, not
|
|
7844
|
+
a border: a border adds 2px of layout width only while focused, so switching
|
|
7845
|
+
focus between thread posts shifted each one sideways ("jitter"). box-shadow
|
|
7846
|
+
paints in the same spot without affecting layout. */
|
|
7743
7847
|
.compose-thread-post:focus-within > jant-compose-editor {
|
|
7744
|
-
|
|
7848
|
+
box-shadow: inset 2px 0 0 transparent;
|
|
7745
7849
|
}
|
|
7746
7850
|
|
|
7747
7851
|
/* "Add to thread" row inside thread compose layout */
|
|
@@ -7852,6 +7956,22 @@
|
|
|
7852
7956
|
padding: 6px 0 4px;
|
|
7853
7957
|
}
|
|
7854
7958
|
|
|
7959
|
+
/* Reply / edit-reply compose (a .compose-editor-row directly under
|
|
7960
|
+
.compose-thread-layout — not the multi-post thread variant) needs clear
|
|
7961
|
+
separation from the parent post shown above it. A thread post is meant to
|
|
7962
|
+
sit tight on the rail, so this is scoped away from thread compose. Uses a
|
|
7963
|
+
static :not() rather than :has() on purpose: a :has() here re-evaluates on
|
|
7964
|
+
focus changes inside the row and caused a one-frame horizontal reflow
|
|
7965
|
+
("jitter") when clicking into the editor. */
|
|
7966
|
+
.compose-thread-layout:not(.compose-thread-compose-layout)
|
|
7967
|
+
.compose-editor-row {
|
|
7968
|
+
padding-top: 14px;
|
|
7969
|
+
}
|
|
7970
|
+
|
|
7971
|
+
.compose-thread-post-header + .compose-body {
|
|
7972
|
+
padding-top: 8px;
|
|
7973
|
+
}
|
|
7974
|
+
|
|
7855
7975
|
/* Thread format selector: pill-tag style (lighter than the main segmented) */
|
|
7856
7976
|
.compose-thread-segmented {
|
|
7857
7977
|
background: transparent;
|
|
@@ -8630,6 +8750,15 @@
|
|
|
8630
8750
|
margin-bottom: 1.4rem;
|
|
8631
8751
|
}
|
|
8632
8752
|
|
|
8753
|
+
/* In inline-format compose (reply / edit-reply / thread — the editors wrapped
|
|
8754
|
+
in .compose-editor-row), the format header sits right above the body, so the
|
|
8755
|
+
first block's 1.4rem top margin reads as a gap between the format pills and
|
|
8756
|
+
the input. Drop it there so the text hugs the header. Standalone new/edit
|
|
8757
|
+
editors keep their natural lead-in. */
|
|
8758
|
+
.compose-editor-row .compose-tiptap-body .tiptap > :first-child {
|
|
8759
|
+
margin-top: 0;
|
|
8760
|
+
}
|
|
8761
|
+
|
|
8633
8762
|
.compose-tiptap-body .tiptap ul {
|
|
8634
8763
|
list-style-type: disc;
|
|
8635
8764
|
padding-left: 1.5em;
|
package/src/types/bindings.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface Bindings {
|
|
|
35
35
|
SITE_NAME?: EnvBindingValue;
|
|
36
36
|
SITE_DESCRIPTION?: EnvBindingValue;
|
|
37
37
|
SITE_LANGUAGE?: EnvBindingValue;
|
|
38
|
+
DASHBOARD_LANGUAGE?: EnvBindingValue;
|
|
38
39
|
HOME_DEFAULT_VIEW?: EnvBindingValue;
|
|
39
40
|
MAIN_RSS_FEED?: EnvBindingValue;
|
|
40
41
|
TIME_ZONE?: EnvBindingValue;
|
package/src/types/config.ts
CHANGED
|
@@ -41,6 +41,13 @@ export const CONFIG_FIELDS = {
|
|
|
41
41
|
envOnly: false,
|
|
42
42
|
envKeys: ["SITE_LANGUAGE"],
|
|
43
43
|
},
|
|
44
|
+
// Admin dashboard UI locale. Empty means "follow the content language"
|
|
45
|
+
// (resolved through the catalog fallback chain, i.e. today's behaviour).
|
|
46
|
+
DASHBOARD_LANGUAGE: {
|
|
47
|
+
defaultValue: "",
|
|
48
|
+
envOnly: false,
|
|
49
|
+
envKeys: ["DASHBOARD_LANGUAGE"],
|
|
50
|
+
},
|
|
44
51
|
CJK_SERIF_FONT: {
|
|
45
52
|
defaultValue: "off",
|
|
46
53
|
envOnly: false,
|
|
@@ -163,7 +170,7 @@ export const CONFIG_FIELDS = {
|
|
|
163
170
|
envKeys: ["ASSET_BASE_URL"],
|
|
164
171
|
},
|
|
165
172
|
UPLOAD_MAX_FILE_SIZE_MB: {
|
|
166
|
-
defaultValue: "
|
|
173
|
+
defaultValue: "1024",
|
|
167
174
|
envOnly: true,
|
|
168
175
|
envKeys: ["UPLOAD_MAX_FILE_SIZE_MB"],
|
|
169
176
|
},
|
|
@@ -455,6 +462,12 @@ export interface AppConfig {
|
|
|
455
462
|
/** true only when description is set in DB or ENV (not just the default) */
|
|
456
463
|
siteDescriptionExplicit: boolean;
|
|
457
464
|
siteLanguage: string;
|
|
465
|
+
/**
|
|
466
|
+
* Admin dashboard UI locale. Empty string means "follow the content
|
|
467
|
+
* language" (derived via the catalog fallback chain). When set, it is one of
|
|
468
|
+
* the translated catalog locales ("en", "zh-Hans", "zh-Hant").
|
|
469
|
+
*/
|
|
470
|
+
dashboardLanguage: string;
|
|
458
471
|
/** CJK serif font locale: "off", "zh-Hans", "zh-Hant", "ja", or "ko" */
|
|
459
472
|
cjkSerifFont: string;
|
|
460
473
|
homeDefaultView: string;
|
package/src/types/props.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface ArchiveFilters {
|
|
|
59
59
|
mediaKinds?: MediaKind[];
|
|
60
60
|
hasMedia?: boolean;
|
|
61
61
|
hasTitle?: boolean;
|
|
62
|
+
/** true = threads (roots with replies), false = single posts (no replies) */
|
|
63
|
+
hasReplies?: boolean;
|
|
62
64
|
visibility?: ArchiveVisibility;
|
|
63
65
|
view?: ArchiveView;
|
|
64
66
|
}
|
|
@@ -122,6 +124,7 @@ export interface CollectionsPageProps {
|
|
|
122
124
|
items: CollectionDirectoryItem[];
|
|
123
125
|
isAuthenticated: boolean;
|
|
124
126
|
sitePathPrefix?: string;
|
|
127
|
+
siteOrigin?: string;
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
// =============================================================================
|
|
@@ -743,6 +743,19 @@ export const ComposeForm: FC<ComposeFormProps> = ({
|
|
|
743
743
|
"@context: Compose dialog header title when composing a thread",
|
|
744
744
|
}),
|
|
745
745
|
),
|
|
746
|
+
replyTitle: i18n._(
|
|
747
|
+
msg({
|
|
748
|
+
message: "Reply",
|
|
749
|
+
comment:
|
|
750
|
+
"@context: Compose dialog header title when replying to a post",
|
|
751
|
+
}),
|
|
752
|
+
),
|
|
753
|
+
editTitle: i18n._(
|
|
754
|
+
msg({
|
|
755
|
+
message: "Edit",
|
|
756
|
+
comment: "@context: Compose dialog header title when editing a post",
|
|
757
|
+
}),
|
|
758
|
+
),
|
|
746
759
|
slashHint: i18n._(
|
|
747
760
|
msg({
|
|
748
761
|
message: "Type / for commands",
|
|
@@ -31,13 +31,11 @@ export function AccountMenuContent({
|
|
|
31
31
|
demoMode = false,
|
|
32
32
|
hostedControlPlaneAccountUrl,
|
|
33
33
|
hostedControlPlaneProviderLabel,
|
|
34
|
-
hostedControlPlaneSiteDeleteUrl,
|
|
35
34
|
}: {
|
|
36
35
|
sitePathPrefix?: string;
|
|
37
36
|
demoMode?: boolean;
|
|
38
37
|
hostedControlPlaneAccountUrl?: string | null;
|
|
39
38
|
hostedControlPlaneProviderLabel?: string | null;
|
|
40
|
-
hostedControlPlaneSiteDeleteUrl?: string | null;
|
|
41
39
|
}) {
|
|
42
40
|
const { i18n } = useLingui();
|
|
43
41
|
const isHosted = Boolean(hostedControlPlaneAccountUrl);
|
|
@@ -266,43 +264,6 @@ export function AccountMenuContent({
|
|
|
266
264
|
/>
|
|
267
265
|
</SettingsDirectorySection>
|
|
268
266
|
|
|
269
|
-
{!demoMode && isHosted && hostedControlPlaneSiteDeleteUrl ? (
|
|
270
|
-
<SettingsDirectorySection
|
|
271
|
-
title={i18n._(
|
|
272
|
-
msg({
|
|
273
|
-
message: "Danger Zone",
|
|
274
|
-
comment:
|
|
275
|
-
"@context: Settings group label for destructive account actions",
|
|
276
|
-
}),
|
|
277
|
-
)}
|
|
278
|
-
tone="danger"
|
|
279
|
-
>
|
|
280
|
-
<SettingsDirectoryLink
|
|
281
|
-
href={hostedControlPlaneSiteDeleteUrl}
|
|
282
|
-
icon={ICONS.trash}
|
|
283
|
-
tone="danger"
|
|
284
|
-
name={i18n._(
|
|
285
|
-
msg({
|
|
286
|
-
message: "Delete Hosted Site",
|
|
287
|
-
comment:
|
|
288
|
-
"@context: Settings item — open the hosted site danger zone in the control plane",
|
|
289
|
-
}),
|
|
290
|
-
)}
|
|
291
|
-
description={i18n._(
|
|
292
|
-
msg({
|
|
293
|
-
message:
|
|
294
|
-
"Open the hosted site controls in {providerLabel} to cancel billing or permanently delete this site.",
|
|
295
|
-
comment:
|
|
296
|
-
"@context: Settings item description for the hosted delete-site entry in the account menu",
|
|
297
|
-
}),
|
|
298
|
-
{
|
|
299
|
-
providerLabel,
|
|
300
|
-
},
|
|
301
|
-
)}
|
|
302
|
-
/>
|
|
303
|
-
</SettingsDirectorySection>
|
|
304
|
-
) : null}
|
|
305
|
-
|
|
306
267
|
{!demoMode && !isHosted && (
|
|
307
268
|
<SettingsDirectorySection
|
|
308
269
|
title={i18n._(
|