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:
Rotimi Best
2026-07-15 22:28:40 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 656fa4fa7e
commit 3980aa0a9b
23 changed files with 580 additions and 98 deletions
@@ -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();