Merge pull request #21 from jedmund/devin/1763997845-admin-form-unification
Phase 1: Admin Form System Unification
This commit is contained in:
commit
3ec59dc996
6 changed files with 266 additions and 399 deletions
|
|
@ -8,7 +8,7 @@ export interface AutoSaveStoreOptions<TPayload, TResponse = unknown> {
|
||||||
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
onSaved?: (res: TResponse, ctx: { prime: (payload: TPayload) => void }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoSaveStore<TPayload> {
|
export interface AutoSaveStore<TPayload, _TResponse = unknown> {
|
||||||
readonly status: AutoSaveStatus
|
readonly status: AutoSaveStatus
|
||||||
readonly lastError: string | null
|
readonly lastError: string | null
|
||||||
schedule: () => void
|
schedule: () => void
|
||||||
|
|
@ -36,7 +36,7 @@ export interface AutoSaveStore<TPayload> {
|
||||||
*/
|
*/
|
||||||
export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
export function createAutoSaveStore<TPayload, TResponse = unknown>(
|
||||||
opts: AutoSaveStoreOptions<TPayload, TResponse>
|
opts: AutoSaveStoreOptions<TPayload, TResponse>
|
||||||
): AutoSaveStore<TPayload> {
|
): AutoSaveStore<TPayload, unknown> {
|
||||||
const debounceMs = opts.debounceMs ?? 2000
|
const debounceMs = opts.debounceMs ?? 2000
|
||||||
const idleResetMs = opts.idleResetMs ?? 2000
|
const idleResetMs = opts.idleResetMs ?? 2000
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
import { loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
||||||
|
|
||||||
export function useDraftRecovery<TPayload>(options: {
|
export function useDraftRecovery<TPayload>(options: {
|
||||||
draftKey: string | null
|
draftKey: () => string | null
|
||||||
onRestore: (payload: TPayload) => void
|
onRestore: (payload: TPayload) => void
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -17,9 +17,10 @@ export function useDraftRecovery<TPayload>(options: {
|
||||||
|
|
||||||
// Auto-detect draft on mount using $effect
|
// Auto-detect draft on mount using $effect
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!options.draftKey || options.enabled === false) return
|
const key = options.draftKey()
|
||||||
|
if (!key || options.enabled === false) return
|
||||||
|
|
||||||
const draft = loadDraft<TPayload>(options.draftKey)
|
const draft = loadDraft<TPayload>(key)
|
||||||
if (draft) {
|
if (draft) {
|
||||||
showPrompt = true
|
showPrompt = true
|
||||||
draftTimestamp = draft.ts
|
draftTimestamp = draft.ts
|
||||||
|
|
@ -43,19 +44,21 @@ export function useDraftRecovery<TPayload>(options: {
|
||||||
draftTimeText,
|
draftTimeText,
|
||||||
|
|
||||||
restore() {
|
restore() {
|
||||||
if (!options.draftKey) return
|
const key = options.draftKey()
|
||||||
const draft = loadDraft<TPayload>(options.draftKey)
|
if (!key) return
|
||||||
|
const draft = loadDraft<TPayload>(key)
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
|
|
||||||
options.onRestore(draft.payload)
|
options.onRestore(draft.payload)
|
||||||
showPrompt = false
|
showPrompt = false
|
||||||
clearDraft(options.draftKey)
|
clearDraft(key)
|
||||||
},
|
},
|
||||||
|
|
||||||
dismiss() {
|
dismiss() {
|
||||||
if (!options.draftKey) return
|
const key = options.draftKey()
|
||||||
|
if (!key) return
|
||||||
showPrompt = false
|
showPrompt = false
|
||||||
clearDraft(options.draftKey)
|
clearDraft(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { beforeNavigate } from '$app/navigation'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
|
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
|
|
||||||
export function useFormGuards(autoSave: AutoSaveStore<unknown, unknown> | null) {
|
export function useFormGuards<TPayload = unknown, _TResponse = unknown>(
|
||||||
|
autoSave: AutoSaveStore<TPayload, unknown> | null
|
||||||
|
) {
|
||||||
if (!autoSave) return // No guards needed for create mode
|
if (!autoSave) return // No guards needed for create mode
|
||||||
|
|
||||||
// Navigation guard: flush autosave before route change
|
// Navigation guard: flush autosave before route change
|
||||||
|
|
@ -21,8 +23,12 @@ export function useFormGuards(autoSave: AutoSaveStore<unknown, unknown> | null)
|
||||||
|
|
||||||
// Warn before closing browser tab/window if unsaved changes
|
// Warn before closing browser tab/window if unsaved changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Capture autoSave in closure to avoid non-null assertions
|
||||||
|
const store = autoSave
|
||||||
|
if (!store) return
|
||||||
|
|
||||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
if (autoSave!.status !== 'saved') {
|
if (store.status !== 'saved') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.returnValue = ''
|
event.returnValue = ''
|
||||||
}
|
}
|
||||||
|
|
@ -34,13 +40,17 @@ export function useFormGuards(autoSave: AutoSaveStore<unknown, unknown> | null)
|
||||||
|
|
||||||
// Cmd/Ctrl+S keyboard shortcut for immediate save
|
// Cmd/Ctrl+S keyboard shortcut for immediate save
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
// Capture autoSave in closure to avoid non-null assertions
|
||||||
|
const store = autoSave
|
||||||
|
if (!store) return
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
const isModifier = event.metaKey || event.ctrlKey
|
const isModifier = event.metaKey || event.ctrlKey
|
||||||
|
|
||||||
if (isModifier && key === 's') {
|
if (isModifier && key === 's') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
autoSave!.flush().catch((error) => {
|
store.flush().catch((error) => {
|
||||||
console.error('Autosave flush failed:', error)
|
console.error('Autosave flush failed:', error)
|
||||||
toast.error('Failed to save changes')
|
toast.error('Failed to save changes')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,15 @@
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import DropdownSelectField from './DropdownSelectField.svelte'
|
import DropdownSelectField from './DropdownSelectField.svelte'
|
||||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||||
|
import DraftPrompt from './DraftPrompt.svelte'
|
||||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||||
import SmartImage from '../SmartImage.svelte'
|
import SmartImage from '../SmartImage.svelte'
|
||||||
import Composer from './composer'
|
import Composer from './composer'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
|
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
|
||||||
|
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
|
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
|
||||||
|
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
|
||||||
import type { Album, Media } from '@prisma/client'
|
import type { Album, Media } from '@prisma/client'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
|
@ -33,6 +38,7 @@
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let isLoading = $state(mode === 'edit')
|
let isLoading = $state(mode === 'edit')
|
||||||
|
let hasLoaded = $state(mode === 'create')
|
||||||
let _isSaving = $state(false)
|
let _isSaving = $state(false)
|
||||||
let _validationErrors = $state<Record<string, string>>({})
|
let _validationErrors = $state<Record<string, string>>({})
|
||||||
let showBulkAlbumModal = $state(false)
|
let showBulkAlbumModal = $state(false)
|
||||||
|
|
@ -40,6 +46,13 @@
|
||||||
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
|
let editorInstance = $state<{ save: () => Promise<JSONContent>; clear: () => void } | undefined>()
|
||||||
let activeTab = $state('metadata')
|
let activeTab = $state('metadata')
|
||||||
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation
|
let pendingMediaIds = $state<number[]>([]) // Photos to add after album creation
|
||||||
|
let updatedAt = $state<string | undefined>(
|
||||||
|
album?.updatedAt
|
||||||
|
? typeof album.updatedAt === 'string'
|
||||||
|
? album.updatedAt
|
||||||
|
: album.updatedAt.toISOString()
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
|
||||||
const tabOptions = [
|
const tabOptions = [
|
||||||
{ value: 'metadata', label: 'Metadata' },
|
{ value: 'metadata', label: 'Metadata' },
|
||||||
|
|
@ -73,6 +86,76 @@
|
||||||
// Derived state for existing media IDs
|
// Derived state for existing media IDs
|
||||||
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id))
|
const existingMediaIds = $derived(albumMedia.map((item) => item.media.id))
|
||||||
|
|
||||||
|
// Draft key for autosave fallback
|
||||||
|
const draftKey = $derived(mode === 'edit' && album ? makeDraftKey('album', album.id) : null)
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
title: formData.title,
|
||||||
|
slug: formData.slug,
|
||||||
|
description: null,
|
||||||
|
date: formData.year || null,
|
||||||
|
location: formData.location || null,
|
||||||
|
showInUniverse: formData.showInUniverse,
|
||||||
|
status: formData.status,
|
||||||
|
content: formData.content,
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autosave store (edit mode only)
|
||||||
|
// Initialized as null and created reactively when album data becomes available
|
||||||
|
let autoSave = $state<ReturnType<typeof createAutoSaveStore<ReturnType<typeof buildPayload>, Album>> | null>(null)
|
||||||
|
|
||||||
|
// INITIALIZATION ORDER:
|
||||||
|
// 1. This effect creates autoSave when album prop becomes available
|
||||||
|
// 2. useFormGuards is called immediately after creation (same effect)
|
||||||
|
// 3. Other effects check for autoSave existence before using it
|
||||||
|
$effect(() => {
|
||||||
|
// Create autoSave when album becomes available (only once)
|
||||||
|
if (mode === 'edit' && album && !autoSave) {
|
||||||
|
const albumId = album.id // Capture album ID to avoid null reference
|
||||||
|
autoSave = createAutoSaveStore({
|
||||||
|
debounceMs: 2000,
|
||||||
|
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||||
|
save: async (payload, { signal }) => {
|
||||||
|
const response = await fetch(`/api/albums/${albumId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Failed to save')
|
||||||
|
return await response.json()
|
||||||
|
},
|
||||||
|
onSaved: (saved: Album, { prime }) => {
|
||||||
|
updatedAt =
|
||||||
|
typeof saved.updatedAt === 'string' ? saved.updatedAt : saved.updatedAt.toISOString()
|
||||||
|
prime(buildPayload())
|
||||||
|
if (draftKey) clearDraft(draftKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form guards (navigation protection, Cmd+S, beforeunload)
|
||||||
|
useFormGuards(autoSave)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Draft recovery helper
|
||||||
|
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
|
||||||
|
draftKey: () => draftKey,
|
||||||
|
onRestore: (payload) => {
|
||||||
|
formData.title = payload.title ?? formData.title
|
||||||
|
formData.slug = payload.slug ?? formData.slug
|
||||||
|
formData.status = payload.status ?? formData.status
|
||||||
|
formData.year = payload.date ?? formData.year
|
||||||
|
formData.location = payload.location ?? formData.location
|
||||||
|
formData.showInUniverse = payload.showInUniverse ?? formData.showInUniverse
|
||||||
|
formData.content = payload.content ?? formData.content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Watch for album changes and populate form data
|
// Watch for album changes and populate form data
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album && mode === 'edit') {
|
if (album && mode === 'edit') {
|
||||||
|
|
@ -93,6 +176,49 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Prime autosave on initial load (edit mode only)
|
||||||
|
$effect(() => {
|
||||||
|
if (mode === 'edit' && album && !hasLoaded && autoSave) {
|
||||||
|
autoSave.prime(buildPayload())
|
||||||
|
hasLoaded = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger autosave when form data changes
|
||||||
|
// Using `void` operator to explicitly track dependencies without using their values
|
||||||
|
// This effect re-runs whenever any of these form fields change
|
||||||
|
$effect(() => {
|
||||||
|
void formData.title
|
||||||
|
void formData.slug
|
||||||
|
void formData.status
|
||||||
|
void formData.year
|
||||||
|
void formData.location
|
||||||
|
void formData.showInUniverse
|
||||||
|
void formData.content
|
||||||
|
void activeTab
|
||||||
|
if (hasLoaded && autoSave) {
|
||||||
|
autoSave.schedule()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save draft only when autosave fails
|
||||||
|
$effect(() => {
|
||||||
|
if (hasLoaded && autoSave && draftKey) {
|
||||||
|
const saveStatus = autoSave.status
|
||||||
|
if (saveStatus === 'error' || saveStatus === 'offline') {
|
||||||
|
saveDraft(draftKey, buildPayload())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cleanup autosave on unmount
|
||||||
|
$effect(() => {
|
||||||
|
if (autoSave) {
|
||||||
|
const instance = autoSave
|
||||||
|
return () => instance.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function populateFormData(data: Album) {
|
function populateFormData(data: Album) {
|
||||||
formData = {
|
formData = {
|
||||||
title: data.title || '',
|
title: data.title || '',
|
||||||
|
|
@ -275,13 +401,21 @@
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{#if !isLoading}
|
{#if !isLoading}
|
||||||
<AutoSaveStatus
|
<AutoSaveStatus
|
||||||
status="idle"
|
status={autoSave?.status ?? 'idle'}
|
||||||
lastSavedAt={album?.updatedAt}
|
lastSavedAt={album?.updatedAt}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{#if draftRecovery.showPrompt}
|
||||||
|
<DraftPrompt
|
||||||
|
timeAgo={draftRecovery.draftTimeText}
|
||||||
|
onRestore={draftRecovery.restore}
|
||||||
|
onDismiss={draftRecovery.dismiss}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="loading">Loading album...</div>
|
<div class="loading">Loading album...</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, beforeNavigate } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import AdminPage from './AdminPage.svelte'
|
import AdminPage from './AdminPage.svelte'
|
||||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||||
import Editor from './Editor.svelte'
|
import Editor from './Editor.svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import Input from './Input.svelte'
|
import Input from './Input.svelte'
|
||||||
import DropdownSelectField from './DropdownSelectField.svelte'
|
import DropdownSelectField from './DropdownSelectField.svelte'
|
||||||
|
import DraftPrompt from './DraftPrompt.svelte'
|
||||||
import { toast } from '$lib/stores/toast'
|
import { toast } from '$lib/stores/toast'
|
||||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
|
||||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
|
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
|
||||||
|
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
|
||||||
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
import AutoSaveStatus from './AutoSaveStatus.svelte'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
import type { Post } from '@prisma/client'
|
import type { Post } from '@prisma/client'
|
||||||
|
|
@ -44,48 +47,59 @@
|
||||||
// Ref to the editor component
|
// Ref to the editor component
|
||||||
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
let editorRef: { save: () => Promise<JSONContent> } | undefined
|
||||||
|
|
||||||
// Draft backup
|
// Draft key for autosave fallback
|
||||||
const draftKey = $derived(makeDraftKey('post', postId ?? 'new'))
|
const draftKey = $derived(mode === 'edit' && postId ? makeDraftKey('post', postId) : null)
|
||||||
let showDraftPrompt = $state(false)
|
|
||||||
let draftTimestamp = $state<number | null>(null)
|
|
||||||
let timeTicker = $state(0)
|
|
||||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
|
||||||
|
|
||||||
function buildPayload() {
|
function buildPayload() {
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
type: 'essay',
|
type: 'essay',
|
||||||
status,
|
status,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autosave store (edit mode only)
|
// Autosave store (edit mode only)
|
||||||
let autoSave = mode === 'edit' && postId
|
const autoSave = mode === 'edit' && postId
|
||||||
? createAutoSaveStore({
|
? createAutoSaveStore({
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
getPayload: () => (hasLoaded ? buildPayload() : null),
|
getPayload: () => (hasLoaded ? buildPayload() : null),
|
||||||
save: async (payload, { signal }) => {
|
save: async (payload, { signal }) => {
|
||||||
const response = await fetch(`/api/posts/${postId}`, {
|
const response = await fetch(`/api/posts/${postId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
signal
|
signal
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error('Failed to save')
|
if (!response.ok) throw new Error('Failed to save')
|
||||||
return await response.json()
|
return await response.json()
|
||||||
},
|
},
|
||||||
onSaved: (saved: Post, { prime }) => {
|
onSaved: (saved: Post, { prime }) => {
|
||||||
updatedAt = saved.updatedAt.toISOString()
|
updatedAt = saved.updatedAt.toISOString()
|
||||||
prime(buildPayload())
|
prime(buildPayload())
|
||||||
if (draftKey) clearDraft(draftKey)
|
if (draftKey) clearDraft(draftKey)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Draft recovery helper
|
||||||
|
const draftRecovery = useDraftRecovery<ReturnType<typeof buildPayload>>({
|
||||||
|
draftKey: () => draftKey,
|
||||||
|
onRestore: (payload) => {
|
||||||
|
title = payload.title ?? title
|
||||||
|
slug = payload.slug ?? slug
|
||||||
|
status = payload.status ?? status
|
||||||
|
content = payload.content ?? content
|
||||||
|
tags = payload.tags ?? tags
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form guards (navigation protection, Cmd+S, beforeunload)
|
||||||
|
useFormGuards(autoSave)
|
||||||
|
|
||||||
const tabOptions = [
|
const tabOptions = [
|
||||||
{ value: 'metadata', label: 'Metadata' },
|
{ value: 'metadata', label: 'Metadata' },
|
||||||
|
|
@ -106,14 +120,14 @@ let autoSave = mode === 'edit' && postId
|
||||||
]
|
]
|
||||||
|
|
||||||
// Auto-generate slug from title
|
// Auto-generate slug from title
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (title && !slug) {
|
if (title && !slug) {
|
||||||
slug = title
|
slug = title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Prime autosave on initial load (edit mode only)
|
// Prime autosave on initial load (edit mode only)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -133,7 +147,7 @@ $effect(() => {
|
||||||
|
|
||||||
// Save draft only when autosave fails
|
// Save draft only when autosave fails
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (hasLoaded && autoSave) {
|
if (hasLoaded && autoSave && draftKey) {
|
||||||
const saveStatus = autoSave.status
|
const saveStatus = autoSave.status
|
||||||
if (saveStatus === 'error' || saveStatus === 'offline') {
|
if (saveStatus === 'error' || saveStatus === 'offline') {
|
||||||
saveDraft(draftKey, buildPayload())
|
saveDraft(draftKey, buildPayload())
|
||||||
|
|
@ -141,88 +155,6 @@ $effect(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show restore prompt if a draft exists
|
|
||||||
$effect(() => {
|
|
||||||
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
|
|
||||||
if (draft) {
|
|
||||||
showDraftPrompt = true
|
|
||||||
draftTimestamp = draft.ts
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function restoreDraft() {
|
|
||||||
const draft = loadDraft<ReturnType<typeof buildPayload>>(draftKey)
|
|
||||||
if (!draft) return
|
|
||||||
const p = draft.payload
|
|
||||||
title = p.title ?? title
|
|
||||||
slug = p.slug ?? slug
|
|
||||||
status = p.status ?? status
|
|
||||||
content = p.content ?? content
|
|
||||||
tags = p.tags ?? tags
|
|
||||||
showDraftPrompt = false
|
|
||||||
clearDraft(draftKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissDraft() {
|
|
||||||
showDraftPrompt = false
|
|
||||||
clearDraft(draftKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-update draft time text every minute when prompt visible
|
|
||||||
$effect(() => {
|
|
||||||
if (showDraftPrompt) {
|
|
||||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Navigation guard: flush autosave before navigating away (only if unsaved)
|
|
||||||
beforeNavigate(async () => {
|
|
||||||
if (hasLoaded && autoSave) {
|
|
||||||
if (autoSave.status === 'saved') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Flush any pending changes before allowing navigation to proceed
|
|
||||||
try {
|
|
||||||
await autoSave.flush()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Autosave flush failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Warn before closing browser tab/window if there are unsaved changes
|
|
||||||
$effect(() => {
|
|
||||||
if (!hasLoaded || !autoSave) return
|
|
||||||
|
|
||||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
|
||||||
if (autoSave!.status !== 'saved') {
|
|
||||||
event.preventDefault()
|
|
||||||
event.returnValue = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
|
|
||||||
$effect(() => {
|
|
||||||
if (!hasLoaded || !autoSave) return
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
|
||||||
e.preventDefault()
|
|
||||||
autoSave!.flush().catch((error) => {
|
|
||||||
console.error('Autosave flush failed:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeydown)
|
|
||||||
return () => document.removeEventListener('keydown', handleKeydown)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cleanup autosave on unmount
|
// Cleanup autosave on unmount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (autoSave) {
|
if (autoSave) {
|
||||||
|
|
@ -332,18 +264,12 @@ $effect(() => {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showDraftPrompt}
|
{#if draftRecovery.showPrompt}
|
||||||
<div class="draft-banner">
|
<DraftPrompt
|
||||||
<div class="draft-banner-content">
|
timeAgo={draftRecovery.draftTimeText}
|
||||||
<span class="draft-banner-text">
|
onRestore={draftRecovery.restore}
|
||||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
onDismiss={draftRecovery.dismiss}
|
||||||
</span>
|
/>
|
||||||
<div class="draft-banner-actions">
|
|
||||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
|
||||||
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
|
|
@ -480,72 +406,6 @@ $effect(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-banner {
|
|
||||||
background: $blue-95;
|
|
||||||
border-bottom: 1px solid $blue-80;
|
|
||||||
padding: $unit-2x $unit-5x;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
animation: slideDown 0.2s ease-out;
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $unit-3x;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-text {
|
|
||||||
color: $blue-20;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
font-weight: $font-weight-med;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-button {
|
|
||||||
background: $blue-50;
|
|
||||||
border: none;
|
|
||||||
color: $white;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: $unit-half $unit-2x;
|
|
||||||
border-radius: $corner-radius-sm;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
font-weight: $font-weight-med;
|
|
||||||
transition: background $transition-fast;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $blue-40;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dismiss {
|
|
||||||
background: transparent;
|
|
||||||
color: $blue-30;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $blue-90;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom styles for save/publish buttons to maintain grey color scheme
|
// Custom styles for save/publish buttons to maintain grey color scheme
|
||||||
:global(.save-button.btn-primary) {
|
:global(.save-button.btn-primary) {
|
||||||
background-color: $gray-10;
|
background-color: $gray-10;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { goto, beforeNavigate } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from '$lib/admin/api'
|
import { api } from '$lib/admin/api'
|
||||||
import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/admin/draftStore'
|
import { makeDraftKey, saveDraft, clearDraft } from '$lib/admin/draftStore'
|
||||||
|
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
||||||
|
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
|
||||||
|
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||||
import Composer from '$lib/components/admin/composer'
|
import Composer from '$lib/components/admin/composer'
|
||||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||||
|
import DraftPrompt from '$lib/components/admin/DraftPrompt.svelte'
|
||||||
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
||||||
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
|
|
||||||
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
|
import AutoSaveStatus from '$lib/components/admin/AutoSaveStatus.svelte'
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
import type { Post } from '@prisma/client'
|
import type { Post } from '@prisma/client'
|
||||||
|
|
@ -59,12 +62,8 @@ import { makeDraftKey, saveDraft, loadDraft, clearDraft, timeAgo } from '$lib/ad
|
||||||
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
|
let metadataButtonRef: HTMLButtonElement | undefined = $state.raw()
|
||||||
let showDeleteConfirmation = $state(false)
|
let showDeleteConfirmation = $state(false)
|
||||||
|
|
||||||
// Draft backup
|
// Draft key for autosave fallback
|
||||||
const draftKey = $derived(makeDraftKey('post', $page.params.id))
|
const draftKey = $derived(makeDraftKey('post', $page.params.id))
|
||||||
let showDraftPrompt = $state(false)
|
|
||||||
let draftTimestamp = $state<number | null>(null)
|
|
||||||
let timeTicker = $state(0)
|
|
||||||
const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null))
|
|
||||||
|
|
||||||
const postTypeConfig = {
|
const postTypeConfig = {
|
||||||
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
||||||
|
|
@ -74,7 +73,7 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
|
||||||
let config = $derived(postTypeConfig[postType])
|
let config = $derived(postTypeConfig[postType])
|
||||||
|
|
||||||
// Autosave store
|
// Autosave store
|
||||||
let autoSave = createAutoSaveStore({
|
const autoSave = createAutoSaveStore({
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
getPayload: () => {
|
getPayload: () => {
|
||||||
if (!hasLoaded) return null
|
if (!hasLoaded) return null
|
||||||
|
|
@ -109,6 +108,23 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Draft recovery helper
|
||||||
|
const draftRecovery = useDraftRecovery<DraftPayload>({
|
||||||
|
draftKey: () => draftKey,
|
||||||
|
onRestore: (payload) => {
|
||||||
|
if (payload.title !== undefined) title = payload.title ?? ''
|
||||||
|
if (payload.slug !== undefined) slug = payload.slug
|
||||||
|
if (payload.type !== undefined) postType = payload.type as 'post' | 'essay'
|
||||||
|
if (payload.status !== undefined) status = payload.status as 'draft' | 'published'
|
||||||
|
if (payload.content !== undefined) content = payload.content ?? { type: 'doc', content: [] }
|
||||||
|
if (payload.excerpt !== undefined) excerpt = payload.excerpt ?? ''
|
||||||
|
if (payload.tags !== undefined) tags = payload.tags
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form guards (navigation protection, Cmd+S, beforeunload)
|
||||||
|
useFormGuards(autoSave)
|
||||||
|
|
||||||
// Convert blocks format (from database) to Tiptap format
|
// Convert blocks format (from database) to Tiptap format
|
||||||
function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent {
|
function convertBlocksToTiptap(blocksContent: BlockContent): JSONContent {
|
||||||
if (!blocksContent || !blocksContent.blocks) {
|
if (!blocksContent || !blocksContent.blocks) {
|
||||||
|
|
@ -208,16 +224,11 @@ const draftTimeText = $derived.by(() => (draftTimestamp ? (timeTicker, timeAgo(d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Wait a tick to ensure page params are loaded
|
// Wait a tick to ensure page params are loaded
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
await loadPost()
|
await loadPost()
|
||||||
const draft = loadDraft<DraftPayload>(draftKey)
|
})
|
||||||
if (draft) {
|
|
||||||
showDraftPrompt = true
|
|
||||||
draftTimestamp = draft.ts
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadPost() {
|
async function loadPost() {
|
||||||
const postId = $page.params.id
|
const postId = $page.params.id
|
||||||
|
|
@ -335,27 +346,6 @@ onMount(async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreDraft() {
|
|
||||||
const draft = loadDraft<DraftPayload>(draftKey)
|
|
||||||
if (!draft) return
|
|
||||||
const p = draft.payload
|
|
||||||
// Apply payload fields to form
|
|
||||||
if (p.title !== undefined) title = p.title
|
|
||||||
if (p.slug !== undefined) slug = p.slug
|
|
||||||
if (p.type !== undefined) postType = p.type
|
|
||||||
if (p.status !== undefined) status = p.status
|
|
||||||
if (p.content !== undefined) content = p.content
|
|
||||||
if (p.excerpt !== undefined) excerpt = p.excerpt
|
|
||||||
if (p.tags !== undefined) tags = p.tags
|
|
||||||
showDraftPrompt = false
|
|
||||||
clearDraft(draftKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissDraft() {
|
|
||||||
showDraftPrompt = false
|
|
||||||
clearDraft(draftKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMetadataPopover(event: MouseEvent) {
|
function handleMetadataPopover(event: MouseEvent) {
|
||||||
const target = event.target as Node
|
const target = event.target as Node
|
||||||
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
||||||
|
|
@ -403,68 +393,10 @@ onMount(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation guard: flush autosave before navigating away (only if there are unsaved changes)
|
|
||||||
beforeNavigate(async (_navigation) => {
|
|
||||||
if (hasLoaded) {
|
|
||||||
// If status is 'saved', there are no unsaved changes - allow navigation
|
|
||||||
if (autoSave.status === 'saved') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, flush any pending changes before allowing navigation to proceed
|
|
||||||
try {
|
|
||||||
await autoSave.flush()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Autosave flush failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Warn before closing browser tab/window if there are unsaved changes
|
|
||||||
$effect(() => {
|
|
||||||
if (!hasLoaded) return
|
|
||||||
|
|
||||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
|
||||||
// Only warn if there are unsaved changes
|
|
||||||
if (autoSave.status !== 'saved') {
|
|
||||||
event.preventDefault()
|
|
||||||
event.returnValue = '' // Required for Chrome
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Keyboard shortcut: Cmd/Ctrl+S to save immediately
|
|
||||||
$effect(() => {
|
|
||||||
if (!hasLoaded) return
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') {
|
|
||||||
e.preventDefault()
|
|
||||||
autoSave.flush().catch((error) => {
|
|
||||||
console.error('Autosave flush failed:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeydown)
|
|
||||||
return () => document.removeEventListener('keydown', handleKeydown)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cleanup autosave on unmount
|
// Cleanup autosave on unmount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => autoSave.destroy()
|
return () => autoSave.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-update draft time text every minute when prompt visible
|
|
||||||
$effect(() => {
|
|
||||||
if (showDraftPrompt) {
|
|
||||||
const id = setInterval(() => (timeTicker = timeTicker + 1), 60000)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -542,18 +474,12 @@ $effect(() => {
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showDraftPrompt}
|
{#if draftRecovery.showPrompt}
|
||||||
<div class="draft-banner">
|
<DraftPrompt
|
||||||
<div class="draft-banner-content">
|
timeAgo={draftRecovery.draftTimeText}
|
||||||
<span class="draft-banner-text">
|
onRestore={draftRecovery.restore}
|
||||||
Unsaved draft found{#if draftTimeText} (saved {draftTimeText}){/if}.
|
onDismiss={draftRecovery.dismiss}
|
||||||
</span>
|
/>
|
||||||
<div class="draft-banner-actions">
|
|
||||||
<button class="draft-banner-button" onclick={restoreDraft}>Restore</button>
|
|
||||||
<button class="draft-banner-button dismiss" onclick={dismissDraft}>Dismiss</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -636,72 +562,6 @@ $effect(() => {
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.draft-banner {
|
|
||||||
background: $blue-95;
|
|
||||||
border-bottom: 1px solid $blue-80;
|
|
||||||
padding: $unit-2x $unit-5x;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
animation: slideDown 0.2s ease-out;
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $unit-3x;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-text {
|
|
||||||
color: $blue-20;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
font-weight: $font-weight-med;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draft-banner-button {
|
|
||||||
background: $blue-50;
|
|
||||||
border: none;
|
|
||||||
color: $white;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: $unit-half $unit-2x;
|
|
||||||
border-radius: $corner-radius-sm;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
font-weight: $font-weight-med;
|
|
||||||
transition: background $transition-fast;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $blue-40;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dismiss {
|
|
||||||
background: transparent;
|
|
||||||
color: $blue-30;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $blue-90;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue