diff --git a/AGENTS.md b/AGENTS.md index 336043e4..eb8301b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,6 +423,29 @@ Use `.server.ts` files for server-side code to isolate API keys. - **Icon-only buttons** (a `Button` whose content is just an icon, e.g. `size="icon"`) must use `variant="secondary"`. - **Theme color classes:** Classes that use colors from `packages/ui/src/index.css` (e.g. `text-muted-foreground`, `text-primary`) must be prefixed with `ui:` in dashboard code so they resolve against the UI theme (e.g. `ui:text-muted-foreground`, `ui:text-primary`). Only color-related utilities need the prefix; layout/sizing classes like `rounded`, `border`, `p-4` stay unprefixed (Tailwind defaults). +### Page layout and settings save bar + +Use `@cio/ui/base/page` for dashboard page shells. See `packages/ui/README.md` § Page layout for the full component list. + +**Every settings page with save/discard** must use `Page.SettingsActions` as the last child of `Page.Root` (after `Page.Body`). Do not put Save in `Page.Header` or inline at the bottom of form sections. + +- Sticky at the viewport bottom while scrolling; docks naturally at the end of the page content +- Save and Discard are disabled when `hasChanges` is false +- Labels come from dashboard translations: `common.unsaved_changes.label`, `common.discard`, `common.save_changes` +- Pair with `UnsavedChanges` for navigation guards where appropriate + +```svelte + +``` + ## Emails: system vs org-branded Every transactional email in `packages/email/src/emails` is one of two kinds — decide deliberately, because it changes the branding, the schema, and the `from` address. diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte index 337732ad..07356657 100644 --- a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/course-settings.svelte @@ -1,15 +1,13 @@ - + {$t('aiTutor.page.course.title')} {$t('aiTutor.page.course.description')} - - - @@ -84,4 +109,15 @@ {/if} {/snippet} + + diff --git a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte index a0d345e9..2b4407df 100644 --- a/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte +++ b/apps/dashboard/src/lib/features/ai-tutor-settings/pages/org-settings.svelte @@ -1,39 +1,55 @@ - + {$t('aiTutor.page.org.title')} {$t('aiTutor.page.org.description')} - - - @@ -45,4 +61,15 @@ {/if} {/snippet} + + diff --git a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-edit.svelte b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-edit.svelte index c05a1ddd..3c0a5180 100644 --- a/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-edit.svelte +++ b/apps/dashboard/src/lib/features/course/components/ceritficate/certificate-edit.svelte @@ -9,11 +9,10 @@ type Props = { errors: Record; + activeTab?: string; }; - let { errors }: Props = $props(); - - let activeTab = $state('design'); + let { errors, activeTab = $bindable('design') }: Props = $props(); {$t('upgrade.certificate')} diff --git a/apps/dashboard/src/lib/features/course/pages/certificates.svelte b/apps/dashboard/src/lib/features/course/pages/certificates.svelte index 2173d327..fc85dd51 100644 --- a/apps/dashboard/src/lib/features/course/pages/certificates.svelte +++ b/apps/dashboard/src/lib/features/course/pages/certificates.svelte @@ -6,15 +6,16 @@ type Props = { errors: Record; + activeTab?: string; }; - let { errors }: Props = $props(); + let { errors, activeTab = $bindable('design') }: Props = $props(); - + diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index fba37548..f9b83bdc 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -262,6 +262,16 @@ }); } + export function handleDiscard() { + if (!courseApi.course) return; + + setDefault(courseApi.course); + selectedTagIds = [...initialTagIds]; + avatar = undefined; + errors = { title: undefined, description: undefined }; + hasUnsavedChanges = false; + } + function sanitizeCalloutForSave(value: typeof $settings.callout) { if (!value) return null; diff --git a/apps/dashboard/src/lib/features/settings/components/auth-general.svelte b/apps/dashboard/src/lib/features/settings/components/auth-general.svelte index 5558ccd4..c4a5903f 100644 --- a/apps/dashboard/src/lib/features/settings/components/auth-general.svelte +++ b/apps/dashboard/src/lib/features/settings/components/auth-general.svelte @@ -4,12 +4,17 @@ import { snackbar } from '$features/ui/snackbar/store'; import * as Field from '@cio/ui/base/field'; import { Switch } from '@cio/ui/base/switch'; - import { Button } from '@cio/ui/base/button'; import { Textarea } from '@cio/ui/base/textarea'; import LockIcon from '@lucide/svelte/icons/lock'; import { orgApi } from '$features/org/api/org.svelte'; import * as Alert from '@cio/ui/base/alert'; + interface Props { + hasUnsavedChanges?: boolean; + } + + let { hasUnsavedChanges = $bindable(false) }: Props = $props(); + // Auth settings state - synced with store let disableSignup = $state($currentOrg?.disableSignup ?? false); let disableSignupMessage = $state($currentOrg?.disableSignupMessage ?? ''); @@ -31,7 +36,22 @@ } }); - async function handleSave() { + let currentAllowPublicSignups = $derived(!$currentOrg?.settings?.signup?.inviteOnly); + + let hasChanges = $derived( + disableSignup !== ($currentOrg?.disableSignup ?? false) || + disableSignupMessage !== ($currentOrg?.disableSignupMessage ?? '') || + allowPublicSignups !== currentAllowPublicSignups || + disableEmailPassword !== ($currentOrg?.disableEmailPassword ?? false) || + disableGoogleAuth !== ($currentOrg?.disableGoogleAuth ?? false) || + internalEnrollmentOnly !== ($currentOrg?.settings?.internalEnrollmentOnly ?? false) + ); + + $effect(() => { + hasUnsavedChanges = hasChanges; + }); + + export async function handleSave() { if (!$currentOrg) return; isSaving = true; @@ -64,15 +84,17 @@ isSaving = false; } - let currentAllowPublicSignups = $derived(!$currentOrg?.settings?.signup?.inviteOnly); - let hasChanges = $derived( - disableSignup !== ($currentOrg?.disableSignup ?? false) || - disableSignupMessage !== ($currentOrg?.disableSignupMessage ?? '') || - allowPublicSignups !== currentAllowPublicSignups || - disableEmailPassword !== ($currentOrg?.disableEmailPassword ?? false) || - disableGoogleAuth !== ($currentOrg?.disableGoogleAuth ?? false) || - internalEnrollmentOnly !== ($currentOrg?.settings?.internalEnrollmentOnly ?? false) - ); + export function handleDiscard() { + if (!$currentOrg) return; + + disableSignup = $currentOrg.disableSignup ?? false; + disableSignupMessage = $currentOrg.disableSignupMessage ?? ''; + allowPublicSignups = !$currentOrg.settings?.signup?.inviteOnly; + disableEmailPassword = $currentOrg.disableEmailPassword ?? false; + disableGoogleAuth = $currentOrg.disableGoogleAuth ?? false; + internalEnrollmentOnly = $currentOrg.settings?.internalEnrollmentOnly ?? false; + hasUnsavedChanges = false; + } @@ -158,14 +180,6 @@ - - {#if hasChanges} -
- -
- {/if} diff --git a/apps/dashboard/src/lib/features/settings/pages/auth.svelte b/apps/dashboard/src/lib/features/settings/pages/auth.svelte index bf056782..24cd92d1 100644 --- a/apps/dashboard/src/lib/features/settings/pages/auth.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/auth.svelte @@ -2,6 +2,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import * as UnderlineTabs from '@cio/ui/custom/underline-tabs'; + import * as Page from '@cio/ui/base/page'; import { t } from '$lib/utils/functions/translations'; import { licenseApi } from '$features/license/api/license.svelte'; import { currentOrgPath } from '$lib/utils/store/org'; @@ -11,6 +12,10 @@ import AuthSso from '../components/auth-sso.svelte'; import AuthTokenAuth from '../components/auth-token-auth.svelte'; + let authGeneralComponent: AuthGeneral | null = $state(null); + let hasUnsavedChanges = $state(false); + let isSaving = $state(false); + const ssoLicensed = $derived(licenseApi.hasAccess('sso')); const tokenAuthLicensed = $derived(licenseApi.hasAccess('token-auth')); const licenseResolved = $derived(licenseApi.isResolved); @@ -55,6 +60,19 @@ break; } } + + async function handleSave() { + isSaving = true; + try { + await authGeneralComponent?.handleSave(); + } finally { + isSaving = false; + } + } + + function handleDiscard() { + authGeneralComponent?.handleDiscard(); + } {$t('upgrade.enterprise_required')} @@ -77,7 +95,7 @@ - + {#if ssoLicensed} @@ -92,3 +110,16 @@ {/if} + +{#if currentTab === 'general'} + +{/if} diff --git a/apps/dashboard/src/lib/features/settings/pages/customize-lms.svelte b/apps/dashboard/src/lib/features/settings/pages/customize-lms.svelte index 117a1356..42301478 100644 --- a/apps/dashboard/src/lib/features/settings/pages/customize-lms.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/customize-lms.svelte @@ -11,7 +11,14 @@ import { UploadWidget } from '$features/ui'; import * as Field from '@cio/ui/base/field'; + interface Props { + hasUnsavedChanges?: boolean; + } + + let { hasUnsavedChanges = $bindable(false) }: Props = $props(); + let widgetKey = $state(''); + let savedCustomizationSnapshot = $state(''); function widgetControl(key: string) { widgetKey = key; @@ -31,10 +38,40 @@ $currentOrg.customization.auth.backgroundImage = ''; } + function captureCustomizationSnapshot() { + savedCustomizationSnapshot = JSON.stringify($currentOrg.customization); + } + + $effect(() => { + if (!$currentOrg?.id) return; + + if (!savedCustomizationSnapshot) { + captureCustomizationSnapshot(); + } + }); + + $effect(() => { + if (!$currentOrg?.id || !savedCustomizationSnapshot) return; + + hasUnsavedChanges = JSON.stringify($currentOrg.customization) !== savedCustomizationSnapshot; + }); + export async function handleSave() { await orgApi.update($currentOrg.id, { customization: $currentOrg.customization }); + + if (orgApi.success) { + captureCustomizationSnapshot(); + hasUnsavedChanges = false; + } + } + + export function handleDiscard() { + if (!savedCustomizationSnapshot) return; + + $currentOrg.customization = JSON.parse(savedCustomizationSnapshot); + hasUnsavedChanges = false; } diff --git a/apps/dashboard/src/lib/features/settings/pages/notifications.svelte b/apps/dashboard/src/lib/features/settings/pages/notifications.svelte index 2c1c38ae..0b27adfc 100644 --- a/apps/dashboard/src/lib/features/settings/pages/notifications.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/notifications.svelte @@ -14,7 +14,6 @@ import { orgApi } from '$features/org/api/org.svelte'; import * as Field from '@cio/ui/base/field'; import { Switch } from '@cio/ui/base/switch'; - import { Button } from '@cio/ui/base/button'; import BellIcon from '@lucide/svelte/icons/bell'; import Building2Icon from '@lucide/svelte/icons/building-2'; import { onMount } from 'svelte'; @@ -32,6 +31,12 @@ let isLoadingPersonal = $state(false); let hasHydratedOrgToggles = $state(false); + interface Props { + hasUnsavedChanges?: boolean; + } + + let { hasUnsavedChanges = $bindable(false) }: Props = $props(); + const tutorOnlyToggleKeys = new Set(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS); const personalNotificationSections = $derived( @@ -125,6 +130,26 @@ ) ); + $effect(() => { + hasUnsavedChanges = personalHasChanges || orgHasChanges; + }); + + export async function handleSaveAll() { + if (personalHasChanges) { + await handleSavePersonal(); + } + + if (orgHasChanges) { + await handleSaveOrg(); + } + } + + export function handleDiscard() { + applyPersonalToggleState(savedPersonalToggles); + syncOrgTogglesFromCurrentOrg(); + hasUnsavedChanges = false; + } + function toStoredPreferences(keys: readonly T[], toggles: Record) { const preferences: Partial> = {}; @@ -216,17 +241,6 @@ {/each} - -
- -
{#if $isOrgAdmin} @@ -269,17 +283,6 @@ {/each} - -
- -
{/if}
diff --git a/apps/dashboard/src/lib/features/settings/pages/org.svelte b/apps/dashboard/src/lib/features/settings/pages/org.svelte index 61d414bd..c23d64cf 100644 --- a/apps/dashboard/src/lib/features/settings/pages/org.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/org.svelte @@ -12,8 +12,15 @@ import { UploadImage, UnsavedChanges } from '$features/ui'; import * as Field from '@cio/ui/base/field'; + interface Props { + hasUnsavedChanges?: boolean; + } + + let { hasUnsavedChanges = $bindable(false) }: Props = $props(); + let avatar = $state(); - let hasUnsavedChanges = $state(false); + let savedName = $state(''); + let capturedOrgId = $state(null); const themes = { rose: 'rose', @@ -57,9 +64,30 @@ if (orgApi.success) { hasUnsavedChanges = false; avatar = undefined; + savedName = $currentOrg.name; } } + function captureSavedFields() { + savedName = $currentOrg.name; + } + + $effect(() => { + if (!$currentOrg?.id) return; + + if (capturedOrgId !== $currentOrg.id) { + capturedOrgId = $currentOrg.id; + captureSavedFields(); + } + }); + + export function handleDiscard() { + $currentOrg.name = savedName; + avatar = undefined; + hasUnsavedChanges = false; + orgApi.errors = {}; + } + let isCustomTheme = $derived($currentOrg?.theme?.includes('#')); let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined); @@ -141,7 +169,7 @@
(); let hasLangChanged = $state(false); let locale = $derived($profile.locale || undefined); - let hasUnsavedChanges = $state(false); + let savedFullname = $state(''); + let savedUsername = $state(''); + let savedLocale = $state(undefined); + let capturedProfileId = $state(null); let email = $derived($profile.email || ''); let isChangingEmail = $state(false); let emailChangeInitiated = $state(false); @@ -34,6 +43,7 @@ if (profileApi.success) { hasUnsavedChanges = false; avatar = undefined; + captureSavedFields(); } } @@ -58,6 +68,31 @@ } const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app'); + + function captureSavedFields() { + savedFullname = $profile.fullname; + savedUsername = $profile.username; + savedLocale = $profile.locale || undefined; + } + + $effect(() => { + if (!$profile.id) return; + + if (capturedProfileId !== $profile.id) { + capturedProfileId = $profile.id; + captureSavedFields(); + } + }); + + export function handleDiscard() { + $profile.fullname = savedFullname; + $profile.username = savedUsername; + $profile.locale = savedLocale; + avatar = undefined; + hasLangChanged = false; + hasUnsavedChanges = false; + profileApi.errors = {}; + } diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index d264cd64..fb08f39a 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -2,8 +2,11 @@ "common": { "refresh": "Opdater", "unsaved_changes": { - "message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?" - } + "message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?", + "label": "Ikke-gemte ændringer" + }, + "discard": "Kassér", + "save_changes": "Gem ændringer" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index b154b3d0..8e99ce55 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -2,8 +2,11 @@ "common": { "refresh": "Aktualisieren", "unsaved_changes": { - "message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?" - } + "message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?", + "label": "Nicht gespeicherte Änderungen" + }, + "discard": "Verwerfen", + "save_changes": "Änderungen speichern" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 73e1e574..52f3dec0 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -289,8 +289,11 @@ }, "common": { "unsaved_changes": { - "message": "You have unsaved changes. Are you sure you want to leave?" + "message": "You have unsaved changes. Are you sure you want to leave?", + "label": "Unsaved changes" }, + "discard": "Discard", + "save_changes": "Save changes", "refresh": "Refresh" }, "compliance": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 3918483f..05245e7d 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -2,8 +2,11 @@ "common": { "refresh": "Actualizar", "unsaved_changes": { - "message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?" - } + "message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?", + "label": "Cambios sin guardar" + }, + "discard": "Descartar", + "save_changes": "Guardar cambios" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 9ac1280c..37f775b0 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -2,8 +2,11 @@ "common": { "refresh": "Actualiser", "unsaved_changes": { - "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?" - } + "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?", + "label": "Modifications non enregistrées" + }, + "discard": "Abandonner les modifications", + "save_changes": "Enregistrer les modifications" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 68630aec..165c2316 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -2,8 +2,11 @@ "common": { "refresh": "रीफ़्रेश करें", "unsaved_changes": { - "message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?" - } + "message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?", + "label": "सहेजे नहीं गए परिवर्तन" + }, + "discard": "त्यागें", + "save_changes": "परिवर्तन सहेजें" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 7bc31618..ee644b9d 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -2,8 +2,11 @@ "common": { "refresh": "Odśwież", "unsaved_changes": { - "message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?" - } + "message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?", + "label": "Niezapisane zmiany" + }, + "discard": "Odrzuć", + "save_changes": "Zapisz zmiany" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index de3b54dc..583e131f 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -2,8 +2,11 @@ "common": { "refresh": "Atualizar", "unsaved_changes": { - "message": "Você tem alterações não salvas. Tem certeza de que deseja sair?" - } + "message": "Você tem alterações não salvas. Tem certeza de que deseja sair?", + "label": "Alterações não salvas" + }, + "discard": "Descartar", + "save_changes": "Salvar alterações" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index f3903a9e..18fb1c0f 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -2,8 +2,11 @@ "common": { "refresh": "Обновить", "unsaved_changes": { - "message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?" - } + "message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?", + "label": "Несохраненные изменения" + }, + "discard": "Отменить", + "save_changes": "Сохранить изменения" }, "validations": { "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 93634665..28b92d14 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -2,8 +2,11 @@ "common": { "refresh": "Làm mới", "unsaved_changes": { - "message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?" - } + "message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?", + "label": "Thay đổi chưa lưu" + }, + "discard": "Hủy", + "save_changes": "Lưu thay đổi" }, "validations": { "generic": { diff --git a/apps/dashboard/src/routes/(app)/cohorts/[id]/settings/+page.svelte b/apps/dashboard/src/routes/(app)/cohorts/[id]/settings/+page.svelte index 00f6e7b5..0e1ae975 100644 --- a/apps/dashboard/src/routes/(app)/cohorts/[id]/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/cohorts/[id]/settings/+page.svelte @@ -1,11 +1,11 @@ - + {$t('cohorts.sidebar.settings') || 'Settings'} - - - {#snippet child()} @@ -149,4 +153,14 @@
{/snippet} + diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/certificates/+page.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/certificates/+page.svelte index 53959482..0bca3c10 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/certificates/+page.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/certificates/+page.svelte @@ -7,8 +7,6 @@ import { parseCertificateThemeId } from '$features/course/utils/certificate-utils'; import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org'; import { profile } from '$lib/utils/store/user'; - import { Button } from '@cio/ui/base/button'; - import ZapIcon from '@lucide/svelte/icons/zap'; import { RefreshPageData, UnsavedChanges } from '$features/ui'; import { ZCourseUpdate } from '@cio/utils/validation/course/course'; import { validateWithTranslation } from '$lib/utils/validation'; @@ -16,6 +14,7 @@ import { openUpgradeModal } from '$lib/utils/functions/org'; let errors = $state>({}); + let certificateActiveTab = $state('design'); let hasUnsavedChanges = $state(false); let savedCertificateState = $state(null); let savedCertificateStateCourseId = $state(null); @@ -122,6 +121,19 @@ snackbar.success('snackbar.course_settings.success.saved'); } } + + function handleDiscard() { + if (!savedCertificateState || !courseApi.course) return; + + const snapshot = JSON.parse(savedCertificateState) as ReturnType; + + if (!snapshot) return; + + courseApi.course.description = snapshot.description; + courseApi.course.certificate = { ...snapshot.certificate }; + hasUnsavedChanges = false; + errors = {}; + } @@ -143,26 +155,24 @@ -
- {#if canEditCertificates} - - {/if} - -
+
{#snippet child()} - + {/snippet} + {#if canEditCertificates && certificateActiveTab === 'settings'} + + {/if} diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte index 72ffd36f..ac9c7b4e 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/settings/+page.svelte @@ -2,7 +2,6 @@ import { CourseSettingsPage } from '$features/course/pages'; import * as Page from '@cio/ui/base/page'; import { t } from '$lib/utils/functions/translations'; - import { Button } from '@cio/ui/base/button'; import { courseApi } from '$features/course/api'; import { profile } from '$lib/utils/store/user'; import { RefreshPageData } from '$features/ui'; @@ -20,22 +19,21 @@ isSaving = false; } } + + function handleDiscard() { + settingsComponent?.handleDiscard(); + } - + {$t('course.navItem.settings.heading')} -
- - courseApi.refreshCourse(data.courseId, $profile.id)} /> -
+ courseApi.refreshCourse(data.courseId, $profile.id)} />
@@ -43,4 +41,14 @@ {/snippet} +
diff --git a/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte b/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte index c15213ef..ba82135b 100644 --- a/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/lms/settings/+page.svelte @@ -1,11 +1,11 @@ Settings - ClassroomIO - + {$t('settings.profile.heading')} - - - {#snippet child()} - + {/snippet} + diff --git a/apps/dashboard/src/routes/(app)/lms/settings/notifications/+page.svelte b/apps/dashboard/src/routes/(app)/lms/settings/notifications/+page.svelte index e0c9a9e9..fbf7d187 100644 --- a/apps/dashboard/src/routes/(app)/lms/settings/notifications/+page.svelte +++ b/apps/dashboard/src/routes/(app)/lms/settings/notifications/+page.svelte @@ -2,6 +2,23 @@ import { NotificationsPage } from '$features/settings/pages'; import { t } from '$lib/utils/functions/translations'; import * as Page from '@cio/ui/base/page'; + + let notificationsComponent: NotificationsPage | null = $state(null); + let hasUnsavedChanges = $state(false); + let isSaving = $state(false); + + async function handleSave() { + isSaving = true; + try { + await notificationsComponent?.handleSaveAll(); + } finally { + isSaving = false; + } + } + + function handleDiscard() { + notificationsComponent?.handleDiscard(); + } @@ -11,11 +28,21 @@ {$t('settings.notifications.heading')} - {$t('settings.notifications.personal_page_subtitle')} + {$t('settings.notifications.page_subtitle')} {#snippet child()} - + {/snippet} + diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/settings/+layout.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/settings/+layout.svelte index 45d49937..833774ee 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/settings/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/settings/+layout.svelte @@ -1,13 +1,11 @@ Settings - ClassroomIO - + {$t('settings.profile.heading')} {$t('settings.profile.page_subtitle')} - - {#snippet child()} - + {/snippet} + diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/settings/customize-lms/+page.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/settings/customize-lms/+page.svelte index c37537e0..3af43448 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/settings/customize-lms/+page.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/settings/customize-lms/+page.svelte @@ -1,10 +1,11 @@ Customize LMS - ClassroomIO - + + diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/settings/domains/+page.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/settings/domains/+page.svelte index ae52876e..f5d4914f 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/settings/domains/+page.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/settings/domains/+page.svelte @@ -1,25 +1,5 @@ diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/settings/notifications/+page.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/settings/notifications/+page.svelte index b0d392e8..fbf7d187 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/settings/notifications/+page.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/settings/notifications/+page.svelte @@ -2,6 +2,23 @@ import { NotificationsPage } from '$features/settings/pages'; import { t } from '$lib/utils/functions/translations'; import * as Page from '@cio/ui/base/page'; + + let notificationsComponent: NotificationsPage | null = $state(null); + let hasUnsavedChanges = $state(false); + let isSaving = $state(false); + + async function handleSave() { + isSaving = true; + try { + await notificationsComponent?.handleSaveAll(); + } finally { + isSaving = false; + } + } + + function handleDiscard() { + notificationsComponent?.handleDiscard(); + } @@ -16,6 +33,16 @@ {#snippet child()} - + {/snippet} + diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/settings/org/+page.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/settings/org/+page.svelte index 67ad8885..047b3e27 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/settings/org/+page.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/settings/org/+page.svelte @@ -1,10 +1,11 @@ Organization Settings - ClassroomIO - + + diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/settings/teams/+page.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/settings/teams/+page.svelte index ded04f55..10f5d796 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/settings/teams/+page.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/settings/teams/+page.svelte @@ -1,25 +1,5 @@ diff --git a/packages/storybook/src/molecules/page/page.stories.svelte b/packages/storybook/src/molecules/page/page.stories.svelte index 24e9aa55..e471375f 100644 --- a/packages/storybook/src/molecules/page/page.stories.svelte +++ b/packages/storybook/src/molecules/page/page.stories.svelte @@ -385,3 +385,60 @@ {/snippet} + + + {#snippet template()} + + + + Course settings + Update publishing, enrollment, and visibility options. + + + + {#snippet child()} +
+ {#each Array.from({ length: 8 }) as _, index (index)} +
+ Settings section {index + 1} +
+ {/each} +
+ {/snippet} +
+ undefined} + onDiscard={() => undefined} + /> +
+ {/snippet} +
+ + + {#snippet template()} + + + + Course settings + + + + {#snippet child()} +
All changes saved.
+ {/snippet} +
+ undefined} + onDiscard={() => undefined} + /> +
+ {/snippet} +
diff --git a/packages/ui/README.md b/packages/ui/README.md index e6927585..554dca0a 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -122,6 +122,66 @@ Reusable Svelte hooks are located in the `src/hooks/` directory. These are Svelt Utility functions and helpers are located in `src/tools/`. The main utility is the `cn` function for class name merging. +### Page layout (`src/base/page/`) + +Composable page shell used across dashboard list and settings screens. Import as `import * as Page from '@cio/ui/base/page'`. + +| Export | Purpose | +| ------------------------------ | ------------------------------------------------------------ | +| `Page.Root` | Flex column wrapper with minimum viewport height | +| `Page.Header` | Title row; pass `isSticky` to pin the header while scrolling | +| `Page.HeaderContent` | Title + subtitle column | +| `Page.Title` / `Page.Subtitle` | Page heading and description | +| `Page.Action` | Right-aligned header actions | +| `Page.Body` | Main content area (`child` snippet) | +| `Page.BodyHeader` | Toolbar inside the body | +| `Page.SettingsActions` | Save/discard bar for settings forms (floats, then docks) | + +**Settings pages:** Place `Page.SettingsActions` as the last child inside `Page.Root`, after `Page.Body`. The bar uses `position: sticky; bottom: 0` so it stays pinned to the viewport bottom while you scroll, then settles into normal flow at the end of the page. Do not put `overflow` on `Page.Root` that would break sticky positioning (horizontal overflow on `Page.Body` is fine). Disable Save and Discard via `hasChanges={false}` when the form is clean. Pass translated `statusLabel`, `discardLabel`, and `saveLabel` props from the dashboard. Use `disabled` to block Save only (Discard still follows `hasChanges`). Use `contentClass` for extra classes on the inner card when needed. + +**`Page.Root` can come from a layout.** "Last child of `Page.Root`" is a runtime relationship, not a same-file one. Where a `+layout.svelte` owns the `Page.Root` and renders pages into it via `{@render children?.()}`, the page file itself contains only `Page.Header`, `Page.Body`, and `Page.SettingsActions` — that is correct and already satisfies the rule. Do **not** add a second `Page.Root` in the page: nesting them stacks two `min-h-[calc(100vh-48px)]` containers, drops the layout's width constraints, and shortens the sticky bar's containing block so it stops floating. Pages that rely on a layout-owned `Page.Root`: + +- `routes/(app)/org/[slug]/settings/+page.svelte` and `settings/org/+page.svelte`, `settings/customize-lms/+page.svelte`, `settings/notifications/+page.svelte` → `routes/(app)/org/[slug]/settings/+layout.svelte` +- `routes/(app)/lms/settings/+page.svelte`, `routes/(app)/lms/settings/notifications/+page.svelte` → `routes/(app)/lms/settings/+layout.svelte` + +**Never render `Page.SettingsActions` from inside `Page.Body`.** That includes rendering it from a feature component that a route passes into `Page.Body`'s `child` snippet. `Page.Body` sets `overflow-x-hidden`, and CSS computes `overflow-y` to `auto` whenever the other axis is not `visible` — so `Page.Body` is a scroll container. It is sized by its content and never scrolls internally, so a sticky bar inside it has no travel and renders flat at the end of the content. Keep the bar a sibling of `Page.Body` and lift the dirty/saving state up to the route with `bind:this` and `bind:hasUnsavedChanges`, the way `routes/(app)/courses/[id]/settings/+page.svelte` does. + +The bar also sits at `z-50`, matching the editor root so it paints above editor content (later in DOM wins the tie) while staying below body-portaled dialogs and sheets. Its shadow appears only while it is pinned away from its flow position, tracked by an `IntersectionObserver` on a 1px sentinel rendered as its next sibling. + +| Prop | Description | +| -------------- | -------------------------------------------------------- | +| `hasChanges` | Enables Save and Discard when the form is dirty | +| `loading` | Shows loading state on Save | +| `disabled` | Disables Save only (e.g. while not initialized) | +| `contentClass` | Extra classes on the inner card (width, padding, etc.) | +| `statusLabel` | Left-side status text (e.g. "Unsaved changes") | +| `discardLabel` | Discard button label | +| `saveLabel` | Save button label | +| `onSave` | Save handler | +| `onDiscard` | Discard handler | + +```svelte + + ... + + {#snippet child()} + + {/snippet} + + + +``` + +See `Molecules/Page` → **Settings Actions** in Storybook. + ## Importing `cn` The `cn` utility function is used throughout the codebase for merging Tailwind CSS classes. It combines `clsx` and `tailwind-merge` to handle class conflicts intelligently. diff --git a/packages/ui/src/base/page/index.ts b/packages/ui/src/base/page/index.ts index a97331c4..61d058bf 100644 --- a/packages/ui/src/base/page/index.ts +++ b/packages/ui/src/base/page/index.ts @@ -4,6 +4,7 @@ import BodyHeader from './page-body-header.svelte'; import Header from './page-header.svelte'; import HeaderContent from './page-header-content.svelte'; import Root from './page.svelte'; +import SettingsActions from './page-settings-actions.svelte'; import Subtitle from './page-subtitle.svelte'; import Title from './page-title.svelte'; @@ -16,6 +17,7 @@ export { Subtitle, Body, BodyHeader, + SettingsActions, // Root as Page, Header as PageHeader, @@ -24,5 +26,6 @@ export { Title as PageTitle, Subtitle as PageSubtitle, Body as PageBody, - BodyHeader as PageBodyHeader + BodyHeader as PageBodyHeader, + SettingsActions as PageSettingsActions }; diff --git a/packages/ui/src/base/page/page-body.svelte b/packages/ui/src/base/page/page-body.svelte index dc29e860..d2d8f98a 100644 --- a/packages/ui/src/base/page/page-body.svelte +++ b/packages/ui/src/base/page/page-body.svelte @@ -16,7 +16,7 @@
{#if child} diff --git a/packages/ui/src/base/page/page-settings-actions.svelte b/packages/ui/src/base/page/page-settings-actions.svelte new file mode 100644 index 00000000..2d10ea3d --- /dev/null +++ b/packages/ui/src/base/page/page-settings-actions.svelte @@ -0,0 +1,77 @@ + + +
+
+
+

{statusLabel}

+ +
+ + +
+
+
+ + +
diff --git a/packages/ui/src/base/page/page.svelte b/packages/ui/src/base/page/page.svelte index 8c92a310..e0945509 100644 --- a/packages/ui/src/base/page/page.svelte +++ b/packages/ui/src/base/page/page.svelte @@ -13,10 +13,7 @@
{@render children?.()}