App.vue components/AccountSection.vue components/BillingSection.vue components/DangerZone.vue components/NotificationsSection.vue components/ProfileSection.vue components/SaveBar.vue components/SectionNav.vue components/SettingsCard.vue components/ToggleSwitch.vue settings.ts ui.ts Copy
< script setup lang = "ts" >
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { clone, initialSettings, type SectionId } from './settings'
import AccountSection from './components/AccountSection.vue'
import BillingSection from './components/BillingSection.vue'
import DangerZone from './components/DangerZone.vue'
import NotificationsSection from './components/NotificationsSection.vue'
import ProfileSection from './components/ProfileSection.vue'
import SaveBar from './components/SaveBar.vue'
import SectionNav, { type NavSection } from './components/SectionNav.vue'
const sections : NavSection [] = [
{ id: 'profile' , label: 'Profile' , icon: 'M15.5 16.5v-.75a3.25 3.25 0 0 0-3.25-3.25h-4.5a3.25 3.25 0 0 0-3.25 3.25v.75M10 9.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z' },
{ id: 'account' , label: 'Account' , icon: 'M5 9V6.5a5 5 0 0 1 10 0V9M4.5 9h11a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1ZM10 12.25v1.5' },
{ id: 'notifications' , label: 'Notifications' , icon: 'M15 8a5 5 0 0 0-10 0c0 5.5-2 7-2 7h14s-2-1.5-2-7ZM11.4 17.5a1.6 1.6 0 0 1-2.8 0' },
{ id: 'billing' , label: 'Billing' , icon: 'M3 6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6ZM3 8.5h14M6 12h2.5' },
{ id: 'danger' , label: 'Danger zone' , icon: 'M10 7.5v3.5M10 14h.01M8.6 3.4 2.4 14a1.6 1.6 0 0 0 1.4 2.4h12.4a1.6 1.6 0 0 0 1.4-2.4L11.4 3.4a1.6 1.6 0 0 0-2.8 0Z' },
]
const current = ref < SectionId >( 'profile' )
// `saved` is what the server has; `form` is what the user is editing.
const saved = ref ( clone (initialSettings))
const form = reactive ( clone (initialSettings))
function leaves ( value : unknown , prefix = '' ) : Record < string , unknown > {
if (value === null || typeof value !== 'object' ) return { [prefix]: value }
return Object. entries (value). reduce < Record < string , unknown >>(( acc , [ k , v ]) => ({ ... acc, ... leaves (v, prefix ? `${ prefix }.${ k }` : k) }), {})
}
const changedCount = computed (() => {
const a = leaves (form), b = leaves (saved.value)
return Object. keys (a). filter ( k => a[k] !== b[k]). length
})
const dirty = computed (() => changedCount.value > 0 )
const saving = ref ( false )
const toast = ref ( '' )
let toastTimer : ReturnType < typeof setTimeout> | undefined
function notify ( message : string ) {
toast.value = message
clearTimeout (toastTimer)
toastTimer = setTimeout (() => { toast.value = '' }, 3000 )
}
async function save () {
if ( ! dirty.value || saving.value) return
saving.value = true
await new Promise ( resolve => setTimeout (resolve, 700 )) // replace with your API call
saved.value = clone (form)
saving.value = false
notify ( 'Settings saved' )
}
function discard () {
Object. assign (form, clone (saved.value))
}
function exportData () {
const blob = new Blob ([ JSON . stringify (saved.value, null , 2 )], { type: 'application/json' })
const link = Object. assign (document. createElement ( 'a' ), { href: URL . createObjectURL (blob), download: 'quillpad-settings.json' })
link. click ()
URL . revokeObjectURL (link.href)
}
// Cmd/Ctrl+S saves, and the browser warns before leaving with unsaved edits.
function onKey ( event : KeyboardEvent ) {
if ((event.metaKey || event.ctrlKey) && event.key. toLowerCase () === 's' ) {
event. preventDefault ()
save ()
}
}
function onBeforeUnload ( event : BeforeUnloadEvent ) {
if (dirty.value) event. preventDefault ()
}
onMounted (() => {
window. addEventListener ( 'keydown' , onKey)
window. addEventListener ( 'beforeunload' , onBeforeUnload)
})
onBeforeUnmount (() => {
window. removeEventListener ( 'keydown' , onKey)
window. removeEventListener ( 'beforeunload' , onBeforeUnload)
})
function select ( id : SectionId ) {
current.value = id
document. getElementById ( 'settings-panel' )?. focus ({ preventScroll: true })
window. scrollTo ({ top: 0 , behavior: 'smooth' })
}
const currentLabel = computed (() => sections. find ( s => s.id === current.value)?.label ?? '' )
</ script >
< template >
< div class = "min-h-screen bg-gray-50 text-gray-900 antialiased dark:bg-gray-950 dark:text-gray-100" >
< header class = "border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900" >
< div class = "mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 sm:px-6" >
< span class = "grid size-8 place-items-center rounded-lg bg-emerald-600 text-white" aria-hidden = "true" >
< svg viewBox = "0 0 20 20" class = "size-4.5" fill = "none" stroke = "currentColor" stroke-width = "1.8" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "M13.5 3.5 16.5 6.5 8 15l-4 1 1-4 8.5-8.5Z" /></ svg >
</ span >
< span class = "text-[15px] font-semibold tracking-tight" >Quillpad</ span >
< a href = "#" class = "ml-auto rounded-lg px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white" @click.prevent = "notify('This would take you back to your notes.')" >
← Back to notes
</ a >
</ div >
</ header >
< div class = "mx-auto max-w-6xl px-4 pt-8 pb-32 sm:px-6 lg:pt-10" >
< h1 class = "text-2xl font-semibold tracking-tight text-gray-900 sm:text-3xl dark:text-white" >Settings</ h1 >
< p class = "mt-1 text-sm text-gray-500 dark:text-gray-400" >Manage your profile, sign-in, notifications and subscription.</ p >
< div class = "mt-6 lg:mt-8 lg:grid lg:grid-cols-[13rem_1fr] lg:gap-10" >
< aside class = "lg:sticky lg:top-8 lg:self-start" >
< SectionNav : sections = " sections " : current = " current " @ select = " select " />
</ aside >
< main id = "settings-panel" tabindex = "-1" class = "mt-6 outline-none lg:mt-0" :aria-label = "`${currentLabel} settings`" >
< ProfileSection v-if = " current === 'profile' " v-model = " form.profile " />
< AccountSection v-else-if = " current === 'account' " v-model = " form.account " @ change-password = " notify ( 'A password reset link is on its way to your inbox.' ) " />
< NotificationsSection v-else-if = " current === 'notifications' " v-model = " form.notifications " />
< BillingSection v-else-if = " current === 'billing' " v-model = " form.billing " @ notify = " notify " />
< DangerZone
v-else
: username = " saved.profile.username "
@ export-data = " exportData "
@ deactivate = " notify ( 'Account deactivated. (Demo, nothing changed.)' ) "
@ delete-account = " notify ( 'Account scheduled for deletion. (Demo, nothing changed.)' ) "
/>
</ main >
</ div >
</ div >
< SaveBar : visible = " dirty " : saving = " saving " : changes = " changedCount " @ save = " save " @ discard = " discard " />
< Transition enter-from-class = "-translate-y-2 opacity-0" leave-to-class = "opacity-0" enter-active-class = "transition duration-200" leave-active-class = "transition duration-150" >
< div v-if = "toast" role = "status" class = "fixed top-4 left-1/2 z-50 flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 items-center gap-3 rounded-xl bg-white px-4 py-3 text-sm font-medium text-gray-900 shadow-lg ring-1 ring-gray-900/10 dark:bg-gray-800 dark:text-white dark:ring-white/10" >
< svg viewBox = "0 0 20 20" class = "size-5 shrink-0 text-emerald-600 dark:text-emerald-400" fill = "currentColor" aria-hidden = "true" >< path fill-rule = "evenodd" d = "M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.7-9.3a1 1 0 0 0-1.4-1.4L9 10.6 7.7 9.3a1 1 0 0 0-1.4 1.4l2 2a1 1 0 0 0 1.4 0l4-4Z" clip-rule = "evenodd" /></ svg >
{{ toast }}
</ div >
</ Transition >
</ div >
</ template >
< script setup lang = "ts" >
import type { Settings } from '../settings'
import { hintClass, inputClass, labelClass, secondaryButton } from '../ui'
import SettingsCard from './SettingsCard.vue'
import ToggleSwitch from './ToggleSwitch.vue'
const account = defineModel < Settings [ 'account' ]>({ required: true })
const emit = defineEmits <{ changePassword : [] }>()
</ script >
< template >
< div class = "space-y-6" >
< SettingsCard title = "Account" description = "Sign-in email and regional preferences." >
< div class = "grid gap-5 sm:grid-cols-2" >
< div class = "sm:col-span-2" >
< label for = "a-email" :class = "labelClass" >Email address</ label >
< input id = "a-email" v-model = "account.email" type = "email" autocomplete = "email" :class = "inputClass" aria-describedby = "a-email-hint" >
< p id = "a-email-hint" :class = "hintClass" >We’ll send a confirmation link to the new address before switching.</ p >
</ div >
< div >
< label for = "a-language" :class = "labelClass" >Language</ label >
< select id = "a-language" v-model = "account.language" :class = "inputClass" >
< option value = "en-GB" >English (UK)</ option >
< option value = "en-US" >English (US)</ option >
< option value = "pt-PT" >Português</ option >
< option value = "es-ES" >Español</ option >
< option value = "de-DE" >Deutsch</ option >
</ select >
</ div >
< div >
< label for = "a-timezone" :class = "labelClass" >Time zone</ label >
< select id = "a-timezone" v-model = "account.timezone" :class = "inputClass" >
< option value = "America/Los_Angeles" >Pacific Time (UTC−8)</ option >
< option value = "America/New_York" >Eastern Time (UTC−5)</ option >
< option value = "Europe/London" >London (UTC+0)</ option >
< option value = "Europe/Lisbon" >Lisbon (UTC+0)</ option >
< option value = "Europe/Berlin" >Berlin (UTC+1)</ option >
< option value = "Asia/Tokyo" >Tokyo (UTC+9)</ option >
</ select >
</ div >
</ div >
</ SettingsCard >
< SettingsCard title = "Security" >
< ul class = "divide-y divide-gray-100 dark:divide-white/5" >
< li class = "flex items-start justify-between gap-6 pb-5" >
< div >
< p id = "a-2fa-label" class = "text-sm font-medium text-gray-900 dark:text-white" >Two-factor authentication</ p >
< p id = "a-2fa-desc" class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >Ask for a code from your authenticator app when signing in on a new device.</ p >
</ div >
< ToggleSwitch v-model = " account.twoFactor " labelledby = "a-2fa-label" describedby = "a-2fa-desc" />
</ li >
< li class = "flex flex-wrap items-center justify-between gap-4 pt-5" >
< div >
< p class = "text-sm font-medium text-gray-900 dark:text-white" >Password</ p >
< p class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >Last changed 3 months ago.</ p >
</ div >
< button type = "button" :class = "secondaryButton" @click = "emit('changePassword')" >Change password</ button >
</ li >
</ ul >
</ SettingsCard >
</ div >
</ template >
< script setup lang = "ts" >
import { invoices, type Settings } from '../settings'
import { hintClass, inputClass, labelClass, primaryButton, secondaryButton } from '../ui'
import SettingsCard from './SettingsCard.vue'
const billing = defineModel < Settings [ 'billing' ]>({ required: true })
const emit = defineEmits <{ notify : [ message : string ] }>()
</ script >
< template >
< div class = "space-y-6" >
< SettingsCard title = "Plan" description = "You can change or cancel at any time." >
< div class = "flex flex-col gap-5 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 p-5 ring-1 ring-emerald-100 sm:flex-row sm:items-center sm:justify-between dark:from-emerald-500/10 dark:to-teal-500/5 dark:ring-emerald-500/20" >
< div >
< p class = "flex items-center gap-2" >
< span class = "text-lg font-semibold text-gray-900 dark:text-white" >Quillpad Pro</ span >
< span class = "rounded-full bg-emerald-600 px-2 py-0.5 text-xs font-semibold text-white" >Monthly</ span >
</ p >
< p class = "mt-1 text-sm text-gray-600 dark:text-gray-300" >< span class = "font-semibold text-gray-900 dark:text-white" >$12</ span > per month · renews on October 1, 2026</ p >
</ div >
< div class = "flex flex-wrap gap-2 [&>button]:whitespace-nowrap" >
< button type = "button" :class = "secondaryButton" @click = "emit('notify', 'Plan comparison would open here.')" >Compare plans</ button >
< button type = "button" :class = "primaryButton" @click = "emit('notify', 'Switched to yearly billing. (Demo)')" >Switch to yearly, save 20%</ button >
</ div >
</ div >
< dl class = "mt-6 grid gap-5 sm:grid-cols-2" >
< div >
< dt class = "flex justify-between text-sm" >< span class = "font-medium text-gray-900 dark:text-white" >Storage</ span >< span class = "text-gray-500 tabular-nums dark:text-gray-400" >6.4 of 20 GB</ span ></ dt >
< dd class = "mt-2 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10" role = "progressbar" aria-label = "Storage used" aria-valuenow = "32" aria-valuemin = "0" aria-valuemax = "100" >
< div class = "h-full w-[32%] rounded-full bg-emerald-500" / >
</ dd >
</ div >
< div >
< dt class = "flex justify-between text-sm" >< span class = "font-medium text-gray-900 dark:text-white" >Published notebooks</ span >< span class = "text-gray-500 tabular-nums dark:text-gray-400" >8 of 10</ span ></ dt >
< dd class = "mt-2 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10" role = "progressbar" aria-label = "Notebooks used" aria-valuenow = "80" aria-valuemin = "0" aria-valuemax = "100" >
< div class = "h-full w-[80%] rounded-full bg-amber-500" / >
</ dd >
</ div >
</ dl >
</ SettingsCard >
< SettingsCard title = "Payment details" >
< div class = "flex flex-wrap items-center justify-between gap-4 rounded-xl border border-gray-200 p-4 dark:border-white/10" >
< div class = "flex items-center gap-4" >
< span class = "grid h-10 w-14 place-items-center rounded-md bg-gradient-to-br from-gray-800 to-gray-950 text-[10px] font-bold tracking-widest text-white ring-1 ring-black/10 dark:from-gray-700 dark:to-gray-900" aria-hidden = "true" >CARD</ span >
< div >
< p class = "text-sm font-medium text-gray-900 dark:text-white" >Card ending in 4242</ p >
< p class = "text-sm text-gray-500 dark:text-gray-400" >Expires 08 / 2028</ p >
</ div >
</ div >
< button type = "button" :class = "secondaryButton" @click = "emit('notify', 'Card update form would open here.')" >Update card</ button >
</ div >
< div class = "mt-6 grid gap-5 sm:grid-cols-2" >
< div >
< label for = "b-email" :class = "labelClass" >Invoice email</ label >
< input id = "b-email" v-model = "billing.invoiceEmail" type = "email" :class = "inputClass" >
< p :class = "hintClass" >Receipts go here instead of your sign-in email.</ p >
</ div >
< div >
< label for = "b-tax" :class = "labelClass" >VAT / tax ID < span class = "font-normal text-gray-400" >(optional)</ span ></ label >
< input id = "b-tax" v-model = "billing.taxId" type = "text" placeholder = "PT123456789" :class = "inputClass" >
< p :class = "hintClass" >Added to future invoices.</ p >
</ div >
</ div >
</ SettingsCard >
< SettingsCard title = "Invoices" >
< div class = "-mx-5 -my-5 overflow-x-auto sm:-mx-6" >
< table class = "w-full text-left text-sm" >
< thead class = "bg-gray-50/80 text-xs text-gray-500 dark:bg-white/[0.02] dark:text-gray-400" >
< tr >
< th scope = "col" class = "py-2.5 pr-3 pl-5 font-medium sm:pl-6" >Invoice</ th >
< th scope = "col" class = "hidden px-3 py-2.5 font-medium sm:table-cell" >Date</ th >
< th scope = "col" class = "px-3 py-2.5 font-medium" >Amount</ th >
< th scope = "col" class = "px-3 py-2.5 font-medium" >Status</ th >
< th scope = "col" class = "py-2.5 pr-5 pl-3 sm:pr-6" >< span class = "sr-only" >Download</ span ></ th >
</ tr >
</ thead >
< tbody class = "divide-y divide-gray-100 dark:divide-white/5" >
< tr v-for = "inv in invoices" :key = "inv.id" >
< td class = "py-3 pr-3 pl-5 font-mono text-[13px] whitespace-nowrap text-gray-900 sm:pl-6 dark:text-white" >{{ inv.id }}</ td >
< td class = "hidden px-3 py-3 whitespace-nowrap text-gray-500 sm:table-cell dark:text-gray-400" >{{ inv.date }}</ td >
< td class = "px-3 py-3 text-gray-900 tabular-nums dark:text-white" >{{ inv.amount }}</ td >
< td class = "px-3 py-3" >
< span class = "rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset" :class = "inv.status === 'Paid' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20' : 'bg-gray-100 text-gray-600 ring-gray-500/20 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10'" >{{ inv.status }}</ span >
</ td >
< td class = "py-3 pr-5 pl-3 text-right sm:pr-6" >
< button type = "button" class = "rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-2 focus-visible:outline-emerald-600 dark:hover:bg-white/5 dark:hover:text-gray-200" :aria-label = "`Download ${inv.id}`" @click = "emit('notify', `${inv.id}.pdf would download here.`)" >
< svg viewBox = "0 0 20 20" fill = "none" stroke = "currentColor" stroke-width = "1.6" stroke-linecap = "round" stroke-linejoin = "round" class = "size-4.5" aria-hidden = "true" >< path d = "M10 3v10m0 0-3.5-3.5M10 13l3.5-3.5M4 14.5v1A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5v-1" /></ svg >
</ button >
</ td >
</ tr >
</ tbody >
</ table >
</ div >
</ SettingsCard >
</ div >
</ template >
< script setup lang = "ts" >
import { computed, ref } from 'vue'
import { inputClass, secondaryButton } from '../ui'
import SettingsCard from './SettingsCard.vue'
const props = defineProps <{ username : string }>()
const emit = defineEmits <{ exportData : []; deactivate : []; deleteAccount : [] }>()
// Native <dialog> gives us a real modal: focus is trapped, Esc closes, the page behind is inert.
const dialog = ref < HTMLDialogElement | null >( null )
const confirmText = ref ( '' )
const dangerInput = inputClass. replaceAll ( 'emerald' , 'rose' )
const canDelete = computed (() => confirmText.value. trim () === props.username)
function openDialog () {
confirmText.value = ''
dialog.value?. showModal ()
}
function closeDialog () {
dialog.value?. close ()
}
function confirmDelete () {
if ( ! canDelete.value) return
closeDialog ()
emit ( 'deleteAccount' )
}
</ script >
< template >
< SettingsCard title = "Danger zone" description = "Irreversible and destructive actions. Take a breath first." tone = "danger" >
< ul class = "divide-y divide-gray-100 dark:divide-white/5" >
< li class = "flex flex-wrap items-center justify-between gap-4 pb-5" >
< div class = "max-w-md" >
< p class = "text-sm font-medium text-gray-900 dark:text-white" >Export your data</ p >
< p class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >Download your settings as JSON. Notes export as Markdown from the editor.</ p >
</ div >
< button type = "button" :class = "secondaryButton" @click = "emit('exportData')" >Export data</ button >
</ li >
< li class = "flex flex-wrap items-center justify-between gap-4 py-5" >
< div class = "max-w-md" >
< p class = "text-sm font-medium text-gray-900 dark:text-white" >Deactivate account</ p >
< p class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >Hide your profile and notebooks. Sign back in any time to restore them.</ p >
</ div >
< button type = "button" :class = "secondaryButton" @click = "emit('deactivate')" >Deactivate</ button >
</ li >
< li class = "flex flex-wrap items-center justify-between gap-4 pt-5" >
< div class = "max-w-md" >
< p class = "text-sm font-medium text-gray-900 dark:text-white" >Delete account</ p >
< p class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >Permanently delete your account, notes and billing history. This cannot be undone.</ p >
</ div >
< button
type = "button"
class = "inline-flex items-center rounded-lg bg-rose-600 px-3.5 py-2 text-sm font-semibold text-white shadow-xs transition hover:bg-rose-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600"
aria-haspopup = "dialog"
@click = "openDialog"
>Delete account</ button >
</ li >
</ ul >
< dialog
ref = "dialog"
aria-labelledby = "delete-title"
aria-describedby = "delete-desc"
class = "m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl bg-white p-0 text-gray-900 shadow-2xl ring-1 ring-gray-900/5 backdrop:bg-gray-950/50 backdrop:backdrop-blur-sm dark:bg-gray-900 dark:text-white dark:ring-white/10"
@click.self = "closeDialog"
>
< form class = "p-6" @submit.prevent = "confirmDelete" >
< div class = "flex gap-4" >
< span class = "grid size-10 shrink-0 place-items-center rounded-full bg-rose-100 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400" aria-hidden = "true" >
< svg viewBox = "0 0 20 20" fill = "none" stroke = "currentColor" stroke-width = "1.8" stroke-linecap = "round" stroke-linejoin = "round" class = "size-5" >< path d = "M10 7.5v3.5M10 14h.01M8.6 3.4 2.4 14a1.6 1.6 0 0 0 1.4 2.4h12.4a1.6 1.6 0 0 0 1.4-2.4L11.4 3.4a1.6 1.6 0 0 0-2.8 0Z" /></ svg >
</ span >
< div >
< h3 id = "delete-title" class = "text-base font-semibold" >Delete your account?</ h3 >
< p id = "delete-desc" class = "mt-1 text-sm text-gray-600 dark:text-gray-400" >All 214 notes, 8 notebooks and your subscription will be removed within 24 hours. You’ll get a final receipt by email.</ p >
</ div >
</ div >
< label for = "delete-confirm" class = "mt-5 block text-sm text-gray-700 dark:text-gray-300" >
Type < span class = "rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[13px] text-gray-900 dark:bg-white/10 dark:text-white" >{{ username }}</ span > to confirm
</ label >
< input id = "delete-confirm" v-model = "confirmText" type = "text" autocomplete = "off" spellcheck = "false" :class = "dangerInput" >
< div class = "mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end" >
< button type = "button" :class = "secondaryButton" @click = "closeDialog" >Cancel</ button >
< button
type = "submit"
class = "inline-flex items-center justify-center rounded-lg bg-rose-600 px-3.5 py-2 text-sm font-semibold text-white shadow-xs transition hover:bg-rose-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 disabled:cursor-not-allowed disabled:opacity-40"
:disabled = "!canDelete"
>Delete permanently</ button >
</ div >
</ form >
</ dialog >
</ SettingsCard >
</ template >
< script setup lang = "ts" >
import { notificationRows, type Channel, type Settings } from '../settings'
import SettingsCard from './SettingsCard.vue'
import ToggleSwitch from './ToggleSwitch.vue'
const notifications = defineModel < Settings [ 'notifications' ]>({ required: true })
const channels : { id : Channel ; label : string }[] = [
{ id: 'email' , label: 'Email' },
{ id: 'push' , label: 'Push' },
]
const digestOptions : { id : Settings [ 'notifications' ][ 'digest' ]; label : string ; description : string }[] = [
{ id: 'off' , label: 'Off' , description: 'No summary emails' },
{ id: 'daily' , label: 'Daily' , description: 'Every morning at 8:00' },
{ id: 'weekly' , label: 'Weekly' , description: 'Mondays at 8:00' },
]
function setAll ( channel : Channel , value : boolean ) {
for ( const row of notificationRows) notifications.value.channels[channel][row.key] = value
}
const allOn = ( channel : Channel ) => notificationRows. every ( r => notifications.value.channels[channel][r.key])
</ script >
< template >
< div class = "space-y-6" >
< SettingsCard title = "Notifications" description = "Choose what you hear about, and where." >
< table class = "w-full text-sm" >
< thead >
< tr class = "text-left text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400" >
< th scope = "col" class = "pb-3 font-medium" >Activity</ th >
< th v-for = "c in channels" :key = "c.id" scope = "col" class = "w-16 pb-3 text-center font-medium sm:w-24" >
{{ c.label }}
< button
type = "button"
class = "mx-auto mt-1 block rounded text-[11px] font-semibold tracking-normal text-emerald-700 normal-case hover:underline focus-visible:outline-2 focus-visible:outline-emerald-600 dark:text-emerald-400"
:aria-label = "`${allOn(c.id) ? 'Turn off' : 'Turn on'} all ${c.label.toLowerCase()} notifications`"
@click = "setAll(c.id, !allOn(c.id))"
>{{ allOn(c.id) ? 'None' : 'All' }}</ button >
</ th >
</ tr >
</ thead >
< tbody class = "divide-y divide-gray-100 dark:divide-white/5" >
< tr v-for = "row in notificationRows" :key = "row.key" >
< th scope = "row" class = "py-4 pr-4 text-left font-normal" >
< span class = "block font-medium text-gray-900 dark:text-white" >{{ row.label }}</ span >
< span class = "mt-0.5 block text-gray-500 dark:text-gray-400" >{{ row.description }}</ span >
</ th >
< td v-for = "c in channels" :key = "c.id" class = "py-4 text-center" >
< ToggleSwitch v-model = " notifications.channels[c.id][row.key] " : label = " `${ row . label } by ${ c . label . toLowerCase () }` " />
</ td >
</ tr >
</ tbody >
</ table >
</ SettingsCard >
< SettingsCard title = "Digest" description = "A summary of activity on your notes." >
< fieldset >
< legend class = "sr-only" >Digest frequency</ legend >
< div class = "grid gap-3 sm:grid-cols-3" >
< label
v-for = "o in digestOptions"
:key = "o.id"
class = "relative flex cursor-pointer flex-col rounded-xl border p-4 transition has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-emerald-600"
:class = "notifications.digest === o.id
? 'border-emerald-500 bg-emerald-50/60 ring-1 ring-emerald-500 dark:border-emerald-400 dark:bg-emerald-500/10 dark:ring-emerald-400'
: 'border-gray-200 hover:border-gray-300 dark:border-white/10 dark:hover:border-white/20'"
>
< input v-model = "notifications.digest" type = "radio" name = "digest" :value = "o.id" class = "sr-only" >
< span class = "text-sm font-semibold text-gray-900 dark:text-white" >{{ o.label }}</ span >
< span class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >{{ o.description }}</ span >
< svg v-if = "notifications.digest === o.id" viewBox = "0 0 20 20" class = "absolute top-4 right-4 size-5 text-emerald-600 dark:text-emerald-400" fill = "currentColor" aria-hidden = "true" >
< path fill-rule = "evenodd" d = "M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.7-9.3a1 1 0 0 0-1.4-1.4L9 10.6 7.7 9.3a1 1 0 0 0-1.4 1.4l2 2a1 1 0 0 0 1.4 0l4-4Z" clip-rule = "evenodd" />
</ svg >
</ label >
</ div >
</ fieldset >
</ SettingsCard >
</ div >
</ template >
< script setup lang = "ts" >
import { computed, onBeforeUnmount, ref } from 'vue'
import type { Settings } from '../settings'
import { hintClass, inputClass, labelClass, secondaryButton } from '../ui'
import SettingsCard from './SettingsCard.vue'
const profile = defineModel < Settings [ 'profile' ]>({ required: true })
const BIO_MAX = 160
const initials = computed (() => profile.value.name. split ( / \s + / ). map ( p => p[ 0 ] ?? '' ). join ( '' ). slice ( 0 , 2 ). toUpperCase () || '?' )
// Local preview only; upload the File to your storage in a real app.
const photo = ref < string | null >( null )
const fileInput = ref < HTMLInputElement | null >( null )
function onFile ( event : Event ) {
const file = (event.target as HTMLInputElement ).files?.[ 0 ]
if ( ! file) return
if (photo.value) URL . revokeObjectURL (photo.value)
photo.value = URL . createObjectURL (file)
}
function removePhoto () {
if (photo.value) URL . revokeObjectURL (photo.value)
photo.value = null
if (fileInput.value) fileInput.value.value = ''
}
onBeforeUnmount (() => { if (photo.value) URL . revokeObjectURL (photo.value) })
</ script >
< template >
< SettingsCard title = "Profile" description = "This is how other people see you on Quillpad." >
< div class = "space-y-6" >
< div class = "flex flex-wrap items-center gap-5" >
< div class = "grid size-16 shrink-0 place-items-center overflow-hidden rounded-full bg-gradient-to-br from-emerald-400 to-teal-600 text-xl font-semibold text-white ring-4 ring-emerald-50 dark:ring-emerald-500/10" >
< img v-if = "photo" :src = "photo" alt = "Your new profile photo" class = "size-full object-cover" >
< span v-else aria-hidden = "true" >{{ initials }}</ span >
</ div >
< div >
< div class = "flex flex-wrap gap-2" >
< label :class = "secondaryButton" class = "cursor-pointer focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-emerald-600" >
Upload photo
< input ref = "fileInput" type = "file" accept = "image/*" class = "sr-only" @change = "onFile" >
</ label >
< button v-if = "photo" type = "button" class = "rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5" @click = "removePhoto" >Remove</ button >
</ div >
< p :class = "hintClass" >Square JPG or PNG, at least 256 × 256.</ p >
</ div >
</ div >
< div class = "grid gap-5 sm:grid-cols-2" >
< div >
< label for = "p-name" :class = "labelClass" >Display name</ label >
< input id = "p-name" v-model = "profile.name" type = "text" autocomplete = "name" required :class = "inputClass" >
</ div >
< div >
< label for = "p-pronouns" :class = "labelClass" >Pronouns < span class = "font-normal text-gray-400" >(optional)</ span ></ label >
< input id = "p-pronouns" v-model = "profile.pronouns" type = "text" :class = "inputClass" placeholder = "e.g. they/them" >
</ div >
< div >
< label for = "p-username" :class = "labelClass" >Username</ label >
< div class = "mt-1.5 flex rounded-lg shadow-xs" >
< span class = "inline-flex items-center rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-gray-400" >@</ span >
< input id = "p-username" v-model = "profile.username" type = "text" autocomplete = "username" spellcheck = "false" :class = "inputClass" class = "mt-0! rounded-l-none shadow-none!" aria-describedby = "p-username-hint" >
</ div >
< p id = "p-username-hint" :class = "hintClass" >quillpad.example/@{{ profile.username || 'username' }}</ p >
</ div >
< div >
< label for = "p-website" :class = "labelClass" >Website</ label >
< div class = "mt-1.5 flex rounded-lg shadow-xs" >
< span class = "inline-flex items-center rounded-l-lg border border-r-0 border-gray-300 bg-gray-50 px-3 text-sm text-gray-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-gray-400" >https://</ span >
< input id = "p-website" v-model = "profile.website" type = "text" inputmode = "url" spellcheck = "false" :class = "inputClass" class = "mt-0! rounded-l-none shadow-none!" >
</ div >
</ div >
< div class = "sm:col-span-2" >
< div class = "flex items-baseline justify-between" >
< label for = "p-bio" :class = "labelClass" >Bio</ label >
< span class = "text-xs tabular-nums" :class = "profile.bio.length > BIO_MAX - 20 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400'" aria-live = "polite" >{{ profile.bio.length }}/{{ BIO_MAX }}</ span >
</ div >
< textarea id = "p-bio" v-model = "profile.bio" rows = "3" :maxlength = "BIO_MAX" :class = "inputClass" class = "resize-none" / >
< p :class = "hintClass" >Shown on your public notebook. Plain text, no links.</ p >
</ div >
</ div >
</ div >
</ SettingsCard >
</ template >
< script setup lang = "ts" >
defineProps <{ visible : boolean ; saving : boolean ; changes : number }>()
const emit = defineEmits <{ save : []; discard : [] }>()
</ script >
< template >
< Transition
enter-from-class = "translate-y-6 opacity-0"
leave-to-class = "translate-y-6 opacity-0"
enter-active-class = "transition duration-200 ease-out"
leave-active-class = "transition duration-150 ease-in"
>
< div v-if = "visible" class = "fixed inset-x-0 bottom-0 z-40 px-4 pb-4 sm:pb-6" role = "region" aria-label = "Unsaved changes" >
< div class = "mx-auto flex max-w-2xl items-center gap-3 rounded-2xl bg-gray-900 py-3 pr-3 pl-4 text-white shadow-2xl shadow-gray-900/20 ring-1 ring-white/10 sm:pl-5 dark:bg-gray-800" >
< span class = "relative flex size-2 shrink-0" aria-hidden = "true" >
< span class = "absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-60 motion-reduce:animate-none" / >
< span class = "relative inline-flex size-2 rounded-full bg-amber-400" / >
</ span >
< p class = "min-w-0 flex-1 text-sm" aria-live = "polite" >
< span class = "font-medium" >Unsaved changes</ span >
< span class = "hidden text-gray-400 sm:inline" > · {{ changes }} {{ changes === 1 ? 'field' : 'fields' }} edited</ span >
</ p >
< button
type = "button"
class = "rounded-lg px-3 py-2 text-sm font-medium text-gray-300 transition hover:bg-white/10 hover:text-white focus-visible:outline-2 focus-visible:outline-emerald-400 disabled:opacity-50"
:disabled = "saving"
@click = "emit('discard')"
>Discard</ button >
< button
type = "button"
class = "inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-3.5 py-2 text-sm font-semibold text-gray-950 transition hover:bg-emerald-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-400 disabled:opacity-70"
:disabled = "saving"
@click = "emit('save')"
>
< svg v-if = "saving" viewBox = "0 0 24 24" class = "size-4 animate-spin" fill = "none" aria-hidden = "true" >
< circle cx = "12" cy = "12" r = "9" stroke = "currentColor" stroke-opacity = "0.25" stroke-width = "3" />
< path d = "M21 12a9 9 0 0 0-9-9" stroke = "currentColor" stroke-width = "3" stroke-linecap = "round" />
</ svg >
{{ saving ? 'Saving…' : 'Save changes' }}
< kbd v-if = "!saving" class = "hidden rounded bg-gray-950/15 px-1 font-sans text-[11px] sm:inline" >⌘S</ kbd >
</ button >
</ div >
</ div >
</ Transition >
</ template >
< script setup lang = "ts" >
import type { SectionId } from '../settings'
export interface NavSection { id : SectionId ; label : string ; icon : string }
defineProps <{ sections : NavSection []; current : SectionId }>()
const emit = defineEmits <{ select : [ id : SectionId ] }>()
function onSelect ( event : MouseEvent , id : SectionId ) {
// On mobile the nav is a horizontal strip; keep the chosen item in view.
(event.currentTarget as HTMLElement ). scrollIntoView ({ block: 'nearest' , inline: 'nearest' , behavior: 'smooth' })
emit ( 'select' , id)
}
</ script >
< template >
< nav aria-label = "Settings sections" >
< ul class = "-mx-4 flex gap-1 overflow-x-auto px-4 pb-1 lg:mx-0 lg:flex-col lg:overflow-visible lg:px-0 lg:pb-0" >
< li v-for = "s in sections" :key = "s.id" class = "shrink-0" >
< button
type = "button"
class = "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium whitespace-nowrap transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
:class = "current === s.id
? (s.id === 'danger' ? 'bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300' : 'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/10')
: (s.id === 'danger' ? 'text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-500/10' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-white')"
:aria-current = "current === s.id ? 'page' : undefined"
@click = "onSelect($event, s.id)"
>
< svg viewBox = "0 0 20 20" fill = "none" stroke = "currentColor" stroke-width = "1.6" stroke-linecap = "round" stroke-linejoin = "round" class = "size-5 shrink-0" :class = "current === s.id && s.id !== 'danger' ? 'text-emerald-600 dark:text-emerald-400' : 'opacity-70'" aria-hidden = "true" >
< path :d = "s.icon" />
</ svg >
{{ s.label }}
</ button >
</ li >
</ ul >
</ nav >
</ template >
< script setup lang = "ts" >
defineProps <{ title : string ; description ?: string ; tone ?: 'default' | 'danger' }>()
</ script >
< template >
< section
class = "overflow-hidden rounded-2xl border bg-white shadow-xs dark:bg-gray-900"
:class = "tone === 'danger' ? 'border-rose-200 dark:border-rose-500/30' : 'border-gray-200 dark:border-white/10'"
>
< header class = "border-b px-5 py-4 sm:px-6" :class = "tone === 'danger' ? 'border-rose-100 dark:border-rose-500/20' : 'border-gray-100 dark:border-white/5'" >
< h2 class = "text-base font-semibold" :class = "tone === 'danger' ? 'text-rose-700 dark:text-rose-400' : 'text-gray-900 dark:text-white'" >{{ title }}</ h2 >
< p v-if = "description" class = "mt-0.5 text-sm text-gray-500 dark:text-gray-400" >{{ description }}</ p >
</ header >
< div class = "px-5 py-5 sm:px-6" >
< slot / >
</ div >
< footer v-if = "$slots.footer" class = "border-t border-gray-100 bg-gray-50/60 px-5 py-3 text-sm sm:px-6 dark:border-white/5 dark:bg-white/[0.02]" >
< slot name = "footer" / >
</ footer >
</ section >
</ template >
< script setup lang = "ts" >
const enabled = defineModel < boolean >({ required: true })
defineProps <{
/** Accessible name. Hidden visually when `labelledby` points at a visible label instead. */
label ?: string
labelledby ?: string
describedby ?: string
}>()
</ script >
< template >
< button
type = "button"
role = "switch"
:aria-checked = "enabled"
:aria-label = "labelledby ? undefined : label"
:aria-labelledby = "labelledby"
:aria-describedby = "describedby"
class = "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full p-0.5 transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600"
:class = "enabled ? 'bg-emerald-600 dark:bg-emerald-500' : 'bg-gray-200 dark:bg-white/15'"
@click = "enabled = !enabled"
>
< span
class = "grid size-5 place-items-center rounded-full bg-white shadow-sm ring-1 ring-gray-900/5 transition-transform duration-200"
:class = "enabled ? 'translate-x-5' : 'translate-x-0'"
aria-hidden = "true"
>
< svg v-if = "enabled" viewBox = "0 0 12 12" class = "size-3 text-emerald-600" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" >< path d = "m3 6.2 2 2L9 4" /></ svg >
</ span >
</ button >
</ template >
// The settings model and its starting values. Load these from your API instead.
export type SectionId = 'profile' | 'account' | 'notifications' | 'billing' | 'danger'
export type Channel = 'email' | 'push'
export type NotificationKey = 'comments' | 'mentions' | 'followers' | 'productUpdates'
export interface Settings {
profile : {
name : string
username : string
bio : string
website : string
pronouns : string
}
account : {
email : string
language : string
timezone : string
twoFactor : boolean
}
notifications : {
channels : Record < Channel , Record < NotificationKey , boolean >>
digest : 'off' | 'daily' | 'weekly'
}
billing : {
invoiceEmail : string
taxId : string
}
}
export const initialSettings : Settings = {
profile: {
name: 'Rosa Delgado' ,
username: 'rosadelgado' ,
bio: 'Product designer writing about tools, type and slow software. Currently sketching in Lisbon.' ,
website: 'rosadelgado.example' ,
pronouns: 'she/her' ,
},
account: {
email: 'rosa@quillpad.example' ,
language: 'en-GB' ,
timezone: 'Europe/Lisbon' ,
twoFactor: true ,
},
notifications: {
channels: {
email: { comments: true , mentions: true , followers: false , productUpdates: false },
push: { comments: true , mentions: true , followers: true , productUpdates: false },
},
digest: 'weekly' ,
},
billing: {
invoiceEmail: 'accounts@delgado-studio.example' ,
taxId: '' ,
},
}
export const notificationRows : { key : NotificationKey ; label : string ; description : string }[] = [
{ key: 'comments' , label: 'Comments' , description: 'Someone replies to a note you published.' },
{ key: 'mentions' , label: 'Mentions' , description: 'Someone mentions @you in a note or comment.' },
{ key: 'followers' , label: 'New followers' , description: 'Someone starts following your notebook.' },
{ key: 'productUpdates' , label: 'Product news' , description: 'New Quillpad features, at most once a month.' },
]
export interface Invoice { id : string ; date : string ; amount : string ; status : 'Paid' | 'Refunded' }
export const invoices : Invoice [] = [
{ id: 'INV-2026-09' , date: 'Sep 1, 2026' , amount: '$12.00' , status: 'Paid' },
{ id: 'INV-2026-08' , date: 'Aug 1, 2026' , amount: '$12.00' , status: 'Paid' },
{ id: 'INV-2026-07' , date: 'Jul 1, 2026' , amount: '$12.00' , status: 'Paid' },
{ id: 'INV-2026-06' , date: 'Jun 1, 2026' , amount: '$12.00' , status: 'Refunded' },
]
export const clone = < T >( value : T ) : T => JSON . parse ( JSON . stringify (value)) as T
// Shared Tailwind class strings for form controls, so every field looks the same.
export const labelClass = 'block text-sm font-medium text-gray-800 dark:text-gray-200'
export const inputClass = 'mt-1.5 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-xs outline-none transition placeholder:text-gray-400 focus:border-emerald-500 focus:ring-3 focus:ring-emerald-500/20 dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:border-emerald-400'
export const hintClass = 'mt-1.5 text-xs text-gray-500 dark:text-gray-400'
export const secondaryButton = 'inline-flex items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm font-semibold text-gray-800 shadow-xs transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10'
export const primaryButton = 'inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3.5 py-2 text-sm font-semibold text-white shadow-xs transition hover:bg-emerald-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 disabled:cursor-not-allowed disabled:opacity-50'