@jant/core 0.6.6 → 0.6.8
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 +1 -0
- package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- 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-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.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__/json.test.ts +94 -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 +357 -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/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-collection-directory.ts +1 -0
- package/src/client/components/jant-collection-form.ts +1 -0
- package/src/client/components/jant-command-palette.ts +4 -0
- package/src/client/components/jant-compose-dialog.ts +106 -44
- package/src/client/components/jant-compose-editor.ts +65 -11
- package/src/client/components/jant-compose-fullscreen.ts +3 -0
- package/src/client/components/jant-nav-manager.ts +4 -0
- package/src/client/components/jant-post-menu.ts +3 -0
- package/src/client/components/jant-repo-picker.ts +3 -0
- package/src/client/components/jant-settings-general.ts +3 -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/json.ts +56 -2
- package/src/client/multipart-upload.ts +17 -7
- package/src/client/note-expand.ts +63 -0
- package/src/client/upload-session.ts +17 -9
- package/src/client.ts +1 -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 +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- 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/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -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/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +77 -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/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- 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 +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/site-admin.ts +121 -0
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -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/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/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-CL2PC1Fl.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
|
@@ -72,6 +72,11 @@ export interface ManageManagedSiteDomainInput {
|
|
|
72
72
|
makePrimary?: boolean;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
export interface RenameManagedSiteInput {
|
|
76
|
+
key: string;
|
|
77
|
+
primaryHost: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
75
80
|
export interface ExportManagedSiteDeps {
|
|
76
81
|
env: Bindings;
|
|
77
82
|
storage?: StorageDriver | null;
|
|
@@ -130,6 +135,20 @@ export interface SiteAdminService {
|
|
|
130
135
|
): Promise<ManagedSitePostCountResult[]>;
|
|
131
136
|
suspendManagedSite(siteId: string): Promise<Site>;
|
|
132
137
|
resumeManagedSite(siteId: string): Promise<Site>;
|
|
138
|
+
/**
|
|
139
|
+
* Atomically rename a managed site: change `sites.key` and rewrite the
|
|
140
|
+
* existing primary domain's host. The primary domain row keeps its id, so
|
|
141
|
+
* downstream projections that key off the domain id (e.g. the control
|
|
142
|
+
* plane's `cloud_site_domain` projection) stay stable across the rename.
|
|
143
|
+
*
|
|
144
|
+
* Throws ConflictError if the new key or primary host collide with another
|
|
145
|
+
* site (or with a non-primary domain on the same site). No-ops when the new
|
|
146
|
+
* key and host both match the current values.
|
|
147
|
+
*/
|
|
148
|
+
renameManagedSite(
|
|
149
|
+
siteId: string,
|
|
150
|
+
input: RenameManagedSiteInput,
|
|
151
|
+
): Promise<ManagedSiteResult>;
|
|
133
152
|
deleteManagedSite(
|
|
134
153
|
siteId: string,
|
|
135
154
|
deps?: DeleteManagedSiteDeps,
|
|
@@ -1024,5 +1043,107 @@ export function createSiteAdminService(
|
|
|
1024
1043
|
.where(eq(siteDomains.id, normalizedDomainId));
|
|
1025
1044
|
});
|
|
1026
1045
|
},
|
|
1046
|
+
async renameManagedSite(siteId, input) {
|
|
1047
|
+
assertManagedSiteOperationsEnabled();
|
|
1048
|
+
const normalizedSiteId = siteId.trim();
|
|
1049
|
+
if (!normalizedSiteId) {
|
|
1050
|
+
throw new NotFoundError("Site");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const newKey = input.key.trim();
|
|
1054
|
+
const newPrimaryHost = input.primaryHost.trim().toLowerCase();
|
|
1055
|
+
if (!newKey) {
|
|
1056
|
+
throw new ConflictError("Site key is required.");
|
|
1057
|
+
}
|
|
1058
|
+
if (!newPrimaryHost) {
|
|
1059
|
+
throw new ConflictError("Primary host is required.");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function performRename(
|
|
1063
|
+
targetDb: Database,
|
|
1064
|
+
): Promise<ManagedSiteResult> {
|
|
1065
|
+
const siteRow = await requireSiteRow(targetDb, normalizedSiteId);
|
|
1066
|
+
|
|
1067
|
+
const primaryRows = await targetDb
|
|
1068
|
+
.select()
|
|
1069
|
+
.from(siteDomains)
|
|
1070
|
+
.where(
|
|
1071
|
+
and(
|
|
1072
|
+
eq(siteDomains.siteId, normalizedSiteId),
|
|
1073
|
+
eq(siteDomains.kind, "primary"),
|
|
1074
|
+
),
|
|
1075
|
+
)
|
|
1076
|
+
.limit(1);
|
|
1077
|
+
const primaryRow = primaryRows[0];
|
|
1078
|
+
if (!primaryRow) {
|
|
1079
|
+
throw new NotFoundError("Site primary domain");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (siteRow.key === newKey && primaryRow.host === newPrimaryHost) {
|
|
1083
|
+
return {
|
|
1084
|
+
domain: toSiteDomain(primaryRow),
|
|
1085
|
+
site: toSite(siteRow),
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (siteRow.key !== newKey) {
|
|
1090
|
+
const conflictingSite = await targetDb
|
|
1091
|
+
.select({ id: sites.id })
|
|
1092
|
+
.from(sites)
|
|
1093
|
+
.where(eq(sites.key, newKey))
|
|
1094
|
+
.limit(1);
|
|
1095
|
+
if (conflictingSite[0]) {
|
|
1096
|
+
throw new ConflictError("Site key is already in use.");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (primaryRow.host !== newPrimaryHost) {
|
|
1101
|
+
const conflictingDomain = await targetDb
|
|
1102
|
+
.select({ id: siteDomains.id, siteId: siteDomains.siteId })
|
|
1103
|
+
.from(siteDomains)
|
|
1104
|
+
.where(eq(siteDomains.host, newPrimaryHost))
|
|
1105
|
+
.limit(1);
|
|
1106
|
+
if (conflictingDomain[0]) {
|
|
1107
|
+
throw new ConflictError("Primary host is already in use.");
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const timestamp = now();
|
|
1112
|
+
const updatedSiteRow = (
|
|
1113
|
+
await targetDb
|
|
1114
|
+
.update(sites)
|
|
1115
|
+
.set({ key: newKey, updatedAt: timestamp })
|
|
1116
|
+
.where(eq(sites.id, normalizedSiteId))
|
|
1117
|
+
.returning()
|
|
1118
|
+
)[0];
|
|
1119
|
+
if (!updatedSiteRow) {
|
|
1120
|
+
throw new NotFoundError("Site");
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
const updatedDomainRow = (
|
|
1124
|
+
await targetDb
|
|
1125
|
+
.update(siteDomains)
|
|
1126
|
+
.set({ host: newPrimaryHost, updatedAt: timestamp })
|
|
1127
|
+
.where(eq(siteDomains.id, primaryRow.id))
|
|
1128
|
+
.returning()
|
|
1129
|
+
)[0];
|
|
1130
|
+
if (!updatedDomainRow) {
|
|
1131
|
+
throw new NotFoundError("Site primary domain");
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
return {
|
|
1135
|
+
domain: toSiteDomain(updatedDomainRow),
|
|
1136
|
+
site: toSite(updatedSiteRow),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (supportsDrizzleTransaction(db, databaseDialect)) {
|
|
1141
|
+
return db.transaction(async (tx) =>
|
|
1142
|
+
performRename(tx as unknown as Database),
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return performRename(db);
|
|
1147
|
+
},
|
|
1027
1148
|
};
|
|
1028
1149
|
}
|
|
@@ -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,7 @@ export interface UploadSessionService {
|
|
|
143
147
|
}): Promise<{
|
|
144
148
|
abortedMultipartUploads: number;
|
|
145
149
|
deletedSessions: number;
|
|
150
|
+
deletedOrphanMedia: number;
|
|
146
151
|
}>;
|
|
147
152
|
abort(id: string, deps: { storage: StorageDriver }): Promise<void>;
|
|
148
153
|
}
|
|
@@ -760,9 +765,22 @@ export function createUploadSessionService(
|
|
|
760
765
|
deletedSessions += 1;
|
|
761
766
|
}
|
|
762
767
|
|
|
768
|
+
// Reap finalized media that was uploaded during compose but never
|
|
769
|
+
// attached to a post (`postId IS NULL`) past the grace window. Deletes
|
|
770
|
+
// the S3/R2 objects and DB rows (best-effort storage delete). Bounded by
|
|
771
|
+
// the same per-run `limit`; any backlog drains over subsequent runs.
|
|
772
|
+
const orphanIds = await media.listOrphanedMediaIds({
|
|
773
|
+
before: now() - ORPHAN_MEDIA_GRACE_SECONDS,
|
|
774
|
+
limit,
|
|
775
|
+
});
|
|
776
|
+
if (orphanIds.length > 0) {
|
|
777
|
+
await media.deleteByIds(orphanIds, deps.storage);
|
|
778
|
+
}
|
|
779
|
+
|
|
763
780
|
return {
|
|
764
781
|
abortedMultipartUploads,
|
|
765
782
|
deletedSessions,
|
|
783
|
+
deletedOrphanMedia: orphanIds.length,
|
|
766
784
|
};
|
|
767
785
|
},
|
|
768
786
|
};
|
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(
|
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/config.ts
CHANGED
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._(
|
|
@@ -22,6 +22,28 @@ function ChevronRight() {
|
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function ExternalLinkIndicator() {
|
|
26
|
+
return (
|
|
27
|
+
<svg
|
|
28
|
+
class="settings-directory-item-chevron"
|
|
29
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
30
|
+
width="16"
|
|
31
|
+
height="16"
|
|
32
|
+
viewBox="0 0 24 24"
|
|
33
|
+
fill="none"
|
|
34
|
+
stroke="currentColor"
|
|
35
|
+
stroke-width="2"
|
|
36
|
+
stroke-linecap="round"
|
|
37
|
+
stroke-linejoin="round"
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
>
|
|
40
|
+
<path d="M15 3h6v6" />
|
|
41
|
+
<path d="M10 14 21 3" />
|
|
42
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
43
|
+
</svg>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
export function SettingsDirectorySection({
|
|
26
48
|
title,
|
|
27
49
|
tone = "default",
|
|
@@ -45,10 +67,12 @@ export function SettingsDirectoryItemContent({
|
|
|
45
67
|
icon,
|
|
46
68
|
name,
|
|
47
69
|
description,
|
|
70
|
+
external = false,
|
|
48
71
|
}: {
|
|
49
72
|
icon: string;
|
|
50
73
|
name: string;
|
|
51
74
|
description: string;
|
|
75
|
+
external?: boolean;
|
|
52
76
|
}) {
|
|
53
77
|
return (
|
|
54
78
|
<>
|
|
@@ -59,7 +83,7 @@ export function SettingsDirectoryItemContent({
|
|
|
59
83
|
<span class="settings-directory-item-name">{name}</span>
|
|
60
84
|
<span class="settings-directory-item-desc">{description}</span>
|
|
61
85
|
</span>
|
|
62
|
-
<ChevronRight />
|
|
86
|
+
{external ? <ExternalLinkIndicator /> : <ChevronRight />}
|
|
63
87
|
</>
|
|
64
88
|
);
|
|
65
89
|
}
|
|
@@ -93,6 +117,7 @@ export function SettingsDirectoryLink({
|
|
|
93
117
|
icon={icon}
|
|
94
118
|
name={name}
|
|
95
119
|
description={description}
|
|
120
|
+
external={target === "_blank"}
|
|
96
121
|
/>
|
|
97
122
|
</a>
|
|
98
123
|
);
|