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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * fix(dashboard): always show top separator on lesson material actions Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): reduce lesson material action separator margin to my-2 Co-authored-by: Rotimi Best <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
656fa4fa7e
commit
3980aa0a9b
@@ -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",
|
||||
|
||||
@@ -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 [email protected] --new [email protected] # dry run (default)
|
||||
* pnpm db:update-user-email -- --old [email protected] --new [email protected] --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 [email protected] --new [email protected] [--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();
|
||||
@@ -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 })}
|
||||
<div
|
||||
class={cn(
|
||||
'ui:bg-primary ui:z-50 ui:size-2.5 ui:rotate-45 ui:rounded-[2px]',
|
||||
'ui:bg-primary ui:z-menu-elevated ui:size-2.5 ui:rotate-45 ui:rounded-[2px]',
|
||||
'ui:data-[side=top]:translate-x-1/2 ui:data-[side=top]:translate-y-[calc(-50%+2px)]',
|
||||
'ui:data-[side=bottom]:-translate-x-1/2 ui:data-[side=bottom]:-translate-y-[calc(-50%+1px)]',
|
||||
'ui:data-[side=right]:translate-x-[calc(50%+2px)] ui:data-[side=right]:translate-y-1/2',
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
* z-menu-elevated (250) Dropdown/select/popover content that must escape a z-app-bar,
|
||||
* z-app-bar-elevated, or z-modal ancestor.
|
||||
*
|
||||
* Anything not listed (tooltips, dropdowns, sheets, drawers) uses Tailwind's default `z-50`,
|
||||
* which already sits above normal page content.
|
||||
* Anything not listed (dropdowns, sheets, drawers) uses Tailwind's default `z-50`,
|
||||
* which already sits above normal page content. Tooltips use `z-menu-elevated` so they
|
||||
* remain visible over `z-app-bar` chrome such as the transcript side panel.
|
||||
*/
|
||||
@utility z-app-bar {
|
||||
z-index: 100;
|
||||
|
||||
Reference in New Issue
Block a user