feat(ui): Page.SettingsActions for settings save bar (#819)
* feat(ui): add Page.SettingsActions for settings save bar Introduce a sticky bottom save/discard bar on Page and migrate settings pages to use it instead of header or inline save buttons. Includes profile, org, customize-lms, notifications, auth, AI tutor, course settings, certificates, and cohort settings. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): always show Page.SettingsActions, disable when clean Keep the settings save bar visible at all times and disable Save and Discard when hasChanges is false instead of hiding the bar entirely. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): add bottom spacing and shadow to Page.SettingsActions Wrap the actions card in a sticky container with bottom padding so it does not sit flush against the viewport edge, and use shadow-md on the card for clearer elevation. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): stop viewport-sticking Page.SettingsActions on scroll Remove sticky bottom positioning so the save bar stays at the end of the page content and scrolls away when viewing upper sections. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): use sticky bottom for Page.SettingsActions dock behavior Replace the IntersectionObserver fixed/dock toggle that caused layout thrashing and flipping with native position: sticky; bottom: 0. Move overflow-x-hidden from Page.Root to Page.Body so sticky is not broken. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): keep Page.SettingsActions sticky and above editor content Page.Root carried md:overflow-x-auto, which makes overflow-y compute to auto, turning the root into the sticky bar's nearest scrolling ancestor. Since the root sizes to its content it never scrolls internally, so the bar rendered at its natural flow position instead of pinning to the viewport. Drop the overflow so sticky resolves against the viewport; horizontal containment stays on Page.Body. Raise the bar to z-50 so it is not painted over by the editor root, which is relative z-50. Matching rather than exceeding 50 keeps body-portaled dialogs and sheets above the bar. Show the shadow only while the bar is pinned away from its flow position, tracked with an IntersectionObserver on a 1px sentinel sibling. Unlike the previous observer this toggles a box-shadow only, so it cannot feed back into layout. * fix(ui): tighten settings actions spacing and tab visibility - Remove mt-6 and lower z-index so popovers sit above the bar - Wrap sentinel in a single root to avoid extra Page.Root flex gap - Remove Page.Body mb-4 redundant with settings actions spacing - Show certificate save bar only on the settings tab - Raise theme color picker z-index via --picker-z-index Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): address PR review feedback for settings actions - Capture profile/org saved baselines only on initial load, not on edits - Reset course settings errors with explicit field shape in handleDiscard - Restore VisitOrgSiteButton on org profile settings header - Derive AI tutor dirty state from stores directly instead of mirrored state - Use discard-specific French label and document SettingsActions props Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs(ui): clarify Page.Root ownership for Page.SettingsActions "Last child of Page.Root" is a runtime relationship. Several settings pages get their Page.Root from a +layout.svelte, so the page file holds only Page.Header, Page.Body, and Page.SettingsActions. Document that this is correct and list the pages, since adding a nested Page.Root stacks two min-height containers and shortens the sticky bar's containing block. Also document that Page.SettingsActions must never be rendered from inside Page.Body: overflow-x-hidden makes Page.Body a scroll container that never scrolls, so a sticky bar in it has no travel. ---------
This commit is contained in:
@@ -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"`.
|
- **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).
|
- **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
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
## Emails: system vs org-branded
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
|
|
||||||
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||||
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import { Label } from '@cio/ui/base/label';
|
import { Label } from '@cio/ui/base/label';
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
||||||
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
||||||
@@ -23,6 +21,22 @@
|
|||||||
const formStore = writable<AiTutorSettings>({ ...defaultAiTutorSettings });
|
const formStore = writable<AiTutorSettings>({ ...defaultAiTutorSettings });
|
||||||
let inheritFromOrg = $state(true);
|
let inheritFromOrg = $state(true);
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
|
let savedStateSnapshot = $state('');
|
||||||
|
|
||||||
|
const currentStateSnapshot = $derived(
|
||||||
|
JSON.stringify({
|
||||||
|
inheritFromOrg,
|
||||||
|
settings: $formStore
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUnsavedChanges = $derived(
|
||||||
|
initialized && savedStateSnapshot !== '' && currentStateSnapshot !== savedStateSnapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
function captureSavedSnapshot() {
|
||||||
|
savedStateSnapshot = currentStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await aiTutorApi.fetchCourseSettings(courseId);
|
await aiTutorApi.fetchCourseSettings(courseId);
|
||||||
@@ -33,31 +47,42 @@
|
|||||||
inheritFromOrg = view.override == null || view.override.inheritFromOrg !== false;
|
inheritFromOrg = view.override == null || view.override.inheritFromOrg !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureSavedSnapshot();
|
||||||
initialized = true;
|
initialized = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (inheritFromOrg) {
|
if (inheritFromOrg) {
|
||||||
await aiTutorApi.updateCourseSettings(courseId, { inheritFromOrg: true });
|
await aiTutorApi.updateCourseSettings(courseId, { inheritFromOrg: true });
|
||||||
return;
|
} else {
|
||||||
|
const data = get(formStore);
|
||||||
|
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = $formStore;
|
if (aiTutorApi.courseSettings) {
|
||||||
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
|
captureSavedSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
if (!savedStateSnapshot) return;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(savedStateSnapshot) as {
|
||||||
|
inheritFromOrg: boolean;
|
||||||
|
settings: AiTutorSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
inheritFromOrg = parsed.inheritFromOrg;
|
||||||
|
formStore.set(parsed.settings);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||||
<Page.Header isSticky class="ui:bg-background z-10">
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('aiTutor.page.course.title')}</Page.Title>
|
<Page.Title>{$t('aiTutor.page.course.title')}</Page.Title>
|
||||||
<Page.Subtitle>{$t('aiTutor.page.course.description')}</Page.Subtitle>
|
<Page.Subtitle>{$t('aiTutor.page.course.description')}</Page.Subtitle>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
|
|
||||||
{$t('aiTutor.action.save')}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
@@ -84,4 +109,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={aiTutorApi.saving}
|
||||||
|
disabled={!initialized || aiTutorApi.saving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
@@ -1,39 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
||||||
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
|
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
|
||||||
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
||||||
|
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
|
let savedSettingsSnapshot = $state('');
|
||||||
|
|
||||||
|
const currentSettingsSnapshot = $derived(JSON.stringify($orgTutorSettingsStore));
|
||||||
|
|
||||||
|
const hasUnsavedChanges = $derived(
|
||||||
|
initialized && savedSettingsSnapshot !== '' && currentSettingsSnapshot !== savedSettingsSnapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
function captureSavedSnapshot() {
|
||||||
|
savedSettingsSnapshot = currentSettingsSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await aiTutorApi.fetchOrgSettings();
|
await aiTutorApi.fetchOrgSettings();
|
||||||
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
|
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
|
||||||
|
captureSavedSnapshot();
|
||||||
initialized = true;
|
initialized = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
await aiTutorApi.updateOrgSettings($orgTutorSettingsStore);
|
await aiTutorApi.updateOrgSettings(get(orgTutorSettingsStore));
|
||||||
|
|
||||||
|
if (aiTutorApi.orgSettings) {
|
||||||
|
captureSavedSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
if (!savedSettingsSnapshot) return;
|
||||||
|
|
||||||
|
orgTutorSettingsStore.set(JSON.parse(savedSettingsSnapshot));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||||
<Page.Header isSticky class="ui:bg-background z-10">
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('aiTutor.page.org.title')}</Page.Title>
|
<Page.Title>{$t('aiTutor.page.org.title')}</Page.Title>
|
||||||
<Page.Subtitle>{$t('aiTutor.page.org.description')}</Page.Subtitle>
|
<Page.Subtitle>{$t('aiTutor.page.org.description')}</Page.Subtitle>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
|
|
||||||
{$t('aiTutor.action.save')}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
@@ -45,4 +61,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={aiTutorApi.saving}
|
||||||
|
disabled={!initialized || aiTutorApi.saving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
+2
-3
@@ -9,11 +9,10 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
|
activeTab?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { errors }: Props = $props();
|
let { errors, activeTab = $bindable('design') }: Props = $props();
|
||||||
|
|
||||||
let activeTab = $state('design');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
|
activeTab?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { errors }: Props = $props();
|
let { errors, activeTab = $bindable('design') }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IssueCertificateModal />
|
<IssueCertificateModal />
|
||||||
|
|
||||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||||
<CertificateEdit {errors} />
|
<CertificateEdit {errors} bind:activeTab />
|
||||||
</RoleBasedSecurity>
|
</RoleBasedSecurity>
|
||||||
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
|
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
|
||||||
<StudentCertificate />
|
<StudentCertificate />
|
||||||
|
|||||||
@@ -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) {
|
function sanitizeCalloutForSave(value: typeof $settings.callout) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,17 @@
|
|||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import { Textarea } from '@cio/ui/base/textarea';
|
import { Textarea } from '@cio/ui/base/textarea';
|
||||||
import LockIcon from '@lucide/svelte/icons/lock';
|
import LockIcon from '@lucide/svelte/icons/lock';
|
||||||
import { orgApi } from '$features/org/api/org.svelte';
|
import { orgApi } from '$features/org/api/org.svelte';
|
||||||
import * as Alert from '@cio/ui/base/alert';
|
import * as Alert from '@cio/ui/base/alert';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
// Auth settings state - synced with store
|
// Auth settings state - synced with store
|
||||||
let disableSignup = $state($currentOrg?.disableSignup ?? false);
|
let disableSignup = $state($currentOrg?.disableSignup ?? false);
|
||||||
let disableSignupMessage = $state($currentOrg?.disableSignupMessage ?? '');
|
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;
|
if (!$currentOrg) return;
|
||||||
|
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
@@ -64,15 +84,17 @@
|
|||||||
isSaving = false;
|
isSaving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentAllowPublicSignups = $derived(!$currentOrg?.settings?.signup?.inviteOnly);
|
export function handleDiscard() {
|
||||||
let hasChanges = $derived(
|
if (!$currentOrg) return;
|
||||||
disableSignup !== ($currentOrg?.disableSignup ?? false) ||
|
|
||||||
disableSignupMessage !== ($currentOrg?.disableSignupMessage ?? '') ||
|
disableSignup = $currentOrg.disableSignup ?? false;
|
||||||
allowPublicSignups !== currentAllowPublicSignups ||
|
disableSignupMessage = $currentOrg.disableSignupMessage ?? '';
|
||||||
disableEmailPassword !== ($currentOrg?.disableEmailPassword ?? false) ||
|
allowPublicSignups = !$currentOrg.settings?.signup?.inviteOnly;
|
||||||
disableGoogleAuth !== ($currentOrg?.disableGoogleAuth ?? false) ||
|
disableEmailPassword = $currentOrg.disableEmailPassword ?? false;
|
||||||
internalEnrollmentOnly !== ($currentOrg?.settings?.internalEnrollmentOnly ?? false)
|
disableGoogleAuth = $currentOrg.disableGoogleAuth ?? false;
|
||||||
);
|
internalEnrollmentOnly = $currentOrg.settings?.internalEnrollmentOnly ?? false;
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field.Group class="w-full max-w-2xl! px-2">
|
<Field.Group class="w-full max-w-2xl! px-2">
|
||||||
@@ -158,14 +180,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if hasChanges}
|
|
||||||
<div class="mt-6 flex justify-end">
|
|
||||||
<Button variant="default" onclick={handleSave} loading={isSaving} disabled={isSaving || !$isEnterprisePlan}>
|
|
||||||
{$t('settings.auth.general.save_button')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
|
|
||||||
<Field.Separator />
|
<Field.Separator />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
import { licenseApi } from '$features/license/api/license.svelte';
|
import { licenseApi } from '$features/license/api/license.svelte';
|
||||||
import { currentOrgPath } from '$lib/utils/store/org';
|
import { currentOrgPath } from '$lib/utils/store/org';
|
||||||
@@ -11,6 +12,10 @@
|
|||||||
import AuthSso from '../components/auth-sso.svelte';
|
import AuthSso from '../components/auth-sso.svelte';
|
||||||
import AuthTokenAuth from '../components/auth-token-auth.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 ssoLicensed = $derived(licenseApi.hasAccess('sso'));
|
||||||
const tokenAuthLicensed = $derived(licenseApi.hasAccess('token-auth'));
|
const tokenAuthLicensed = $derived(licenseApi.hasAccess('token-auth'));
|
||||||
const licenseResolved = $derived(licenseApi.isResolved);
|
const licenseResolved = $derived(licenseApi.isResolved);
|
||||||
@@ -55,6 +60,19 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
await authGeneralComponent?.handleSave();
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
authGeneralComponent?.handleDiscard();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
|
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
|
||||||
@@ -77,7 +95,7 @@
|
|||||||
</UnderlineTabs.List>
|
</UnderlineTabs.List>
|
||||||
|
|
||||||
<UnderlineTabs.Content value="general">
|
<UnderlineTabs.Content value="general">
|
||||||
<AuthGeneral />
|
<AuthGeneral bind:this={authGeneralComponent} bind:hasUnsavedChanges />
|
||||||
</UnderlineTabs.Content>
|
</UnderlineTabs.Content>
|
||||||
|
|
||||||
{#if ssoLicensed}
|
{#if ssoLicensed}
|
||||||
@@ -92,3 +110,16 @@
|
|||||||
</UnderlineTabs.Content>
|
</UnderlineTabs.Content>
|
||||||
{/if}
|
{/if}
|
||||||
</UnderlineTabs.Root>
|
</UnderlineTabs.Root>
|
||||||
|
|
||||||
|
{#if currentTab === 'general'}
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -11,7 +11,14 @@
|
|||||||
import { UploadWidget } from '$features/ui';
|
import { UploadWidget } from '$features/ui';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
let widgetKey = $state('');
|
let widgetKey = $state('');
|
||||||
|
let savedCustomizationSnapshot = $state('');
|
||||||
|
|
||||||
function widgetControl(key: string) {
|
function widgetControl(key: string) {
|
||||||
widgetKey = key;
|
widgetKey = key;
|
||||||
@@ -31,10 +38,40 @@
|
|||||||
$currentOrg.customization.auth.backgroundImage = '';
|
$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() {
|
export async function handleSave() {
|
||||||
await orgApi.update($currentOrg.id, {
|
await orgApi.update($currentOrg.id, {
|
||||||
customization: $currentOrg.customization
|
customization: $currentOrg.customization
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (orgApi.success) {
|
||||||
|
captureCustomizationSnapshot();
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDiscard() {
|
||||||
|
if (!savedCustomizationSnapshot) return;
|
||||||
|
|
||||||
|
$currentOrg.customization = JSON.parse(savedCustomizationSnapshot);
|
||||||
|
hasUnsavedChanges = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
import { orgApi } from '$features/org/api/org.svelte';
|
import { orgApi } from '$features/org/api/org.svelte';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import BellIcon from '@lucide/svelte/icons/bell';
|
import BellIcon from '@lucide/svelte/icons/bell';
|
||||||
import Building2Icon from '@lucide/svelte/icons/building-2';
|
import Building2Icon from '@lucide/svelte/icons/building-2';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -32,6 +31,12 @@
|
|||||||
let isLoadingPersonal = $state(false);
|
let isLoadingPersonal = $state(false);
|
||||||
let hasHydratedOrgToggles = $state(false);
|
let hasHydratedOrgToggles = $state(false);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
|
const tutorOnlyToggleKeys = new Set<string>(TUTOR_ONLY_PERSONAL_EMAIL_NOTIFICATION_TOGGLE_KEYS);
|
||||||
|
|
||||||
const personalNotificationSections = $derived(
|
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<T extends string>(keys: readonly T[], toggles: Record<T, boolean>) {
|
function toStoredPreferences<T extends string>(keys: readonly T[], toggles: Record<T, boolean>) {
|
||||||
const preferences: Partial<Record<T, boolean>> = {};
|
const preferences: Partial<Record<T, boolean>> = {};
|
||||||
|
|
||||||
@@ -216,17 +241,6 @@
|
|||||||
</Field.Set>
|
</Field.Set>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
loading={isSavingPersonal}
|
|
||||||
disabled={!personalHasChanges || isSavingPersonal || isLoadingPersonal || !$currentOrg?.id}
|
|
||||||
onclick={handleSavePersonal}
|
|
||||||
>
|
|
||||||
{$t('settings.notifications.personal.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
|
|
||||||
{#if $isOrgAdmin}
|
{#if $isOrgAdmin}
|
||||||
@@ -269,17 +283,6 @@
|
|||||||
</Field.Set>
|
</Field.Set>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
loading={isSavingOrg}
|
|
||||||
disabled={!orgHasChanges || isSavingOrg}
|
|
||||||
onclick={handleSaveOrg}
|
|
||||||
>
|
|
||||||
{$t('settings.notifications.org.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
{/if}
|
{/if}
|
||||||
</Field.Group>
|
</Field.Group>
|
||||||
|
|||||||
@@ -12,8 +12,15 @@
|
|||||||
import { UploadImage, UnsavedChanges } from '$features/ui';
|
import { UploadImage, UnsavedChanges } from '$features/ui';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
let avatar = $state<string | File | undefined>();
|
let avatar = $state<string | File | undefined>();
|
||||||
let hasUnsavedChanges = $state(false);
|
let savedName = $state('');
|
||||||
|
let capturedOrgId = $state<string | null>(null);
|
||||||
|
|
||||||
const themes = {
|
const themes = {
|
||||||
rose: 'rose',
|
rose: 'rose',
|
||||||
@@ -57,9 +64,30 @@
|
|||||||
if (orgApi.success) {
|
if (orgApi.success) {
|
||||||
hasUnsavedChanges = false;
|
hasUnsavedChanges = false;
|
||||||
avatar = undefined;
|
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 isCustomTheme = $derived($currentOrg?.theme?.includes('#'));
|
||||||
let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined);
|
let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined);
|
||||||
</script>
|
</script>
|
||||||
@@ -141,7 +169,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={isCustomTheme ? `border-color: ${$currentOrg.theme};` : ''}
|
style={isCustomTheme ? `border-color: ${$currentOrg.theme}; --picker-z-index: 50;` : '--picker-z-index: 50;'}
|
||||||
class="group relative h-auto w-fit cursor-pointer rounded-full border-2 {!isCustomTheme
|
class="group relative h-auto w-fit cursor-pointer rounded-full border-2 {!isCustomTheme
|
||||||
? 'custom-theme-picker--empty'
|
? 'custom-theme-picker--empty'
|
||||||
: ''}"
|
: ''}"
|
||||||
|
|||||||
@@ -11,10 +11,19 @@
|
|||||||
import { UploadImage, UnsavedChanges } from '$features/ui';
|
import { UploadImage, UnsavedChanges } from '$features/ui';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hasUnsavedChanges?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
let avatar = $state<string | File | undefined>();
|
let avatar = $state<string | File | undefined>();
|
||||||
let hasLangChanged = $state(false);
|
let hasLangChanged = $state(false);
|
||||||
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
|
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
|
||||||
let hasUnsavedChanges = $state(false);
|
let savedFullname = $state('');
|
||||||
|
let savedUsername = $state('');
|
||||||
|
let savedLocale = $state<TLocale | undefined>(undefined);
|
||||||
|
let capturedProfileId = $state<string | null>(null);
|
||||||
let email = $derived($profile.email || '');
|
let email = $derived($profile.email || '');
|
||||||
let isChangingEmail = $state(false);
|
let isChangingEmail = $state(false);
|
||||||
let emailChangeInitiated = $state(false);
|
let emailChangeInitiated = $state(false);
|
||||||
@@ -34,6 +43,7 @@
|
|||||||
if (profileApi.success) {
|
if (profileApi.success) {
|
||||||
hasUnsavedChanges = false;
|
hasUnsavedChanges = false;
|
||||||
avatar = undefined;
|
avatar = undefined;
|
||||||
|
captureSavedFields();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +68,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app');
|
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 = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UnsavedChanges bind:hasUnsavedChanges />
|
<UnsavedChanges bind:hasUnsavedChanges />
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Opdater",
|
"refresh": "Opdater",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Aktualisieren",
|
"refresh": "Aktualisieren",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -289,8 +289,11 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes": {
|
"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"
|
"refresh": "Refresh"
|
||||||
},
|
},
|
||||||
"compliance": {
|
"compliance": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Actualizar",
|
"refresh": "Actualizar",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Actualiser",
|
"refresh": "Actualiser",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "रीफ़्रेश करें",
|
"refresh": "रीफ़्रेश करें",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
|
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?",
|
||||||
}
|
"label": "सहेजे नहीं गए परिवर्तन"
|
||||||
|
},
|
||||||
|
"discard": "त्यागें",
|
||||||
|
"save_changes": "परिवर्तन सहेजें"
|
||||||
},
|
},
|
||||||
"validations": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Odśwież",
|
"refresh": "Odśwież",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Atualizar",
|
"refresh": "Atualizar",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Обновить",
|
"refresh": "Обновить",
|
||||||
"unsaved_changes": {
|
"unsaved_changes": {
|
||||||
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
|
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?",
|
||||||
}
|
"label": "Несохраненные изменения"
|
||||||
|
},
|
||||||
|
"discard": "Отменить",
|
||||||
|
"save_changes": "Сохранить изменения"
|
||||||
},
|
},
|
||||||
"validations": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"refresh": "Làm mới",
|
"refresh": "Làm mới",
|
||||||
"unsaved_changes": {
|
"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": {
|
"validations": {
|
||||||
"generic": {
|
"generic": {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import { Input } from '@cio/ui/base/input';
|
import { Input } from '@cio/ui/base/input';
|
||||||
import { Textarea } from '@cio/ui/base/textarea';
|
import { Textarea } from '@cio/ui/base/textarea';
|
||||||
import { Label } from '@cio/ui/base/label';
|
import { Label } from '@cio/ui/base/label';
|
||||||
import * as Select from '@cio/ui/base/select';
|
import * as Select from '@cio/ui/base/select';
|
||||||
import { Separator } from '@cio/ui/base/separator';
|
import { Separator } from '@cio/ui/base/separator';
|
||||||
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { cohortApi } from '$features/cohort/api';
|
import { cohortApi } from '$features/cohort/api';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -70,6 +70,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
const currentCohort = cohortApi.cohort;
|
||||||
|
if (!currentCohort) return;
|
||||||
|
|
||||||
|
name = currentCohort.name;
|
||||||
|
description = currentCohort.description ?? '';
|
||||||
|
status = (currentCohort.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
@@ -84,15 +93,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||||
<Page.Header class="sticky top-13 z-10 bg-white">
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('cohorts.sidebar.settings') || 'Settings'}</Page.Title>
|
<Page.Title>{$t('cohorts.sidebar.settings') || 'Settings'}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
|
||||||
{$t('cohorts.settings.save') || 'Save Changes'}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
@@ -149,4 +153,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
|
||||||
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
|
import { isFreePlan, isOrgAdmin } from '$lib/utils/store/org';
|
||||||
import { profile } from '$lib/utils/store/user';
|
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 { RefreshPageData, UnsavedChanges } from '$features/ui';
|
||||||
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
|
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
|
||||||
import { validateWithTranslation } from '$lib/utils/validation';
|
import { validateWithTranslation } from '$lib/utils/validation';
|
||||||
@@ -16,6 +14,7 @@
|
|||||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||||
|
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
let certificateActiveTab = $state('design');
|
||||||
let hasUnsavedChanges = $state(false);
|
let hasUnsavedChanges = $state(false);
|
||||||
let savedCertificateState = $state<string | null>(null);
|
let savedCertificateState = $state<string | null>(null);
|
||||||
let savedCertificateStateCourseId = $state<string | null>(null);
|
let savedCertificateStateCourseId = $state<string | null>(null);
|
||||||
@@ -122,6 +121,19 @@
|
|||||||
snackbar.success('snackbar.course_settings.success.saved');
|
snackbar.success('snackbar.course_settings.success.saved');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
if (!savedCertificateState || !courseApi.course) return;
|
||||||
|
|
||||||
|
const snapshot = JSON.parse(savedCertificateState) as ReturnType<typeof getCertificateFormState>;
|
||||||
|
|
||||||
|
if (!snapshot) return;
|
||||||
|
|
||||||
|
courseApi.course.description = snapshot.description;
|
||||||
|
courseApi.course.certificate = { ...snapshot.certificate };
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
errors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -143,26 +155,24 @@
|
|||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<div class="flex w-full justify-end gap-2">
|
<RefreshPageData />
|
||||||
{#if canEditCertificates}
|
|
||||||
<Button
|
|
||||||
onclick={saveCertificate}
|
|
||||||
loading={courseApi.isLoading}
|
|
||||||
disabled={courseApi.isLoading || !hasUnsavedChanges}
|
|
||||||
>
|
|
||||||
{#if $isFreePlan}
|
|
||||||
<ZapIcon size={16} class="filled" />
|
|
||||||
{/if}
|
|
||||||
{$t('course.navItem.certificates.save')}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<RefreshPageData />
|
|
||||||
</div>
|
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<CertificatesPage {errors} />
|
<CertificatesPage {errors} bind:activeTab={certificateActiveTab} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
{#if canEditCertificates && certificateActiveTab === 'settings'}
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={courseApi.isLoading}
|
||||||
|
disabled={courseApi.isLoading}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={saveCertificate}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { CourseSettingsPage } from '$features/course/pages';
|
import { CourseSettingsPage } from '$features/course/pages';
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { RefreshPageData } from '$features/ui';
|
import { RefreshPageData } from '$features/ui';
|
||||||
@@ -20,22 +19,21 @@
|
|||||||
isSaving = false;
|
isSaving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
settingsComponent?.handleDiscard();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||||
<Page.Header isSticky class="ui:bg-background z-10">
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>
|
<Page.Title>
|
||||||
{$t('course.navItem.settings.heading')}
|
{$t('course.navItem.settings.heading')}
|
||||||
</Page.Title>
|
</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<div class="flex items-center gap-2">
|
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
||||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
|
||||||
{$t('course.navItem.settings.save')}
|
|
||||||
</Button>
|
|
||||||
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
|
|
||||||
</div>
|
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
@@ -43,4 +41,14 @@
|
|||||||
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
|
<CourseSettingsPage bind:this={settingsComponent} bind:hasUnsavedChanges />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
</Page.Root>
|
</Page.Root>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ProfilePage } from '$features/settings/pages';
|
import { ProfilePage } from '$features/settings/pages';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let profileComponent: ProfilePage | null = $state(null);
|
let profileComponent: ProfilePage | null = $state(null);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let hasUnsavedChanges = $state(false);
|
||||||
|
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -15,24 +15,32 @@
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
profileComponent?.handleDiscard();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Settings - ClassroomIO</title>
|
<title>Settings - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page.Header isSticky>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
|
||||||
{$t('settings.profile.update_profile')}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<ProfilePage bind:this={profileComponent} />
|
<ProfilePage bind:this={profileComponent} bind:hasUnsavedChanges />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isLoading}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleUpdate}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
import { NotificationsPage } from '$features/settings/pages';
|
import { NotificationsPage } from '$features/settings/pages';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import * as Page from '@cio/ui/base/page';
|
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();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -11,11 +28,21 @@
|
|||||||
<Page.Header>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('settings.notifications.heading')}</Page.Title>
|
<Page.Title>{$t('settings.notifications.heading')}</Page.Title>
|
||||||
<Page.Subtitle>{$t('settings.notifications.personal_page_subtitle')}</Page.Subtitle>
|
<Page.Subtitle>{$t('settings.notifications.page_subtitle')}</Page.Subtitle>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<NotificationsPage />
|
<NotificationsPage bind:this={notificationsComponent} bind:hasUnsavedChanges />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: import('svelte').Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { OrgSettingsInlineTabs } from '$features/settings/components';
|
import { OrgSettingsInlineTabs } from '$features/settings/components';
|
||||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
@@ -27,16 +25,6 @@
|
|||||||
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
|
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
|
||||||
<Page.Subtitle>{$t('settings.organization.page_subtitle')}</Page.Subtitle>
|
<Page.Subtitle>{$t('settings.organization.page_subtitle')}</Page.Subtitle>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
loading={$settingsHeaderAction.loading}
|
|
||||||
disabled={$settingsHeaderAction.disabled || !$settingsHeaderAction.onClick}
|
|
||||||
onclick={() => $settingsHeaderAction.onClick?.()}
|
|
||||||
>
|
|
||||||
{$settingsHeaderAction.label}
|
|
||||||
</Button>
|
|
||||||
</Page.Action>
|
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
|
|
||||||
<OrgSettingsInlineTabs />
|
<OrgSettingsInlineTabs />
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import { ProfilePage } from '$features/settings/pages';
|
import { ProfilePage } from '$features/settings/pages';
|
||||||
import { VisitOrgSiteButton } from '$features/ui';
|
import { VisitOrgSiteButton } from '$features/ui';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { Button } from '@cio/ui/base/button';
|
|
||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let profileComponent: ProfilePage | null = $state(null);
|
let profileComponent: ProfilePage | null = $state(null);
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
|
let hasUnsavedChanges = $state(false);
|
||||||
|
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -16,27 +16,36 @@
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDiscard() {
|
||||||
|
profileComponent?.handleDiscard();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Settings - ClassroomIO</title>
|
<title>Settings - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page.Header isSticky>
|
<Page.Header>
|
||||||
<Page.HeaderContent>
|
<Page.HeaderContent>
|
||||||
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
<Page.Title>{$t('settings.profile.heading')}</Page.Title>
|
||||||
<Page.Subtitle>{$t('settings.profile.page_subtitle')}</Page.Subtitle>
|
<Page.Subtitle>{$t('settings.profile.page_subtitle')}</Page.Subtitle>
|
||||||
</Page.HeaderContent>
|
</Page.HeaderContent>
|
||||||
<Page.Action>
|
<Page.Action>
|
||||||
<Button variant="secondary" loading={isLoading} onclick={handleUpdate}>
|
|
||||||
{$t('settings.profile.update_profile')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<VisitOrgSiteButton />
|
<VisitOrgSiteButton />
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<ProfilePage bind:this={profileComponent} />
|
<ProfilePage bind:this={profileComponent} bind:hasUnsavedChanges />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isLoading}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleUpdate}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { CustomizeLmsPage } from '$features/settings/pages';
|
import { CustomizeLmsPage } from '$features/settings/pages';
|
||||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
|
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
|
let hasUnsavedChanges = $state(false);
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
@@ -15,27 +16,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
function handleDiscard() {
|
||||||
settingsHeaderAction.set({
|
customizeLmsComponent?.handleDiscard();
|
||||||
label: 'Save',
|
}
|
||||||
disabled: isSaving,
|
|
||||||
loading: isSaving,
|
|
||||||
onClick: handleSave
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
settingsHeaderAction.set({
|
|
||||||
label: 'Save',
|
|
||||||
disabled: true,
|
|
||||||
loading: false,
|
|
||||||
onClick: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Customize LMS - ClassroomIO</title>
|
<title>Customize LMS - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<CustomizeLmsPage bind:this={customizeLmsComponent} />
|
<CustomizeLmsPage bind:this={customizeLmsComponent} bind:hasUnsavedChanges />
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { DomainsPage } from '$features/settings/pages';
|
import { DomainsPage } from '$features/settings/pages';
|
||||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
settingsHeaderAction.set({
|
|
||||||
label: 'Save',
|
|
||||||
disabled: true,
|
|
||||||
loading: false,
|
|
||||||
onClick: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
settingsHeaderAction.set({
|
|
||||||
label: 'Save',
|
|
||||||
disabled: true,
|
|
||||||
loading: false,
|
|
||||||
onClick: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
import { NotificationsPage } from '$features/settings/pages';
|
import { NotificationsPage } from '$features/settings/pages';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import * as Page from '@cio/ui/base/page';
|
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();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -16,6 +33,16 @@
|
|||||||
</Page.Header>
|
</Page.Header>
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
<NotificationsPage />
|
<NotificationsPage bind:this={notificationsComponent} bind:hasUnsavedChanges />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { OrgPage } from '$features/settings/pages';
|
import { OrgPage } from '$features/settings/pages';
|
||||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let orgComponent: OrgPage | null = $state(null);
|
let orgComponent: OrgPage | null = $state(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let hasUnsavedChanges = $state(false);
|
||||||
|
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -15,27 +16,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
function handleDiscard() {
|
||||||
settingsHeaderAction.set({
|
orgComponent?.handleDiscard();
|
||||||
label: 'Save',
|
}
|
||||||
disabled: loading,
|
|
||||||
loading,
|
|
||||||
onClick: handleUpdate
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
settingsHeaderAction.set({
|
|
||||||
label: 'Save',
|
|
||||||
disabled: true,
|
|
||||||
loading: false,
|
|
||||||
onClick: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Organization Settings - ClassroomIO</title>
|
<title>Organization Settings - ClassroomIO</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<OrgPage bind:this={orgComponent} />
|
<OrgPage bind:this={orgComponent} bind:hasUnsavedChanges />
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
{loading}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleUpdate}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,25 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { TeamsPage } from '$features/settings/pages';
|
import { TeamsPage } from '$features/settings/pages';
|
||||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
settingsHeaderAction.set({
|
|
||||||
label: 'Save',
|
|
||||||
disabled: true,
|
|
||||||
loading: false,
|
|
||||||
onClick: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
settingsHeaderAction.set({
|
|
||||||
label: 'Save',
|
|
||||||
disabled: true,
|
|
||||||
loading: false,
|
|
||||||
onClick: null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
@@ -385,3 +385,60 @@
|
|||||||
</Page.Root>
|
</Page.Root>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Settings Actions">
|
||||||
|
{#snippet template()}
|
||||||
|
<Page.Root class="h-[32rem] w-[42rem] overflow-y-auto">
|
||||||
|
<Page.Header>
|
||||||
|
<Page.HeaderContent>
|
||||||
|
<Page.Title>Course settings</Page.Title>
|
||||||
|
<Page.Subtitle>Update publishing, enrollment, and visibility options.</Page.Subtitle>
|
||||||
|
</Page.HeaderContent>
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Body>
|
||||||
|
{#snippet child()}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each Array.from({ length: 8 }) as _, index (index)}
|
||||||
|
<div class="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
|
||||||
|
Settings section {index + 1}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={true}
|
||||||
|
statusLabel="Unsaved changes"
|
||||||
|
discardLabel="Discard"
|
||||||
|
saveLabel="Save changes"
|
||||||
|
onSave={() => undefined}
|
||||||
|
onDiscard={() => undefined}
|
||||||
|
/>
|
||||||
|
</Page.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Settings Actions (no changes)">
|
||||||
|
{#snippet template()}
|
||||||
|
<Page.Root class="w-[42rem]">
|
||||||
|
<Page.Header>
|
||||||
|
<Page.HeaderContent>
|
||||||
|
<Page.Title>Course settings</Page.Title>
|
||||||
|
</Page.HeaderContent>
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Body>
|
||||||
|
{#snippet child()}
|
||||||
|
<div class="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">All changes saved.</div>
|
||||||
|
{/snippet}
|
||||||
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={false}
|
||||||
|
statusLabel="Unsaved changes"
|
||||||
|
discardLabel="Discard"
|
||||||
|
saveLabel="Save changes"
|
||||||
|
onSave={() => undefined}
|
||||||
|
onDiscard={() => undefined}
|
||||||
|
/>
|
||||||
|
</Page.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|||||||
@@ -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.
|
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
|
||||||
|
<Page.Root>
|
||||||
|
<Page.Header>...</Page.Header>
|
||||||
|
<Page.Body>
|
||||||
|
{#snippet child()}
|
||||||
|
<!-- form sections -->
|
||||||
|
{/snippet}
|
||||||
|
</Page.Body>
|
||||||
|
<Page.SettingsActions
|
||||||
|
hasChanges={hasUnsavedChanges}
|
||||||
|
loading={isSaving}
|
||||||
|
statusLabel={$t('common.unsaved_changes.label')}
|
||||||
|
discardLabel={$t('common.discard')}
|
||||||
|
saveLabel={$t('common.save_changes')}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
</Page.Root>
|
||||||
|
```
|
||||||
|
|
||||||
|
See `Molecules/Page` → **Settings Actions** in Storybook.
|
||||||
|
|
||||||
## Importing `cn`
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import BodyHeader from './page-body-header.svelte';
|
|||||||
import Header from './page-header.svelte';
|
import Header from './page-header.svelte';
|
||||||
import HeaderContent from './page-header-content.svelte';
|
import HeaderContent from './page-header-content.svelte';
|
||||||
import Root from './page.svelte';
|
import Root from './page.svelte';
|
||||||
|
import SettingsActions from './page-settings-actions.svelte';
|
||||||
import Subtitle from './page-subtitle.svelte';
|
import Subtitle from './page-subtitle.svelte';
|
||||||
import Title from './page-title.svelte';
|
import Title from './page-title.svelte';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export {
|
|||||||
Subtitle,
|
Subtitle,
|
||||||
Body,
|
Body,
|
||||||
BodyHeader,
|
BodyHeader,
|
||||||
|
SettingsActions,
|
||||||
//
|
//
|
||||||
Root as Page,
|
Root as Page,
|
||||||
Header as PageHeader,
|
Header as PageHeader,
|
||||||
@@ -24,5 +26,6 @@ export {
|
|||||||
Title as PageTitle,
|
Title as PageTitle,
|
||||||
Subtitle as PageSubtitle,
|
Subtitle as PageSubtitle,
|
||||||
Body as PageBody,
|
Body as PageBody,
|
||||||
BodyHeader as PageBodyHeader
|
BodyHeader as PageBodyHeader,
|
||||||
|
SettingsActions as PageSettingsActions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="page-body"
|
data-slot="page-body"
|
||||||
class={cn('ui:flex-1 ui:flex ui:gap-4 ui:flex-col ui:mb-4', className)}
|
class={cn('ui:flex-1 ui:flex ui:gap-4 ui:flex-col ui:overflow-x-hidden', className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#if child}
|
{#if child}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '../button';
|
||||||
|
import { cn, type WithElementRef } from '../../tools';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
contentClass: contentClassName,
|
||||||
|
hasChanges = false,
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
statusLabel,
|
||||||
|
discardLabel,
|
||||||
|
saveLabel,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
contentClass?: string;
|
||||||
|
hasChanges?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
statusLabel: string;
|
||||||
|
discardLabel: string;
|
||||||
|
saveLabel: string;
|
||||||
|
onSave?: () => void | Promise<void>;
|
||||||
|
onDiscard?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const actionsDisabled = $derived(!hasChanges || loading);
|
||||||
|
|
||||||
|
let dockSentinel: HTMLDivElement | null = $state(null);
|
||||||
|
let isDocked = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dockSentinel) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
isDocked = entry.isIntersecting;
|
||||||
|
});
|
||||||
|
observer.observe(dockSentinel);
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} class={cn('ui:shrink-0', className)} {...restProps}>
|
||||||
|
<div data-slot="page-settings-actions" class="ui:sticky ui:bottom-0 ui:z-10 ui:pb-4">
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between',
|
||||||
|
'ui:rounded-lg ui:border ui:border-border ui:bg-background ui:p-4 ui:transition-shadow',
|
||||||
|
!isDocked && 'ui:shadow-md',
|
||||||
|
contentClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p class="ui:text-sm ui:text-muted-foreground">{statusLabel}</p>
|
||||||
|
|
||||||
|
<div class="ui:flex ui:shrink-0 ui:items-center ui:justify-end ui:gap-2">
|
||||||
|
<Button variant="ghost" type="button" disabled={actionsDisabled} onclick={onDiscard}>
|
||||||
|
{discardLabel}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" {loading} disabled={actionsDisabled || disabled} onclick={onSave}>
|
||||||
|
{saveLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={dockSentinel}
|
||||||
|
data-slot="page-settings-actions-sentinel"
|
||||||
|
class="ui:h-px ui:shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
@@ -13,10 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="page"
|
data-slot="page"
|
||||||
class={cn(
|
class={cn('ui:flex ui:flex-col ui:gap-4 ui:min-h-[calc(100vh-48px)] ui:w-full', className)}
|
||||||
'ui:flex ui:flex-col ui:gap-4 ui:min-h-[calc(100vh-48px)] ui:w-full ui:overflow-x-hidden ui:md:overflow-x-auto',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
Reference in New Issue
Block a user