feat(KAN-36): rebuild profile and appearance settings
This commit is contained in:
@@ -1,39 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Monitor, Moon, Sun } from 'lucide-vue-next';
|
import { Check, Monitor, Moon, Sun } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { useAppearance } from '@/composables/useAppearance';
|
import { useAppearance } from '@/composables/useAppearance';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const { appearance, updateAppearance } = useAppearance();
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
const { t } = useLocalization();
|
const { t } = useLocalization();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = computed(() => [
|
||||||
{ value: 'light', Icon: Sun, label: 'appearance.light' },
|
{
|
||||||
{ value: 'dark', Icon: Moon, label: 'appearance.dark' },
|
value: 'light' as const,
|
||||||
{ value: 'system', Icon: Monitor, label: 'appearance.system' },
|
Icon: Sun,
|
||||||
] as const;
|
label: t('appearance.light'),
|
||||||
|
description: t('appearance.lightDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark' as const,
|
||||||
|
Icon: Moon,
|
||||||
|
label: t('appearance.dark'),
|
||||||
|
description: t('appearance.darkDescription'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'system' as const,
|
||||||
|
Icon: Monitor,
|
||||||
|
label: t('appearance.system'),
|
||||||
|
description: t('appearance.systemDescription'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<fieldset class="min-w-0">
|
||||||
role="group"
|
<legend class="sr-only">{{ t('appearance.label') }}</legend>
|
||||||
:aria-label="t('appearance.label')"
|
<div class="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||||
class="border-border bg-muted inline-flex max-w-full gap-1 rounded-lg border p-1"
|
<label
|
||||||
>
|
v-for="{ value, Icon, label, description } in tabs"
|
||||||
<button
|
:key="value"
|
||||||
v-for="{ value, Icon, label } in tabs"
|
class="relative min-w-0"
|
||||||
:key="value"
|
>
|
||||||
type="button"
|
<input
|
||||||
:aria-pressed="appearance === value"
|
type="radio"
|
||||||
@click="updateAppearance(value)"
|
name="appearance"
|
||||||
:class="[
|
:value="value"
|
||||||
'min-h-control text-body focus-visible:outline-ring flex min-w-0 items-center justify-center gap-1.5 rounded-md px-3 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
|
:checked="appearance === value"
|
||||||
appearance === value
|
:aria-describedby="`appearance-${value}-description`"
|
||||||
? 'bg-primary text-primary-foreground'
|
class="peer sr-only"
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
@change="updateAppearance(value)"
|
||||||
]"
|
/>
|
||||||
>
|
<span
|
||||||
<component :is="Icon" class="size-4 shrink-0" aria-hidden="true" />
|
class="peer-checked:border-primary peer-checked:bg-accent/50 peer-focus-visible:ring-ring/40 gap-stack bg-background hover:border-primary/50 flex h-full min-h-32 cursor-pointer flex-col rounded-lg border p-4 transition-colors peer-focus-visible:ring-2"
|
||||||
<span>{{ t(label) }}</span>
|
>
|
||||||
</button>
|
<span class="flex items-center justify-between gap-3">
|
||||||
</div>
|
<span
|
||||||
|
class="bg-accent text-primary flex size-10 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="Icon"
|
||||||
|
class="size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="appearance === value"
|
||||||
|
class="text-primary size-5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold">{{ label }}</span>
|
||||||
|
<span
|
||||||
|
:id="`appearance-${value}-description`"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { Form } from '@inertiajs/vue3';
|
import { Form } from '@inertiajs/vue3';
|
||||||
import { useTemplateRef } from 'vue';
|
import { useTemplateRef } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
import PasswordInput from '@/components/PasswordInput.vue';
|
import PasswordInput from '@/components/PasswordInput.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -17,97 +17,104 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
|
|
||||||
const passwordInput = useTemplateRef('passwordInput');
|
const passwordInput = useTemplateRef('passwordInput');
|
||||||
|
const { t } = useLocalization();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<Card
|
||||||
<Heading
|
class="border-destructive/35 gap-0 overflow-hidden rounded-xl py-0 shadow-none"
|
||||||
variant="small"
|
>
|
||||||
title="Delete account"
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
description="Delete your account and all of its resources"
|
<h2 class="text-title">{{ t('profile.delete.title') }}</h2>
|
||||||
/>
|
<p class="text-muted-foreground">
|
||||||
<div
|
{{ t('profile.delete.description') }}
|
||||||
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
|
</p>
|
||||||
>
|
</CardHeader>
|
||||||
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
|
<CardContent class="p-5 sm:p-6">
|
||||||
<p class="font-medium">Warning</p>
|
<div
|
||||||
<p class="text-sm">
|
class="bg-destructive/8 border-destructive/25 gap-column grid rounded-lg border p-4"
|
||||||
Please proceed with caution, this cannot be undone.
|
>
|
||||||
</p>
|
<div class="gap-stack grid">
|
||||||
</div>
|
<p class="text-destructive font-semibold">
|
||||||
<Dialog>
|
{{ t('profile.delete.warning') }}
|
||||||
<DialogTrigger as-child>
|
</p>
|
||||||
<Button variant="destructive" data-test="delete-user-button"
|
<p class="text-body text-muted-foreground">
|
||||||
>Delete account</Button
|
{{ t('profile.delete.warningDescription') }}
|
||||||
>
|
</p>
|
||||||
</DialogTrigger>
|
</div>
|
||||||
<DialogContent>
|
<Dialog>
|
||||||
<Form
|
<DialogTrigger as-child>
|
||||||
v-bind="ProfileController.destroy.form()"
|
<Button
|
||||||
reset-on-success
|
variant="destructive"
|
||||||
@error="() => passwordInput?.focus()"
|
class="justify-self-start"
|
||||||
:options="{
|
data-test="delete-user-button"
|
||||||
preserveScroll: true,
|
>{{ t('profile.delete.action') }}</Button
|
||||||
}"
|
>
|
||||||
class="space-y-6"
|
</DialogTrigger>
|
||||||
v-slot="{ errors, processing, reset, clearErrors }"
|
<DialogContent>
|
||||||
>
|
<Form
|
||||||
<DialogHeader class="space-y-3">
|
v-bind="ProfileController.destroy.form()"
|
||||||
<DialogTitle
|
reset-on-success
|
||||||
>Are you sure you want to delete your
|
@error="() => passwordInput?.focus()"
|
||||||
account?</DialogTitle
|
:options="{
|
||||||
>
|
preserveScroll: true,
|
||||||
<DialogDescription>
|
}"
|
||||||
Once your account is deleted, all of its
|
class="space-y-6"
|
||||||
resources and data will also be permanently
|
v-slot="{ errors, processing, reset, clearErrors }"
|
||||||
deleted. Please enter your password to confirm
|
>
|
||||||
you would like to permanently delete your
|
<DialogHeader class="space-y-3">
|
||||||
account.
|
<DialogTitle>{{
|
||||||
</DialogDescription>
|
t('profile.delete.confirmTitle')
|
||||||
</DialogHeader>
|
}}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ t('profile.delete.confirmDescription') }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<Label for="password" class="sr-only"
|
<Label for="password" class="sr-only">{{
|
||||||
>Password</Label
|
t('profile.delete.password')
|
||||||
>
|
}}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
ref="passwordInput"
|
ref="passwordInput"
|
||||||
placeholder="Password"
|
:placeholder="t('profile.delete.password')"
|
||||||
/>
|
/>
|
||||||
<InputError :message="errors.password" />
|
<InputError :message="errors.password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter class="gap-2">
|
||||||
|
<DialogClose as-child>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
clearErrors();
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
<DialogFooter class="gap-2">
|
|
||||||
<DialogClose as-child>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
type="submit"
|
||||||
@click="
|
variant="destructive"
|
||||||
() => {
|
:disabled="processing"
|
||||||
clearErrors();
|
data-test="confirm-delete-user-button"
|
||||||
reset();
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
Cancel
|
{{ t('profile.delete.confirmAction') }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
<Button
|
</DialogContent>
|
||||||
type="submit"
|
</Dialog>
|
||||||
variant="destructive"
|
</div>
|
||||||
:disabled="processing"
|
</CardContent>
|
||||||
data-test="confirm-delete-user-button"
|
</Card>
|
||||||
>
|
|
||||||
Delete account
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link } from '@inertiajs/vue3';
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
Palette,
|
||||||
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
import { useLocalization } from '@/composables/useLocalization';
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
@@ -19,22 +24,27 @@ const sidebarNavItems = computed<NavItem[]>(() => [
|
|||||||
{
|
{
|
||||||
title: t('navigation.profile'),
|
title: t('navigation.profile'),
|
||||||
href: editProfile(),
|
href: editProfile(),
|
||||||
|
icon: UserRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.security'),
|
title: t('navigation.security'),
|
||||||
href: editSecurity(),
|
href: editSecurity(),
|
||||||
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.gitConnections'),
|
title: t('navigation.gitConnections'),
|
||||||
href: editGitConnections(),
|
href: editGitConnections(),
|
||||||
|
icon: GitBranch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.applications'),
|
title: t('navigation.applications'),
|
||||||
href: editApplications(),
|
href: editApplications(),
|
||||||
|
icon: KeyRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('navigation.appearance'),
|
title: t('navigation.appearance'),
|
||||||
href: editAppearance(),
|
href: editAppearance(),
|
||||||
|
icon: Palette,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -42,47 +52,60 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-4 py-6">
|
<div
|
||||||
<Heading
|
class="gap-page md:p-page mx-auto flex w-full min-w-0 max-w-6xl flex-1 flex-col p-4"
|
||||||
:title="t('navigation.settings')"
|
>
|
||||||
:description="t('navigation.settingsDescription')"
|
<header class="gap-stack grid min-w-0">
|
||||||
/>
|
<div class="text-primary flex items-center gap-2">
|
||||||
|
<Settings2 class="size-4" aria-hidden="true" />
|
||||||
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
|
{{ t('settings.eyebrow') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
|
{{ t('navigation.settings') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
|
{{ t('navigation.settingsDescription') }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row lg:gap-12">
|
<div
|
||||||
<aside class="w-full min-w-0 lg:w-52 lg:shrink-0">
|
class="bg-card grid min-w-0 overflow-hidden rounded-xl border shadow-sm lg:grid-cols-[15rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="bg-muted/35 border-b p-3 lg:border-b-0 lg:border-r lg:p-4"
|
||||||
|
>
|
||||||
<nav
|
<nav
|
||||||
class="flex gap-1 overflow-x-auto lg:flex-col"
|
class="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
||||||
:aria-label="t('navigation.settings')"
|
:aria-label="t('navigation.settings')"
|
||||||
>
|
>
|
||||||
<Button
|
<Link
|
||||||
v-for="item in sidebarNavItems"
|
v-for="item in sidebarNavItems"
|
||||||
:key="toUrl(item.href)"
|
:key="toUrl(item.href)"
|
||||||
variant="ghost"
|
:href="item.href"
|
||||||
|
:aria-current="
|
||||||
|
isCurrentOrParentUrl(item.href) ? 'page' : undefined
|
||||||
|
"
|
||||||
:class="[
|
:class="[
|
||||||
'shrink-0 justify-start lg:w-full',
|
'text-body focus-visible:ring-ring/40 flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 font-medium outline-none transition-colors focus-visible:ring-2 lg:w-full',
|
||||||
{ 'bg-muted': isCurrentOrParentUrl(item.href) },
|
isCurrentOrParentUrl(item.href)
|
||||||
|
? 'border-primary/25 bg-accent text-accent-foreground shadow-xs'
|
||||||
|
: 'text-muted-foreground hover:border-border hover:bg-background hover:text-foreground border-transparent',
|
||||||
]"
|
]"
|
||||||
as-child
|
|
||||||
>
|
>
|
||||||
<Link
|
<component
|
||||||
:href="item.href"
|
:is="item.icon"
|
||||||
:aria-current="
|
class="size-4 shrink-0"
|
||||||
isCurrentOrParentUrl(item.href)
|
aria-hidden="true"
|
||||||
? 'page'
|
/>
|
||||||
: undefined
|
<span class="whitespace-nowrap">{{ item.title }}</span>
|
||||||
"
|
</Link>
|
||||||
>
|
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
|
||||||
{{ item.title }}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<Separator class="lg:hidden" />
|
<div class="min-w-0 p-4 sm:p-6 lg:p-8">
|
||||||
|
<section class="mx-auto grid max-w-3xl gap-8">
|
||||||
<div class="min-w-0 flex-1 md:max-w-2xl">
|
|
||||||
<section class="max-w-xl space-y-12">
|
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ export const en = {
|
|||||||
'navigation.appearance': 'Appearance',
|
'navigation.appearance': 'Appearance',
|
||||||
'navigation.settingsDescription':
|
'navigation.settingsDescription':
|
||||||
'Manage your profile, security, and Git integration settings.',
|
'Manage your profile, security, and Git integration settings.',
|
||||||
|
'settings.eyebrow': 'Account settings',
|
||||||
'navigation.createTicket': 'New ticket',
|
'navigation.createTicket': 'New ticket',
|
||||||
'navigation.createProject': 'New project',
|
'navigation.createProject': 'New project',
|
||||||
'navigation.open': 'Open navigation',
|
'navigation.open': 'Open navigation',
|
||||||
@@ -373,8 +374,58 @@ export const en = {
|
|||||||
'navigation.breadcrumbs': 'Breadcrumbs',
|
'navigation.breadcrumbs': 'Breadcrumbs',
|
||||||
'appearance.label': 'Color theme',
|
'appearance.label': 'Color theme',
|
||||||
'appearance.light': 'Light',
|
'appearance.light': 'Light',
|
||||||
|
'appearance.lightDescription':
|
||||||
|
'Use a bright workspace with dark text and clear borders.',
|
||||||
'appearance.dark': 'Dark',
|
'appearance.dark': 'Dark',
|
||||||
|
'appearance.darkDescription':
|
||||||
|
'Reduce glare with a darker workspace and balanced contrast.',
|
||||||
'appearance.system': 'System',
|
'appearance.system': 'System',
|
||||||
|
'appearance.systemDescription':
|
||||||
|
'Follow the light or dark preference set on this device.',
|
||||||
|
'appearance.title': 'Appearance',
|
||||||
|
'appearance.description': 'Choose how KanbanGit looks on this device.',
|
||||||
|
'appearance.persistenceHint':
|
||||||
|
'Your choice is saved on this device and restored when you open KanbanGit again.',
|
||||||
|
'profile.title': 'Profile',
|
||||||
|
'profile.description':
|
||||||
|
'Keep your personal details and profile picture up to date.',
|
||||||
|
'profile.information': 'Profile information',
|
||||||
|
'profile.picture': 'Profile picture',
|
||||||
|
'profile.pictureDescription':
|
||||||
|
'Help teammates recognize you throughout the workspace.',
|
||||||
|
'profile.picturePreview': 'Profile picture preview',
|
||||||
|
'profile.choosePicture': 'Choose picture',
|
||||||
|
'profile.changePicture': 'Choose another',
|
||||||
|
'profile.discardPicture': 'Discard selection',
|
||||||
|
'profile.removePicture': 'Remove picture',
|
||||||
|
'profile.removingPicture': 'Removing picture…',
|
||||||
|
'profile.selectedPicture': 'Selected: {name}',
|
||||||
|
'profile.pictureHelp':
|
||||||
|
'Choose a PNG, JPG, or WebP image up to 2 MB. On mobile, you can select from your photo library or camera.',
|
||||||
|
'profile.name': 'Name',
|
||||||
|
'profile.namePlaceholder': 'Full name',
|
||||||
|
'profile.email': 'Email address',
|
||||||
|
'profile.emailPlaceholder': 'Email address',
|
||||||
|
'profile.emailUnverified': 'Your email address is unverified.',
|
||||||
|
'profile.resendVerification': 'Resend the verification email',
|
||||||
|
'profile.verificationSent':
|
||||||
|
'A new verification link has been sent to your email address.',
|
||||||
|
'profile.save': 'Save profile',
|
||||||
|
'profile.saving': 'Saving profile…',
|
||||||
|
'profile.unsaved': 'You have unsaved profile changes.',
|
||||||
|
'profile.delete.title': 'Delete account',
|
||||||
|
'profile.delete.description':
|
||||||
|
'Permanently delete your account and all of its resources.',
|
||||||
|
'profile.delete.warning': 'This action cannot be undone',
|
||||||
|
'profile.delete.warningDescription':
|
||||||
|
'Your account and associated data will be permanently removed.',
|
||||||
|
'profile.delete.action': 'Delete account',
|
||||||
|
'profile.delete.confirmTitle':
|
||||||
|
'Are you sure you want to delete your account?',
|
||||||
|
'profile.delete.confirmDescription':
|
||||||
|
'Enter your password to confirm that you want to permanently delete your account and its data.',
|
||||||
|
'profile.delete.password': 'Password',
|
||||||
|
'profile.delete.confirmAction': 'Permanently delete account',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.close': 'Close',
|
'common.close': 'Close',
|
||||||
'common.retry': 'Try again',
|
'common.retry': 'Try again',
|
||||||
|
|||||||
@@ -1,35 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head } from '@inertiajs/vue3';
|
||||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/appearance/index';
|
import { edit } from '@/routes/appearance/index';
|
||||||
import type { BreadcrumbItem } from '@/types';
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
{
|
{
|
||||||
title: 'Appearance settings',
|
title: t('navigation.appearance'),
|
||||||
href: edit(),
|
href: edit(),
|
||||||
},
|
},
|
||||||
];
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Appearance settings" />
|
<Head :title="t('appearance.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Appearance settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="space-y-6">
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
<Heading
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
variant="small"
|
<h2 class="text-title">{{ t('appearance.title') }}</h2>
|
||||||
title="Appearance settings"
|
<p class="text-muted-foreground">
|
||||||
description="Update your account's appearance settings"
|
{{ t('appearance.description') }}
|
||||||
/>
|
</p>
|
||||||
<AppearanceTabs />
|
</CardHeader>
|
||||||
</div>
|
<CardContent class="gap-column grid p-5 sm:p-6">
|
||||||
|
<AppearanceTabs />
|
||||||
|
<p class="text-caption text-muted-foreground">
|
||||||
|
{{ t('appearance.persistenceHint') }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,209 +1,385 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Form, Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { Trash2 } from 'lucide-vue-next';
|
import { Camera, RotateCcw, Trash2 } from 'lucide-vue-next';
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
import { computed, onBeforeUnmount, ref, useTemplateRef } from 'vue';
|
||||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||||
import DeleteUser from '@/components/DeleteUser.vue';
|
import DeleteUser from '@/components/DeleteUser.vue';
|
||||||
import Heading from '@/components/Heading.vue';
|
import FormFeedback from '@/components/FormFeedback.vue';
|
||||||
import InputError from '@/components/InputError.vue';
|
import InputError from '@/components/InputError.vue';
|
||||||
|
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useFormProtection } from '@/composables/useFormProtection';
|
||||||
|
import { useLocalization } from '@/composables/useLocalization';
|
||||||
import AppLayout from '@/layouts/AppLayout.vue';
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||||
import { edit } from '@/routes/profile/index';
|
import { edit } from '@/routes/profile/index';
|
||||||
import { send } from '@/routes/verification';
|
import { send } from '@/routes/verification';
|
||||||
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
import type { BreadcrumbItem, SettingsProfilePageProps } from '@/types';
|
||||||
import UserAvatar from '@/components/ui/avatar/UserAvatar.vue';
|
import type { UserModel } from '@/types/models/user-model';
|
||||||
|
|
||||||
defineProps<SettingsProfilePageProps>();
|
defineProps<SettingsProfilePageProps>();
|
||||||
|
|
||||||
const breadcrumbItems: BreadcrumbItem[] = [
|
const { t } = useLocalization();
|
||||||
{
|
|
||||||
title: 'Profile settings',
|
|
||||||
href: edit(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const page = usePage();
|
const page = usePage();
|
||||||
const user = computed(() => page.props.auth.user);
|
const user = computed(() => page.props.auth.user);
|
||||||
const avatarPreview = ref<string | null>(null);
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
|
||||||
|
{
|
||||||
|
title: t('navigation.profile'),
|
||||||
|
href: edit(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const profileForm = useForm('SettingsProfile', {
|
||||||
|
name: user.value.name,
|
||||||
|
email: user.value.email,
|
||||||
|
avatar: null as File | null,
|
||||||
|
});
|
||||||
|
const profileProtection = useFormProtection(profileForm);
|
||||||
const avatarDeleteForm = useForm({});
|
const avatarDeleteForm = useForm({});
|
||||||
|
const avatarDeleteProtection = useFormProtection(avatarDeleteForm);
|
||||||
|
const avatarInput = useTemplateRef<HTMLInputElement>('avatarInput');
|
||||||
|
const avatarPreview = ref<string | null>(null);
|
||||||
const displayedAvatar = computed(
|
const displayedAvatar = computed(
|
||||||
() => avatarPreview.value ?? user.value.avatar,
|
() => avatarPreview.value ?? user.value.avatar,
|
||||||
);
|
);
|
||||||
|
const displayedUser = computed<UserModel>(() => ({
|
||||||
|
...user.value,
|
||||||
|
avatar: displayedAvatar.value,
|
||||||
|
}));
|
||||||
const canRemoveAvatar = computed(
|
const canRemoveAvatar = computed(
|
||||||
() => user.value.avatar !== null && !user.value.avatar_is_generated,
|
() =>
|
||||||
|
avatarPreview.value === null &&
|
||||||
|
user.value.avatar !== null &&
|
||||||
|
!user.value.avatar_is_generated,
|
||||||
);
|
);
|
||||||
|
const profileUpdateForm = ProfileController.update.form();
|
||||||
|
const profileUpdateTarget = {
|
||||||
|
url: profileUpdateForm.action,
|
||||||
|
method: profileUpdateForm.method,
|
||||||
|
};
|
||||||
|
|
||||||
const clearAvatarPreview = (): void => {
|
function clearAvatarPreview(): void {
|
||||||
if (avatarPreview.value) {
|
if (avatarPreview.value) {
|
||||||
URL.revokeObjectURL(avatarPreview.value);
|
URL.revokeObjectURL(avatarPreview.value);
|
||||||
avatarPreview.value = null;
|
avatarPreview.value = null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleAvatarChange = (event: Event) => {
|
function clearAvatarSelection(): void {
|
||||||
|
clearAvatarPreview();
|
||||||
|
profileForm.avatar = null;
|
||||||
|
profileForm.clearErrors('avatar');
|
||||||
|
profileProtection.clearFeedback();
|
||||||
|
if (avatarInput.value) {
|
||||||
|
avatarInput.value.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAvatarChange(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0] ?? null;
|
||||||
|
|
||||||
clearAvatarPreview();
|
clearAvatarPreview();
|
||||||
|
profileForm.avatar = file;
|
||||||
|
profileForm.clearErrors('avatar');
|
||||||
|
profileProtection.clearFeedback();
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
avatarPreview.value = URL.createObjectURL(file);
|
avatarPreview.value = URL.createObjectURL(file);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const removeAvatar = (): void => {
|
function submitProfile(): void {
|
||||||
avatarDeleteForm.delete(ProfileController.destroyAvatar.url(), {
|
profileProtection.submit(profileUpdateTarget, {
|
||||||
|
forceFormData: true,
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: clearAvatarPreview,
|
onSuccess: () => {
|
||||||
|
profileForm.defaults({
|
||||||
|
name: profileForm.name,
|
||||||
|
email: profileForm.email,
|
||||||
|
avatar: null,
|
||||||
|
});
|
||||||
|
clearAvatarSelection();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
function removeAvatar(): void {
|
||||||
clearAvatarPreview();
|
avatarDeleteProtection.submit(ProfileController.destroyAvatar(), {
|
||||||
});
|
preserveScroll: true,
|
||||||
|
onSuccess: clearAvatarSelection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(clearAvatarPreview);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppLayout :breadcrumbs="breadcrumbItems">
|
<AppLayout :breadcrumbs="breadcrumbItems">
|
||||||
<Head title="Profile settings" />
|
<Head :title="t('profile.title')" />
|
||||||
|
|
||||||
<h1 class="sr-only">Profile settings</h1>
|
|
||||||
|
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
<div class="flex flex-col space-y-6">
|
<Card class="gap-0 overflow-hidden rounded-xl py-0 shadow-none">
|
||||||
<Heading
|
<CardHeader class="gap-stack border-b p-5 sm:p-6">
|
||||||
variant="small"
|
<h2 class="text-title">{{ t('profile.title') }}</h2>
|
||||||
title="Profile information"
|
<p class="text-muted-foreground">
|
||||||
description="Update your name, email address, and profile picture"
|
{{ t('profile.description') }}
|
||||||
/>
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<Form
|
<CardContent class="p-5 sm:p-6">
|
||||||
v-bind="ProfileController.update.form()"
|
<form
|
||||||
class="space-y-6"
|
class="gap-column grid min-w-0"
|
||||||
v-slot="{
|
:aria-busy="profileForm.processing"
|
||||||
errors,
|
@submit.prevent="submitProfile"
|
||||||
processing,
|
>
|
||||||
progress,
|
<FormFeedback
|
||||||
recentlySuccessful,
|
:state="profileProtection.state.value"
|
||||||
}"
|
:title="
|
||||||
>
|
profileForm.processing
|
||||||
<div class="grid gap-3">
|
? t('profile.saving')
|
||||||
<Label for="avatar">Profile picture</Label>
|
: undefined
|
||||||
<div class="flex items-center gap-4">
|
"
|
||||||
<UserAvatar class="size-16" />
|
@retry="submitProfile"
|
||||||
<div class="grid flex-1 gap-2">
|
/>
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<Input
|
<fieldset
|
||||||
id="avatar"
|
class="gap-column grid min-w-0"
|
||||||
name="avatar"
|
:disabled="profileForm.processing"
|
||||||
type="file"
|
>
|
||||||
accept="image/png,image/jpeg,image/webp"
|
<legend class="sr-only">
|
||||||
@change="handleAvatarChange"
|
{{ t('profile.information') }}
|
||||||
/>
|
</legend>
|
||||||
<Button
|
|
||||||
v-if="canRemoveAvatar"
|
<section
|
||||||
type="button"
|
aria-labelledby="profile-picture-title"
|
||||||
variant="outline"
|
class="gap-column grid min-w-0 border-b pb-6"
|
||||||
:disabled="avatarDeleteForm.processing"
|
>
|
||||||
data-test="delete-profile-picture-button"
|
<div class="gap-stack grid">
|
||||||
@click="removeAvatar"
|
<h3
|
||||||
|
id="profile-picture-title"
|
||||||
|
class="font-semibold"
|
||||||
>
|
>
|
||||||
<Trash2 class="size-4" />
|
{{ t('profile.picture') }}
|
||||||
Remove
|
</h3>
|
||||||
</Button>
|
<p
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.pictureDescription') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground text-sm">
|
|
||||||
PNG, JPG, or WebP up to 2 MB.
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
:user="displayedUser"
|
||||||
|
size="xl"
|
||||||
|
class="ring-border shrink-0 ring-1"
|
||||||
|
:label="t('profile.picturePreview')"
|
||||||
|
/>
|
||||||
|
<div class="gap-stack grid min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button as-child variant="outline">
|
||||||
|
<label
|
||||||
|
for="avatar"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Camera
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{
|
||||||
|
avatarPreview
|
||||||
|
? t(
|
||||||
|
'profile.changePicture',
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
'profile.choosePicture',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="avatarPreview"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
@click="clearAvatarSelection"
|
||||||
|
>
|
||||||
|
<RotateCcw aria-hidden="true" />
|
||||||
|
{{
|
||||||
|
t('profile.discardPicture')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="canRemoveAvatar"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
:disabled="
|
||||||
|
avatarDeleteForm.processing
|
||||||
|
"
|
||||||
|
data-test="delete-profile-picture-button"
|
||||||
|
@click="removeAvatar"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" />
|
||||||
|
{{ t('profile.removePicture') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="avatar"
|
||||||
|
ref="avatarInput"
|
||||||
|
name="avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
class="sr-only"
|
||||||
|
aria-describedby="avatar-help avatar-error"
|
||||||
|
:aria-invalid="
|
||||||
|
!!profileForm.errors.avatar
|
||||||
|
"
|
||||||
|
@change="handleAvatarChange"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="profileForm.avatar"
|
||||||
|
class="text-caption min-w-0 break-all font-medium"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('profile.selectedPicture', {
|
||||||
|
name: profileForm.avatar
|
||||||
|
.name,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
id="avatar-help"
|
||||||
|
class="text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ t('profile.pictureHelp') }}
|
||||||
|
</p>
|
||||||
|
<progress
|
||||||
|
v-if="profileForm.progress"
|
||||||
|
class="h-1 w-full overflow-hidden rounded-full"
|
||||||
|
:value="
|
||||||
|
profileForm.progress.percentage
|
||||||
|
"
|
||||||
|
max="100"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
profileForm.progress.percentage
|
||||||
|
}}%
|
||||||
|
</progress>
|
||||||
|
<InputError
|
||||||
|
id="avatar-error"
|
||||||
|
:message="profileForm.errors.avatar"
|
||||||
|
/>
|
||||||
|
<FormFeedback
|
||||||
|
:state="
|
||||||
|
avatarDeleteProtection.state
|
||||||
|
.value
|
||||||
|
"
|
||||||
|
:title="
|
||||||
|
avatarDeleteForm.processing
|
||||||
|
? t(
|
||||||
|
'profile.removingPicture',
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
@retry="removeAvatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="name">{{
|
||||||
|
t('profile.name')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
v-model="profileForm.name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
autocomplete="name"
|
||||||
|
:placeholder="t('profile.namePlaceholder')"
|
||||||
|
:aria-invalid="!!profileForm.errors.name"
|
||||||
|
aria-describedby="name-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="name-error"
|
||||||
|
:message="profileForm.errors.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<Label for="email">{{
|
||||||
|
t('profile.email')
|
||||||
|
}}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
v-model="profileForm.email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
:placeholder="t('profile.emailPlaceholder')"
|
||||||
|
:aria-invalid="!!profileForm.errors.email"
|
||||||
|
aria-describedby="email-error"
|
||||||
|
/>
|
||||||
|
<InputError
|
||||||
|
id="email-error"
|
||||||
|
:message="profileForm.errors.email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
mustVerifyEmail && !user.email_verified_at
|
||||||
|
"
|
||||||
|
class="bg-muted/55 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<p class="text-body text-muted-foreground">
|
||||||
|
{{ t('profile.emailUnverified') }}
|
||||||
|
<Link
|
||||||
|
:href="send()"
|
||||||
|
as="button"
|
||||||
|
class="text-primary font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{{ t('profile.resendVerification') }}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="status === 'verification-link-sent'"
|
||||||
|
role="status"
|
||||||
|
class="text-success-foreground mt-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ t('profile.verificationSent') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<progress
|
|
||||||
v-if="progress"
|
|
||||||
class="h-1 w-full overflow-hidden rounded-full"
|
|
||||||
:value="progress.percentage"
|
|
||||||
max="100"
|
|
||||||
>
|
|
||||||
{{ progress.percentage }}%
|
|
||||||
</progress>
|
|
||||||
<InputError class="mt-1" :message="errors.avatar" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Label for="name">Name</Label>
|
<Button
|
||||||
<Input
|
type="submit"
|
||||||
id="name"
|
:disabled="profileForm.processing"
|
||||||
class="mt-1 block w-full"
|
data-test="update-profile-button"
|
||||||
name="name"
|
>
|
||||||
:default-value="user.name"
|
{{
|
||||||
required
|
profileForm.processing
|
||||||
autocomplete="name"
|
? t('profile.saving')
|
||||||
placeholder="Full name"
|
: t('profile.save')
|
||||||
/>
|
}}
|
||||||
<InputError class="mt-2" :message="errors.name" />
|
</Button>
|
||||||
</div>
|
<p
|
||||||
|
v-if="profileForm.isDirty"
|
||||||
<div class="grid gap-2">
|
class="text-caption text-muted-foreground"
|
||||||
<Label for="email">Email address</Label>
|
>
|
||||||
<Input
|
{{ t('profile.unsaved') }}
|
||||||
id="email"
|
</p>
|
||||||
type="email"
|
</div>
|
||||||
class="mt-1 block w-full"
|
</fieldset>
|
||||||
name="email"
|
</form>
|
||||||
:default-value="user.email"
|
</CardContent>
|
||||||
required
|
</Card>
|
||||||
autocomplete="username"
|
|
||||||
placeholder="Email address"
|
|
||||||
/>
|
|
||||||
<InputError class="mt-2" :message="errors.email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="mustVerifyEmail && !user.email_verified_at">
|
|
||||||
<p class="text-muted-foreground -mt-4 text-sm">
|
|
||||||
Your email address is unverified.
|
|
||||||
<Link
|
|
||||||
:href="send()"
|
|
||||||
as="button"
|
|
||||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
|
||||||
>
|
|
||||||
Click here to resend the verification email.
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="status === 'verification-link-sent'"
|
|
||||||
class="mt-2 text-sm font-medium text-green-600"
|
|
||||||
>
|
|
||||||
A new verification link has been sent to your email
|
|
||||||
address.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
:disabled="processing"
|
|
||||||
data-test="update-profile-button"
|
|
||||||
>Save</Button
|
|
||||||
>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
enter-active-class="transition ease-in-out"
|
|
||||||
enter-from-class="opacity-0"
|
|
||||||
leave-active-class="transition ease-in-out"
|
|
||||||
leave-to-class="opacity-0"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
v-show="recentlySuccessful"
|
|
||||||
class="text-sm text-neutral-600"
|
|
||||||
>
|
|
||||||
Saved.
|
|
||||||
</p>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DeleteUser />
|
<DeleteUser />
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Models\User;
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ProfileUpdateTest extends TestCase
|
class ProfileUpdateTest extends TestCase
|
||||||
@@ -219,6 +220,16 @@ class ProfileUpdateTest extends TestCase
|
|||||||
$response->assertSessionHasErrors('avatar');
|
$response->assertSessionHasErrors('avatar');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_profile_and_appearance_settings_render_the_shared_localized_interface(): void
|
||||||
|
{
|
||||||
|
$process = new Process(['node', 'tests/Fixtures/settings.mjs'], base_path());
|
||||||
|
$process->setTimeout(60);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||||
|
$this->assertStringContainsString('Settings rendering and localization checks passed.', $process->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_user_can_delete_their_account()
|
public function test_user_can_delete_their_account()
|
||||||
{
|
{
|
||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
|
|||||||
@@ -84,15 +84,11 @@ try {
|
|||||||
renderTheme('light'),
|
renderTheme('light'),
|
||||||
renderTheme('dark'),
|
renderTheme('dark'),
|
||||||
]);
|
]);
|
||||||
const selectedButton = (html) =>
|
assert.match(light, /value="light"[^>]*checked/);
|
||||||
html.match(
|
assert.match(dark, /value="dark"[^>]*checked/);
|
||||||
/<button[^>]*aria-pressed="true"[^>]*>([\s\S]*?)<\/button>/,
|
assert.match(light, /<legend class="sr-only">Color theme<\/legend>/);
|
||||||
)[1];
|
assert.equal((light.match(/type="radio"/g) ?? []).length, 3);
|
||||||
assert.match(selectedButton(light), />Light</);
|
assert.equal((dark.match(/ checked/g) ?? []).length, 1);
|
||||||
assert.match(selectedButton(dark), />Dark</);
|
|
||||||
assert.match(light, /role="group" aria-label="Color theme"/);
|
|
||||||
assert.equal((light.match(/type="button"/g) ?? []).length, 3);
|
|
||||||
assert.equal((dark.match(/aria-pressed="true"/g) ?? []).length, 1);
|
|
||||||
assert.equal(normalizeAppearance('unexpected'), 'system');
|
assert.equal(normalizeAppearance('unexpected'), 'system');
|
||||||
|
|
||||||
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
const blade = await readFile('resources/views/app.blade.php', 'utf8');
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const { chromium } = await import(
|
||||||
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||||
|
);
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const base = 'http://127.0.0.1:4184';
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => {
|
||||||
|
errors.push(error.message);
|
||||||
|
console.error(error.message);
|
||||||
|
});
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
|
const setResult = (value) =>
|
||||||
|
context.addCookies([{ name: 'settings_test_result', value, url: base }]);
|
||||||
|
const state = (value) =>
|
||||||
|
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
||||||
|
const themeRadio = (value) =>
|
||||||
|
page.locator(`input[name="appearance"][value="${value}"]`);
|
||||||
|
const chooseTheme = (value) =>
|
||||||
|
page
|
||||||
|
.locator(`label:has(input[name="appearance"][value="${value}"])`)
|
||||||
|
.click();
|
||||||
|
const image = {
|
||||||
|
name: 'profile.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const width of [1440, 390, 320]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const theme of ['light', 'dark']) {
|
||||||
|
await page.emulateMedia({ colorScheme: theme });
|
||||||
|
await page.goto(`${base}/settings/appearance`);
|
||||||
|
await page
|
||||||
|
.getByRole('heading', { name: 'Settings', exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await chooseTheme(theme);
|
||||||
|
assert.equal(await themeRadio(theme).isChecked(), true);
|
||||||
|
assert.equal(
|
||||||
|
await page
|
||||||
|
.locator('html')
|
||||||
|
.evaluate((element) => element.classList.contains('dark')),
|
||||||
|
theme === 'dark',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
`Appearance overflows at ${width}px in ${theme}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${base}/settings/appearance`);
|
||||||
|
const light = themeRadio('light');
|
||||||
|
await light.focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
assert.equal(await themeRadio('dark').isChecked(), true);
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await themeRadio('dark').isChecked(), true);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 900 });
|
||||||
|
await page.goto(`${base}/settings/profile`);
|
||||||
|
const avatarInput = page.locator('#avatar');
|
||||||
|
assert.equal(
|
||||||
|
await avatarInput.getAttribute('accept'),
|
||||||
|
'image/png,image/jpeg,image/webp',
|
||||||
|
);
|
||||||
|
await avatarInput.setInputFiles(image);
|
||||||
|
await page.getByText('Selected: profile.png', { exact: true }).waitFor();
|
||||||
|
assert.match(
|
||||||
|
await page
|
||||||
|
.locator('img[alt="Profile picture preview"]')
|
||||||
|
.getAttribute('src'),
|
||||||
|
/^blob:/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await setResult('validation');
|
||||||
|
await page.getByLabel('Name', { exact: true }).fill('Updated User');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save profile', exact: true })
|
||||||
|
.click();
|
||||||
|
await state('validation');
|
||||||
|
await page
|
||||||
|
.getByText('The avatar must be a file of type: jpg, jpeg, png, webp.', {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await avatarInput.getAttribute('aria-invalid'), 'true');
|
||||||
|
assert.equal(
|
||||||
|
await page.getByLabel('Name', { exact: true }).inputValue(),
|
||||||
|
'Updated User',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await page
|
||||||
|
.locator('img[alt="Profile picture preview"]')
|
||||||
|
.getAttribute('src'),
|
||||||
|
/^blob:/,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route('**/settings/profile?*', (route) => route.abort('failed'));
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Save profile', exact: true })
|
||||||
|
.click();
|
||||||
|
await state('network');
|
||||||
|
await page.unroute('**/settings/profile?*');
|
||||||
|
await setResult('success');
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
await state('success');
|
||||||
|
|
||||||
|
await page.goto(`${base}/settings/profile`);
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Remove picture', exact: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Remove picture', exact: true })
|
||||||
|
.waitFor({ state: 'detached' });
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(
|
||||||
|
'Settings browser checks passed: avatar preview, upload errors and retry, removal, keyboard, 320/390/1440px and both themes.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
import '../../resources/css/app.css';
|
||||||
|
import Appearance from '../../resources/js/pages/settings/Appearance.vue';
|
||||||
|
import Profile from '../../resources/js/pages/settings/Profile.vue';
|
||||||
|
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||||
|
import { createLocalization } from '../../resources/js/lib/localization';
|
||||||
|
|
||||||
|
createInertiaApp({
|
||||||
|
resolve: (name) => (name === 'Appearance' ? Appearance : Profile),
|
||||||
|
setup: ({ el, App, props, plugin }) =>
|
||||||
|
createApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(createLocalization())
|
||||||
|
.use(createAppearance())
|
||||||
|
.mount(el),
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwind from '@tailwindcss/vite';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { createSSRApp, h } from 'vue';
|
||||||
|
import { renderToString } from 'vue/server-renderer';
|
||||||
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
|
|
||||||
|
const serve = process.argv.includes('--serve');
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
cacheDir: 'node_modules/.vite/settings-fixture',
|
||||||
|
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: serve
|
||||||
|
? { host: '127.0.0.1', port: 4184, strictPort: true, hmr: false }
|
||||||
|
: { middlewareMode: true, hmr: false, watch: null },
|
||||||
|
appType: 'custom',
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeUser(generated = false) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name: 'Alex Morgan',
|
||||||
|
email: 'alex@example.test',
|
||||||
|
avatar: generated ? null : '/avatar.png',
|
||||||
|
avatar_color: '#10B981',
|
||||||
|
avatar_is_generated: generated,
|
||||||
|
email_verified_at: null,
|
||||||
|
must_change_password_on_next_login: false,
|
||||||
|
created_at: '2026-09-14T00:00:00Z',
|
||||||
|
updated_at: '2026-09-14T00:00:00Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePage(url, errors = {}, generated = false) {
|
||||||
|
return {
|
||||||
|
component: url.startsWith('/settings/appearance')
|
||||||
|
? 'Appearance'
|
||||||
|
: 'Profile',
|
||||||
|
url,
|
||||||
|
version: null,
|
||||||
|
clearHistory: false,
|
||||||
|
encryptHistory: false,
|
||||||
|
props: {
|
||||||
|
name: 'KanbanGit',
|
||||||
|
errors,
|
||||||
|
sidebarOpen: true,
|
||||||
|
mustVerifyEmail: true,
|
||||||
|
status: null,
|
||||||
|
appearance: 'system',
|
||||||
|
auth: {
|
||||||
|
user: makeUser(generated),
|
||||||
|
permissions: {
|
||||||
|
globalAdmin: false,
|
||||||
|
projectCreate: false,
|
||||||
|
canManageTeams: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
navigation: { project: null },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serve) {
|
||||||
|
server.middlewares.use(async (request, response, next) => {
|
||||||
|
if (!request.url.startsWith('/settings')) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = request.url;
|
||||||
|
let errors = {};
|
||||||
|
let generated = false;
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
const result =
|
||||||
|
request.headers.cookie?.match(
|
||||||
|
/settings_test_result=(\w+)/,
|
||||||
|
)?.[1] ?? 'success';
|
||||||
|
if (result === 'server') {
|
||||||
|
response.statusCode = 503;
|
||||||
|
response.end('Simulated server failure');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result === 'validation') {
|
||||||
|
errors = {
|
||||||
|
avatar: 'The avatar must be a file of type: jpg, jpeg, png, webp.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
generated = request.url.startsWith('/settings/profile/avatar');
|
||||||
|
url = '/settings/profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = makePage(url, errors, generated);
|
||||||
|
response.setHeader('Vary', 'X-Inertia');
|
||||||
|
if (request.headers['x-inertia']) {
|
||||||
|
response.setHeader('X-Inertia', 'true');
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = JSON.stringify(page)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
response.setHeader('Content-Type', 'text/html');
|
||||||
|
response.end(
|
||||||
|
await server.transformIndexHtml(
|
||||||
|
request.url,
|
||||||
|
'<!doctype html><html lang="en" data-appearance="system"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><script>try{const value=localStorage.getItem("appearance");if(["light","dark","system"].includes(value)){document.documentElement.dataset.appearance=value}}catch{}</script></head><body><div id="app" data-page="' +
|
||||||
|
escaped +
|
||||||
|
'"></div><script type="module" src="/tests/Fixtures/settings-preview.mjs"></script></body></html>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
console.log(
|
||||||
|
'Settings fixture ready at http://127.0.0.1:4184/settings/profile',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { createLocalization } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/lib/localization.ts',
|
||||||
|
);
|
||||||
|
const { createAppearance } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/composables/useAppearance.ts',
|
||||||
|
);
|
||||||
|
const { en } = await server.ssrLoadModule(
|
||||||
|
'/resources/js/locales/en.ts',
|
||||||
|
);
|
||||||
|
const Profile = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/Profile.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const Appearance = (
|
||||||
|
await server.ssrLoadModule(
|
||||||
|
'/resources/js/pages/settings/Appearance.vue',
|
||||||
|
)
|
||||||
|
).default;
|
||||||
|
const render = async (
|
||||||
|
url,
|
||||||
|
component,
|
||||||
|
locale = createLocalization(),
|
||||||
|
) => {
|
||||||
|
const response = await createInertiaApp({
|
||||||
|
page: makePage(url),
|
||||||
|
resolve: () => component,
|
||||||
|
render: renderToString,
|
||||||
|
setup: ({ App, props, plugin }) =>
|
||||||
|
createSSRApp({ render: () => h(App, props) })
|
||||||
|
.use(plugin)
|
||||||
|
.use(locale)
|
||||||
|
.use(createAppearance('system')),
|
||||||
|
});
|
||||||
|
return response.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const profile = await render('/settings/profile', Profile);
|
||||||
|
assert.match(profile, /Account settings/);
|
||||||
|
assert.match(profile, /Profile picture/);
|
||||||
|
assert.match(profile, /Choose picture/);
|
||||||
|
assert.match(profile, /accept="image\/png,image\/jpeg,image\/webp"/);
|
||||||
|
assert.match(
|
||||||
|
profile,
|
||||||
|
/On mobile, you can select from your photo library or camera/,
|
||||||
|
);
|
||||||
|
assert.match(profile, /aria-current="page"[^>]*>[\s\S]*Profile/);
|
||||||
|
assert.match(profile, /Delete account/);
|
||||||
|
|
||||||
|
const appearance = await render('/settings/appearance', Appearance);
|
||||||
|
assert.equal((appearance.match(/name="appearance"/g) ?? []).length, 3);
|
||||||
|
assert.match(appearance, /value="light"/);
|
||||||
|
assert.match(appearance, /value="dark"/);
|
||||||
|
assert.match(appearance, /value="system"[^>]*checked/);
|
||||||
|
assert.match(appearance, /Follow the light or dark preference/);
|
||||||
|
assert.match(appearance, /aria-current="page"[^>]*>[\s\S]*Appearance/);
|
||||||
|
|
||||||
|
const localized = await render(
|
||||||
|
'/settings/profile',
|
||||||
|
Profile,
|
||||||
|
createLocalization(
|
||||||
|
{ locale: 'de' },
|
||||||
|
{
|
||||||
|
de: {
|
||||||
|
...en,
|
||||||
|
'settings.eyebrow': 'Kontoeinstellungen',
|
||||||
|
'profile.title': 'Profil',
|
||||||
|
'profile.choosePicture': 'Bild auswählen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.match(localized, /Kontoeinstellungen/);
|
||||||
|
assert.match(localized, /Profil/);
|
||||||
|
assert.match(localized, /Bild auswählen/);
|
||||||
|
console.log('Settings rendering and localization checks passed.');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user