From 3980aa0a9bea40d0cb8395428f4c4f1f57d98647 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 15 Jul 2026 22:28:40 +0100 Subject: [PATCH] feat(dashboard): restyle lesson transcript and summarize actions (#758) * feat(dashboard): restyle lesson transcript and summarize actions Add separator lines around lesson material actions below the video, switch transcript and summarize controls to plain ghost buttons with hover-animated icons, and separate the two actions with a pipe when both are visible. Display rules are unchanged. Co-authored-by: Rotimi Best * refactor(dashboard): inline summarize action into material actions Fold lesson-summarize-button into lesson-material-actions and remove the standalone component. Lessons without videos use the same actions bar with separators disabled. Co-authored-by: Rotimi Best * fix(dashboard): align lesson actions with note width on mobile Constrain material actions to max-w-2xl when there is no video so they match the note column. Improve small-screen layout with tighter padding, flex-wrap, and hide the pipe separator below the sm breakpoint. Co-authored-by: Rotimi Best * add script update * fix(dashboard): tighten spacing between lesson video and note Remove gap-2 from the lesson content stack and drop bottom margin on the last video wrapper so material actions separators control the gap before the note. Co-authored-by: Rotimi Best * fix(dashboard): show bottom separator on note-only lesson actions Lessons without video were passing showSeparators=false, hiding both lines. Split into top/bottom controls so summarize still gets a line before the note without one above the action row. Co-authored-by: Rotimi Best * fix(dashboard): always show top separator on lesson material actions Co-authored-by: Rotimi Best * fix(dashboard): reduce lesson material action separator margin to my-2 Co-authored-by: Rotimi Best * feat(dashboard): consolidate multi-video transcript actions and navigation Register transcript sources per upload video, show a single material actions row after all videos, and add prev/next video navigation in the transcript side panel when multiple transcripts are available. Co-authored-by: Rotimi Best * fix(dashboard): address greptile review on lesson material actions Require lessonId on material actions and stop overwriting the transcript seek wrapper with a stale localSeekFn snapshot in the sync effect. Co-authored-by: Rotimi Best * fix(dashboard): stop transcript registry effect infinite loop Replace the reactive sync effect with explicit registry updates from loadTranscript and skip no-op transcript source writes to avoid reading and writing transcriptSources in the same effect pass. Co-authored-by: Rotimi Best * fix(ui): raise tooltip z-index above app bar side panel Use z-menu-elevated for tooltip content so header icon tooltips are not hidden behind the fixed transcript side panel (z-app-bar). Co-authored-by: Rotimi Best --------- Co-authored-by: Rotimi Best --- .../course/components/lesson/index.ts | 2 +- .../lesson/lesson-material-actions.svelte | 78 ++++++++++ .../lesson/lesson-summarize-button.svelte | 21 --- .../lesson/video/lesson-video-bus.svelte.ts | 144 ++++++++++++++++- .../lesson/video/lesson-video-player.svelte | 94 +++++++----- .../lesson/video/transcript-side-panel.svelte | 79 +++++++++- .../lesson/video/video-card-dropdown.svelte | 21 ++- .../components/lesson/video/video.svelte | 26 +++- .../lib/features/course/pages/lesson.svelte | 8 +- .../src/lib/utils/translations/da.json | 5 +- .../src/lib/utils/translations/de.json | 5 +- .../src/lib/utils/translations/en.json | 5 +- .../src/lib/utils/translations/es.json | 5 +- .../src/lib/utils/translations/fr.json | 5 +- .../src/lib/utils/translations/hi.json | 5 +- .../src/lib/utils/translations/pl.json | 5 +- .../src/lib/utils/translations/pt.json | 5 +- .../src/lib/utils/translations/ru.json | 5 +- .../src/lib/utils/translations/vi.json | 5 +- packages/db/package.json | 1 + packages/db/src/scripts/update-user-email.ts | 145 ++++++++++++++++++ .../src/base/tooltip/tooltip-content.svelte | 4 +- packages/ui/src/index.css | 5 +- 23 files changed, 580 insertions(+), 98 deletions(-) create mode 100644 apps/dashboard/src/lib/features/course/components/lesson/lesson-material-actions.svelte delete mode 100644 apps/dashboard/src/lib/features/course/components/lesson/lesson-summarize-button.svelte create mode 100644 packages/db/src/scripts/update-user-email.ts diff --git a/apps/dashboard/src/lib/features/course/components/lesson/index.ts b/apps/dashboard/src/lib/features/course/components/lesson/index.ts index 47b99059..6c831442 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/index.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/index.ts @@ -10,4 +10,4 @@ export { default as Document } from './document/document.svelte'; export { default as AddVideoModal } from './video/add-video-modal.svelte'; export { default as AddDocumentModal } from './document/add-document-modal.svelte'; export { default as LessonSettingsTab } from './lesson-settings-tab.svelte'; -export { default as LessonSummarizeButton } from './lesson-summarize-button.svelte'; +export { default as LessonMaterialActions } from './lesson-material-actions.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/lesson/lesson-material-actions.svelte b/apps/dashboard/src/lib/features/course/components/lesson/lesson-material-actions.svelte new file mode 100644 index 00000000..186adefe --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/lesson/lesson-material-actions.svelte @@ -0,0 +1,78 @@ + + +{#if showTranscript || showSummarize} +
+ {#if showTopSeparator} + + {/if} + +
+ {#if showTranscript} + + {#snippet children(isHovered)} + + {/snippet} + + {/if} + + {#if showTranscript && showSummarize} + + {/if} + + {#if showSummarize} + + {#snippet children(isHovered)} + + {/snippet} + + {/if} +
+ + {#if showBottomSeparator} + + {/if} +
+{/if} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/lesson-summarize-button.svelte b/apps/dashboard/src/lib/features/course/components/lesson/lesson-summarize-button.svelte deleted file mode 100644 index c9265ade..00000000 --- a/apps/dashboard/src/lib/features/course/components/lesson/lesson-summarize-button.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-bus.svelte.ts b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-bus.svelte.ts index 1c274a5a..155d7688 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-bus.svelte.ts +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-bus.svelte.ts @@ -1,18 +1,32 @@ +import { SvelteMap } from 'svelte/reactivity'; import type { AssetTranscriptPayload } from '$features/media/utils/types'; type SeekFn = (seconds: number) => void; +export type LessonTranscriptSource = { + assetId: string; + videoIndex: number; + transcript: AssetTranscriptPayload | null; + transcriptLoading: boolean; + currentTimeSeconds: number; + seek: SeekFn; +}; + class LessonVideoBus { /** Asset id of the currently mounted upload-video player (if any). */ assetId = $state(null); - /** Last reported playback time in seconds. */ + /** Last reported playback time in seconds for the active player asset. */ currentTimeSeconds = $state(0); /** True after the user (or autoplay) has started playback at least once for the current asset. */ hasPlayed = $state(false); - /** Latest transcript payload for the active asset; null if none / still loading. */ + /** Latest transcript payload for the active transcript source. */ transcript = $state(null); - /** True while the transcript fetch is in flight. */ + /** True while the active transcript fetch is in flight. */ transcriptLoading = $state(false); + /** Transcript sources keyed by upload asset id. */ + transcriptSources = new SvelteMap(); + /** Which upload asset transcript is shown in the side panel. */ + activeTranscriptAssetId = $state(null); private seekFn: SeekFn = () => {}; @@ -21,14 +35,136 @@ class LessonVideoBus { } seek(seconds: number) { + const activeSource = this.getActiveTranscriptSource(); + + if (activeSource) { + activeSource.seek(seconds); + return; + } + this.seekFn(seconds); } + registerTranscriptSource(source: LessonTranscriptSource) { + this.transcriptSources.set(source.assetId, source); + } + + unregisterTranscriptSource(assetId: string) { + this.transcriptSources.delete(assetId); + + if (this.activeTranscriptAssetId === assetId) { + const nextSource = this.getNavigableTranscriptSources()[0] ?? null; + this.activeTranscriptAssetId = nextSource?.assetId ?? null; + this.syncActiveTranscriptToLegacyFields(); + } + } + + updateTranscriptSource(assetId: string, patch: Partial) { + const existing = this.transcriptSources.get(assetId); + + if (!existing) return; + + let next = existing; + let changed = false; + + for (const key of Object.keys(patch) as (keyof LessonTranscriptSource)[]) { + const value = patch[key]; + + if (value !== undefined && existing[key] !== value) { + if (!changed) { + next = { ...existing }; + changed = true; + } + + next[key] = value as LessonTranscriptSource[typeof key]; + } + } + + if (!changed) return; + + this.transcriptSources.set(assetId, next); + + if (this.activeTranscriptAssetId === assetId) { + this.syncActiveTranscriptToLegacyFields(); + } + } + + getActiveTranscriptSource(): LessonTranscriptSource | null { + const assetId = this.activeTranscriptAssetId; + + if (!assetId) return null; + + return this.transcriptSources.get(assetId) ?? null; + } + + getNavigableTranscriptSources(): LessonTranscriptSource[] { + return [...this.transcriptSources.values()] + .filter((source) => (source.transcript?.segments?.length ?? 0) > 0) + .sort((left, right) => left.videoIndex - right.videoIndex); + } + + hasTranscriptAvailable(): boolean { + return [...this.transcriptSources.values()].some((source) => source.transcript != null); + } + + selectTranscriptSource(assetId: string) { + if (!this.transcriptSources.has(assetId)) return; + + this.activeTranscriptAssetId = assetId; + this.syncActiveTranscriptToLegacyFields(); + } + + prepareTranscriptPanelSelection() { + const navigableSources = this.getNavigableTranscriptSources(); + + if (navigableSources.length === 0) { + const fallbackSource = [...this.transcriptSources.values()] + .filter((source) => source.transcript || source.transcriptLoading) + .sort((left, right) => left.videoIndex - right.videoIndex)[0]; + + if (!fallbackSource) return false; + + this.selectTranscriptSource(fallbackSource.assetId); + return true; + } + + const currentAssetId = this.activeTranscriptAssetId; + const hasCurrent = currentAssetId != null && navigableSources.some((source) => source.assetId === currentAssetId); + + if (!hasCurrent) { + this.selectTranscriptSource(navigableSources[0].assetId); + } + + return true; + } + /** Replace the active transcript (e.g. after an edit is saved). */ setTranscript(transcript: AssetTranscriptPayload | null) { + const assetId = this.activeTranscriptAssetId; + + if (assetId) { + this.updateTranscriptSource(assetId, { transcript }); + return; + } + this.transcript = transcript; } + private syncActiveTranscriptToLegacyFields() { + const source = this.getActiveTranscriptSource(); + + if (!source) { + this.transcript = null; + this.transcriptLoading = false; + return; + } + + this.assetId = source.assetId; + this.transcript = source.transcript; + this.transcriptLoading = source.transcriptLoading; + this.seekFn = source.seek; + } + /** Reset all per-lesson state (called when the upload asset changes or the player unmounts). */ reset(opts?: { keepAssetId?: boolean }) { if (!opts?.keepAssetId) { @@ -39,6 +175,8 @@ class LessonVideoBus { this.hasPlayed = false; this.transcript = null; this.transcriptLoading = false; + this.activeTranscriptAssetId = null; + this.transcriptSources.clear(); this.seekFn = () => {}; } } diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte index 5733cce2..81a44f50 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/lesson-video-player.svelte @@ -1,6 +1,5 @@ {#snippet content(video, index)} {#key video.type === 'upload' ? ((video as typeof video & { assetId?: string }).assetId ?? video.link) : video.link} - + {/key} {/snippet} @@ -82,10 +97,17 @@ {#if videos.length}
{#each videos as video, index} -
+
{@render content(video, index)}
{/each} + + 0} + {lessonId} + onTranscript={openTranscriptPanel} + />
{/if} {/if} diff --git a/apps/dashboard/src/lib/features/course/pages/lesson.svelte b/apps/dashboard/src/lib/features/course/pages/lesson.svelte index 7ed8fcf8..0f70ce44 100644 --- a/apps/dashboard/src/lib/features/course/pages/lesson.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lesson.svelte @@ -47,7 +47,7 @@ AddVideoModal, AddDocumentModal, LessonSettingsTab, - LessonSummarizeButton + LessonMaterialActions } from '$features/course/components/lesson'; import type { TLocale } from '@cio/db/types'; @@ -525,11 +525,9 @@ {:else if lessonApi.lesson && !isMaterialsEmpty} {#key lessonId} -
+
{#if !hasLessonVideos} -
- -
+ {/if} {#each viewModeComponents as Component, index (index)} diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 60c1a744..433cac7c 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -2064,7 +2064,10 @@ "edit": "Rediger udskrift", "save": "Gem", "cancel": "Annuller", - "segment_label": "Segmenter kl. {timestamp}" + "segment_label": "Segmenter kl. {timestamp}", + "video_nav_label": "Video {number}", + "previous_video": "Forrige videotransskript", + "next_video": "Næste videotransskript" }, "simple_card": { "kind_youtube": "YouTube", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 8506926f..fcb5696e 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -2064,7 +2064,10 @@ "edit": "Transkript bearbeiten", "save": "Speichern", "cancel": "Abbrechen", - "segment_label": "Segmentieren um {timestamp}" + "segment_label": "Segmentieren um {timestamp}", + "video_nav_label": "Video {number}", + "previous_video": "Vorheriges Video-Transkript", + "next_video": "Nächstes Video-Transkript" }, "simple_card": { "kind_youtube": "YouTube", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index cf5a9482..1e034d2f 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -2384,7 +2384,10 @@ "edit": "Edit transcript", "save": "Save", "cancel": "Cancel", - "segment_label": "Segment at {timestamp}" + "segment_label": "Segment at {timestamp}", + "video_nav_label": "Video {number}", + "previous_video": "Previous video transcript", + "next_video": "Next video transcript" } }, "document": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 02eeb8ac..ac83f343 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -2064,7 +2064,10 @@ "edit": "Editar transcripción", "save": "Guardar", "cancel": "Cancelar", - "segment_label": "Segmentar en {timestamp}" + "segment_label": "Segmentar en {timestamp}", + "video_nav_label": "Video {number}", + "previous_video": "Transcripción del video anterior", + "next_video": "Transcripción del video siguiente" }, "simple_card": { "kind_youtube": "youtube", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 0212c651..a0e374bb 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -2064,7 +2064,10 @@ "edit": "Modifier la transcription", "save": "Enregistrer", "cancel": "Annuler", - "segment_label": "Segment à {timestamp}" + "segment_label": "Segment à {timestamp}", + "video_nav_label": "Vidéo {number}", + "previous_video": "Transcription de la vidéo précédente", + "next_video": "Transcription de la vidéo suivante" }, "simple_card": { "kind_youtube": "YouTube", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 18ccfb0b..ae3145bf 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -2064,7 +2064,10 @@ "edit": "प्रतिलेख संपादित करें", "save": "सहेजें", "cancel": "रद्द करें", - "segment_label": "{timestamp} पर खंड" + "segment_label": "{timestamp} पर खंड", + "video_nav_label": "वीडियो {number}", + "previous_video": "पिछले वीडियो का प्रतिलेख", + "next_video": "अगले वीडियो का प्रतिलेख" }, "simple_card": { "kind_youtube": "यूट्यूब", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index def446c3..1b7d7939 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -2064,7 +2064,10 @@ "edit": "Edytuj transkrypcję", "save": "Zapisz", "cancel": "Anuluj", - "segment_label": "Segment o {timestamp}" + "segment_label": "Segment o {timestamp}", + "video_nav_label": "Wideo {number}", + "previous_video": "Transkrypcja poprzedniego wideo", + "next_video": "Transkrypcja następnego wideo" }, "simple_card": { "kind_youtube": "YouTube", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index d6abaaad..2e9b87e1 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -2064,7 +2064,10 @@ "edit": "Editar transcrição", "save": "Salvar", "cancel": "Cancelar", - "segment_label": "Segmentar em {timestamp}" + "segment_label": "Segmentar em {timestamp}", + "video_nav_label": "Vídeo {number}", + "previous_video": "Transcrição do vídeo anterior", + "next_video": "Transcrição do próximo vídeo" }, "simple_card": { "kind_youtube": "YouTube", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 215db764..00fba4df 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -2064,7 +2064,10 @@ "edit": "Изменить стенограмму", "save": "Сохранить", "cancel": "Отмена", - "segment_label": "Сегмент в {timestamp}" + "segment_label": "Сегмент в {timestamp}", + "video_nav_label": "Видео {number}", + "previous_video": "Транскрипт предыдущего видео", + "next_video": "Транскрипт следующего видео" }, "simple_card": { "kind_youtube": "Ютуб", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 6a4370ed..3feaf562 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -2064,7 +2064,10 @@ "edit": "Chỉnh sửa bản ghi", "save": "Lưu", "cancel": "Hủy bỏ", - "segment_label": "Đoạn tại {timestamp}" + "segment_label": "Đoạn tại {timestamp}", + "video_nav_label": "Video {number}", + "previous_video": "Bản ghi video trước", + "next_video": "Bản ghi video tiếp theo" }, "simple_card": { "kind_youtube": "YouTube", diff --git a/packages/db/package.json b/packages/db/package.json index cb8aeefc..6bafcee5 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -69,6 +69,7 @@ "db:login-link": "tsx src/scripts/generate-login-link.ts", "db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts", "db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts", + "db:update-user-email": "tsx src/scripts/update-user-email.ts", "db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts", "move-users": "tsx src/scripts/supabase-to-betterauth.ts", "pull": "drizzle-kit pull --config drizzle.config.ts", diff --git a/packages/db/src/scripts/update-user-email.ts b/packages/db/src/scripts/update-user-email.ts new file mode 100644 index 00000000..3c64ccfb --- /dev/null +++ b/packages/db/src/scripts/update-user-email.ts @@ -0,0 +1,145 @@ +/** + * Updates a user's email address everywhere it is stored: + * "user" (auth), profile, organizationmember, groupmember and cohort_member. + * + * Membership tables are matched by profile_id OR the old email so rows that + * only carry the denormalized email (pre-signup invites) are updated too. + * Pending organization_invite rows are reported but not modified. + * + * Usage: + * pnpm db:update-user-email -- --old old@example.com --new new@example.com # dry run (default) + * pnpm db:update-user-email -- --old old@example.com --new new@example.com --execute # apply updates + */ +import 'dotenv/config'; + +import postgres from 'postgres'; + +const connectionString = process.env.DATABASE_URL ?? process.env.PRIVATE_DATABASE_URL ?? ''; +const shouldExecute = process.argv.includes('--execute'); + +function readArg(flag: string): string { + const flagIndex = process.argv.indexOf(flag); + const value = flagIndex === -1 ? '' : (process.argv[flagIndex + 1] ?? ''); + + return value.trim().toLowerCase(); +} + +const oldEmail = readArg('--old'); +const newEmail = readArg('--new'); + +if (!connectionString) { + console.error('DATABASE_URL or PRIVATE_DATABASE_URL environment variable is required'); + process.exit(1); +} + +if (!oldEmail || !newEmail) { + console.error('Usage: pnpm db:update-user-email -- --old old@example.com --new new@example.com [--execute]'); + process.exit(1); +} + +if (oldEmail === newEmail) { + console.error('The old and new email addresses are identical.'); + process.exit(1); +} + +if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + console.error(`"${newEmail}" does not look like a valid email address.`); + process.exit(1); +} + +async function main() { + const sql = postgres(connectionString); + + try { + const users = await sql` + SELECT id, name, email, email_verified FROM "user" WHERE lower(email) = ${oldEmail} + `; + + if (users.length === 0) { + console.error(`No user found with email ${oldEmail}`); + process.exit(1); + } + + const targetUser = users[0]; + console.log(`Found user ${targetUser.id} (${targetUser.name}) <${targetUser.email}>`); + + const conflicts = await sql` + SELECT 'user' AS source, id::text FROM "user" WHERE lower(email) = ${newEmail} + UNION ALL + SELECT 'profile' AS source, id::text FROM profile WHERE lower(email) = ${newEmail} + `; + + if (conflicts.length > 0) { + console.error(`The email ${newEmail} is already taken:`); + for (const conflict of conflicts) { + console.error(` ${conflict.source} ${conflict.id}`); + } + process.exit(1); + } + + const userId = targetUser.id; + + const [profileRows, orgMemberRows, groupMemberRows, cohortMemberRows, pendingInvites] = await Promise.all([ + sql`SELECT id FROM profile WHERE id = ${userId}`, + sql`SELECT id FROM organizationmember WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}`, + sql`SELECT id FROM groupmember WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}`, + sql`SELECT id FROM cohort_member WHERE profile_id = ${userId} OR lower(email) = ${oldEmail}`, + sql`SELECT id FROM organization_invite WHERE lower(email) = ${oldEmail}` + ]); + + console.log(''); + console.log(`Rows to update with ${newEmail}:`); + console.log(` user: 1`); + console.log(` profile: ${profileRows.length}`); + console.log(` organizationmember: ${orgMemberRows.length}`); + console.log(` groupmember: ${groupMemberRows.length}`); + console.log(` cohort_member: ${cohortMemberRows.length}`); + + if (pendingInvites.length > 0) { + console.log(''); + console.warn( + `Note: ${pendingInvites.length} pending organization_invite row(s) still reference ${oldEmail} and will NOT be modified.` + ); + } + + if (!shouldExecute) { + console.log(''); + console.log('Dry run only. Re-run with --execute to apply updates.'); + return; + } + + await sql.begin(async (tx) => { + await tx` + UPDATE "user" SET email = ${newEmail}, updated_at = NOW() WHERE id = ${userId} + `; + await tx` + UPDATE profile SET email = ${newEmail}, updated_at = NOW() WHERE id = ${userId} + `; + await tx` + UPDATE organizationmember SET email = ${newEmail} + WHERE profile_id = ${userId} OR lower(email) = ${oldEmail} + `; + await tx` + UPDATE groupmember SET email = ${newEmail} + WHERE profile_id = ${userId} OR lower(email) = ${oldEmail} + `; + await tx` + UPDATE cohort_member SET email = ${newEmail} + WHERE profile_id = ${userId} OR lower(email) = ${oldEmail} + `; + }); + + console.log(''); + console.log(`Done. ${oldEmail} → ${newEmail} for user ${userId}.`); + console.log( + `email_verified was left as-is (${targetUser.email_verified}). The user logs in with the new email; their password is unchanged.` + ); + } catch (error) { + console.error('update-user-email error:', error); + process.exit(1); + } finally { + await sql.end(); + } +} + +main(); diff --git a/packages/ui/src/base/tooltip/tooltip-content.svelte b/packages/ui/src/base/tooltip/tooltip-content.svelte index e0402db3..f073402e 100644 --- a/packages/ui/src/base/tooltip/tooltip-content.svelte +++ b/packages/ui/src/base/tooltip/tooltip-content.svelte @@ -22,7 +22,7 @@ {sideOffset} {side} class={cn( - 'ui:bg-primary ui:text-primary-foreground ui:animate-in ui:fade-in-0 ui:zoom-in-95 ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=closed]:zoom-out-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:origin-(--bits-tooltip-content-transform-origin) ui:z-50 ui:w-fit ui:text-balance ui:rounded-md ui:px-3 ui:py-1.5 ui:text-xs', + 'ui:bg-primary ui:text-primary-foreground ui:animate-in ui:fade-in-0 ui:zoom-in-95 ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=closed]:zoom-out-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:origin-(--bits-tooltip-content-transform-origin) ui:z-menu-elevated ui:w-fit ui:text-balance ui:rounded-md ui:px-3 ui:py-1.5 ui:text-xs', className )} {...restProps} @@ -32,7 +32,7 @@ {#snippet child({ props })}