refactor(ui): unify full-area placeholders
- Add one themed placeholder primitive for page, panel, dialog, and setup states - Vertically center chat, code, assets, variables, and storage placeholders - Let Vue replace the static boot splash instead of mutating global DOM from views
This commit is contained in:
parent
050978029b
commit
a22d1edf8b
23
index.html
23
index.html
|
|
@ -29,7 +29,7 @@
|
|||
--loader-fg: 32, 33, 36;
|
||||
}
|
||||
body { margin: 0; background: var(--loader-bg); }
|
||||
#loader {
|
||||
#boot-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
|
|
@ -38,11 +38,9 @@
|
|||
z-index: 9999;
|
||||
color: rgb(var(--loader-fg));
|
||||
background: var(--loader-bg);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
#loader.fade-out { opacity: 0; pointer-events: none; }
|
||||
#loader svg { width: 32px; height: 32px; opacity: 0.45; }
|
||||
#loader .bar {
|
||||
#boot-splash svg { width: 32px; height: 32px; opacity: 0.45; }
|
||||
#boot-splash .bar {
|
||||
position: absolute;
|
||||
bottom: 50%;
|
||||
left: 50%;
|
||||
|
|
@ -53,7 +51,7 @@
|
|||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#loader .bar::after {
|
||||
#boot-splash .bar::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
|
@ -70,13 +68,14 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<div id="loader">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15.232 5.232 3.536 3.536m-2.036-5.036a2.5 2.5 0 0 1 3.536 3.536L6.5 21.036H3v-3.572L16.732 3.732Z"/>
|
||||
</svg>
|
||||
<div class="bar"></div>
|
||||
<div id="app">
|
||||
<div id="boot-splash">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15.232 5.232 3.536 3.536m-2.036-5.036a2.5 2.5 0 0 1 3.536 3.536L6.5 21.036H3v-3.572L16.732 3.732Z"/>
|
||||
</svg>
|
||||
<div class="bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
const LOADER_FADE_OUT_MS = 300
|
||||
|
||||
export function fadeOutGlobalLoader() {
|
||||
const loader = document.getElementById('loader')
|
||||
if (!loader) return
|
||||
loader.classList.add('fade-out')
|
||||
setTimeout(() => loader.remove(), LOADER_FADE_OUT_MS)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { activeTab } from '@/app/tabs'
|
|||
import AcpPermissionDialog from '@/components/chat/AcpPermissionDialog.vue'
|
||||
import ChatInput from '@/components/chat/ChatInput.vue'
|
||||
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import AppTextButton from '@/components/ui/AppTextButton.vue'
|
||||
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
|
||||
import { useAIChat } from '@/app/ai/chat/use'
|
||||
|
|
@ -132,15 +133,16 @@ function handleClearChat() {
|
|||
<template v-else>
|
||||
<ScrollAreaRoot class="min-h-0 flex-1">
|
||||
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
<AppPlaceholder
|
||||
v-if="messages.length === 0"
|
||||
data-test-id="chat-empty-state"
|
||||
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
|
||||
:label="dialogs.describeCreateOrChange"
|
||||
:ui="{ root: 'h-full' }"
|
||||
>
|
||||
<icon-lucide-message-circle class="size-8 opacity-50" />
|
||||
<p class="text-center text-xs">{{ dialogs.describeCreateOrChange }}</p>
|
||||
</div>
|
||||
<template #icon>
|
||||
<icon-lucide-message-circle class="size-5" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { JSX_REFERENCE, selectionToJSX } from '@open-pencil/core/design-jsx'
|
|||
import { useI18n, useSceneComputed } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import AppTextButton from '@/components/ui/AppTextButton.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
|
||||
|
|
@ -201,13 +202,15 @@ function copyReference() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<AppPlaceholder
|
||||
v-if="!jsxCode"
|
||||
data-test-id="code-panel-empty"
|
||||
class="flex flex-1 items-center justify-center px-4 text-center"
|
||||
:label="dialogs.selectLayerForJSX"
|
||||
>
|
||||
<span class="text-xs text-muted">{{ dialogs.selectLayerForJSX }}</span>
|
||||
</div>
|
||||
<template #icon>
|
||||
<icon-lucide-code-2 class="size-5" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<ScrollAreaRoot v-else data-test-id="code-panel" class="min-h-0 flex-1">
|
||||
<ScrollAreaViewport class="code-highlight size-full">
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { useCollabInjected } from '@/app/collab/use'
|
|||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
import { useCanvasCollaborationAwareness } from '@/app/editor/canvas/collaboration-awareness'
|
||||
import { createCanvasContextSelection } from '@/app/editor/canvas/context-selection'
|
||||
import { fadeOutGlobalLoader } from '@/app/editor/canvas/loader-overlay'
|
||||
import IconLucidePanelBottom from '~icons/lucide/panel-bottom'
|
||||
import IconLucidePanelLeft from '~icons/lucide/panel-left'
|
||||
import IconLucidePanelRight from '~icons/lucide/panel-right'
|
||||
|
|
@ -43,8 +42,7 @@ const { selectAtContextPoint } = createCanvasContextSelection(canvasRef, store)
|
|||
|
||||
useCanvas(sceneCanvasRef, store, {
|
||||
layer: 'scene',
|
||||
showRulers: false,
|
||||
onReady: fadeOutGlobalLoader
|
||||
showRulers: false
|
||||
})
|
||||
const { hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle } = useCanvas(
|
||||
canvasRef,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { openExternalLink } from '@/app/shell/ui'
|
|||
import AssetThumbnail from '@/components/assets-panel/AssetThumbnail.vue'
|
||||
import { findAssetPage } from '@/components/assets-panel/page'
|
||||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import { useButtonUI } from '@/components/ui/button'
|
||||
import { AppDialogRoot, useDialogUI } from '@/components/ui/dialog'
|
||||
import { useMenuUI } from '@/components/ui/menu'
|
||||
|
|
@ -272,7 +273,7 @@ function insertSelectedAsset() {
|
|||
</SegmentedControl>
|
||||
</div>
|
||||
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto px-2 pb-2">
|
||||
<div class="scrollbar-thin flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-2">
|
||||
<section v-for="group in assetGroups" :key="group.pageId" class="mb-3">
|
||||
<h2 class="mb-1 px-1 text-[10px] font-medium tracking-wide text-muted uppercase">
|
||||
{{ group.pageName }}
|
||||
|
|
@ -396,13 +397,16 @@ function insertSelectedAsset() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
<AppPlaceholder
|
||||
v-if="filteredAssets.length === 0"
|
||||
data-test-id="assets-empty"
|
||||
class="px-3 py-6 text-center text-xs text-muted"
|
||||
:label="panels.noLocalComponents"
|
||||
size="compact"
|
||||
>
|
||||
{{ panels.noLocalComponents }}
|
||||
</div>
|
||||
<template #icon>
|
||||
<icon-lucide-component class="size-4" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
</div>
|
||||
|
||||
<AppDialogRoot
|
||||
|
|
|
|||
|
|
@ -2,21 +2,25 @@
|
|||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import { openSettingsDialog } from '@/app/settings/dialog'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
|
||||
const { dialogs } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="provider-setup" class="flex flex-1 flex-col items-center justify-center px-6">
|
||||
<icon-lucide-sparkles class="mb-3 size-7 text-muted" />
|
||||
<p class="mb-5 text-center text-xs text-muted">{{ dialogs.connectAIProvider }}</p>
|
||||
<button
|
||||
type="button"
|
||||
data-test-id="provider-setup-open-settings"
|
||||
class="w-full rounded bg-accent py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
@click="openSettingsDialog('ai')"
|
||||
>
|
||||
{{ dialogs.openProviderSettings }}
|
||||
</button>
|
||||
</div>
|
||||
<AppPlaceholder data-test-id="provider-setup" :label="dialogs.connectAIProvider">
|
||||
<template #icon>
|
||||
<icon-lucide-sparkles class="size-5" />
|
||||
</template>
|
||||
<template #action>
|
||||
<button
|
||||
type="button"
|
||||
data-test-id="provider-setup-open-settings"
|
||||
class="w-full rounded bg-accent py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
@click="openSettingsDialog('ai')"
|
||||
>
|
||||
{{ dialogs.openProviderSettings }}
|
||||
</button>
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from 'reka-ui'
|
||||
|
||||
import AppBadge from '@/components/ui/AppBadge.vue'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import { useInputUI } from '@/components/ui/input'
|
||||
import { useSelectUI } from '@/components/ui/select'
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ export type AppComboboxOption = {
|
|||
interface AppComboboxInputProps {
|
||||
options: AppComboboxOption[]
|
||||
placeholder?: string
|
||||
emptyLabel?: string
|
||||
ui?: {
|
||||
input?: string
|
||||
content?: string
|
||||
|
|
@ -34,7 +36,7 @@ interface AppComboboxInputProps {
|
|||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { options, placeholder, ui } = defineProps<AppComboboxInputProps>()
|
||||
const { options, placeholder, emptyLabel = 'No results', ui } = defineProps<AppComboboxInputProps>()
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const open = ref(false)
|
||||
|
|
@ -45,7 +47,6 @@ const select = useSelectUI({
|
|||
})
|
||||
const inputClass = computed(() => useInputUI({ size: 'sm', ui: { base: ui?.input } }).base)
|
||||
const viewportClass = ui?.viewport ?? 'max-h-56 overflow-y-auto p-0.5'
|
||||
const emptyClass = ui?.empty ?? 'px-2 py-2 text-[11px] text-muted'
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = modelValue.value.trim().toLowerCase()
|
||||
|
|
@ -107,7 +108,13 @@ function updateValue(value: AcceptableValue) {
|
|||
</div>
|
||||
<AppBadge v-if="option.meta">{{ option.meta }}</AppBadge>
|
||||
</ComboboxItem>
|
||||
<div v-if="filteredOptions.length === 0" :class="emptyClass">No matching models</div>
|
||||
<AppPlaceholder
|
||||
v-if="filteredOptions.length === 0"
|
||||
:label="emptyLabel"
|
||||
:fill="false"
|
||||
size="compact"
|
||||
:ui="{ root: ui?.empty }"
|
||||
/>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
|
|
|
|||
51
src/components/ui/AppPlaceholder.stories.ts
Normal file
51
src/components/ui/AppPlaceholder.stories.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import AppPlaceholder from './AppPlaceholder.vue'
|
||||
|
||||
type PlaceholderStoryArgs = {
|
||||
label: string
|
||||
description?: string
|
||||
fill?: boolean
|
||||
size: 'compact' | 'panel' | 'page'
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Design System/Placeholder',
|
||||
args: {
|
||||
label: 'No documents yet',
|
||||
description: 'Create a document to start working in this space.',
|
||||
size: 'panel'
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { AppPlaceholder },
|
||||
setup: () => ({ args }),
|
||||
template: `
|
||||
<div class="flex h-72 w-full bg-app text-surface">
|
||||
<AppPlaceholder v-bind="args">
|
||||
<template #icon>
|
||||
<icon-lucide-files class="size-5" />
|
||||
</template>
|
||||
<template #action>
|
||||
<button type="button" class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white">
|
||||
Create document
|
||||
</button>
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
} satisfies Meta<PlaceholderStoryArgs>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Panel: Story = {}
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
label: 'No results',
|
||||
description: undefined,
|
||||
fill: false,
|
||||
size: 'compact'
|
||||
}
|
||||
}
|
||||
55
src/components/ui/AppPlaceholder.vue
Normal file
55
src/components/ui/AppPlaceholder.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { ComponentUI } from '@/components/ui/types'
|
||||
import placeholderTheme from '@/theme/placeholder'
|
||||
|
||||
const placeholder = tv(placeholderTheme)
|
||||
type PlaceholderVariants = VariantProps<typeof placeholder>
|
||||
type PlaceholderUI = ComponentUI<typeof placeholderTheme>
|
||||
|
||||
const {
|
||||
label,
|
||||
description,
|
||||
fill = true,
|
||||
size = 'panel',
|
||||
ui
|
||||
} = defineProps<{
|
||||
label: string
|
||||
description?: string
|
||||
fill?: boolean
|
||||
size?: PlaceholderVariants['size']
|
||||
ui?: PlaceholderUI
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const styles = computed(() => {
|
||||
const theme = placeholder({ fill, size })
|
||||
return {
|
||||
root: theme.root({ class: ui?.root }),
|
||||
content: theme.content({ class: ui?.content }),
|
||||
icon: theme.icon({ class: ui?.icon }),
|
||||
label: theme.label({ class: ui?.label }),
|
||||
description: theme.description({ class: ui?.description }),
|
||||
action: theme.action({ class: ui?.action })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="styles.root" data-slot="placeholder">
|
||||
<div :class="styles.content" data-slot="placeholder-content">
|
||||
<div v-if="slots.icon" :class="styles.icon" data-slot="placeholder-icon" aria-hidden="true">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<p :class="styles.label" data-slot="placeholder-label">{{ label }}</p>
|
||||
<p v-if="description" :class="styles.description" data-slot="placeholder-description">
|
||||
{{ description }}
|
||||
</p>
|
||||
<div v-if="slots.action" :class="styles.action" data-slot="placeholder-action">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -32,6 +32,7 @@ import IconToggleLeft from '~icons/lucide/toggle-left'
|
|||
import IconType from '~icons/lucide/type'
|
||||
import IconX from '~icons/lucide/x'
|
||||
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { AppDialogRoot } from '@/components/ui/dialog'
|
||||
import { useMenuUI } from '@/components/ui/menu'
|
||||
|
|
@ -133,18 +134,20 @@ function resizeHandleClass(resizing: boolean) {
|
|||
<icon-lucide-x class="size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-muted">{{ dialogs.noVariableCollections }}</p>
|
||||
<AppPlaceholder :label="dialogs.noVariableCollections">
|
||||
<template #icon>
|
||||
<icon-lucide-folder class="size-5" />
|
||||
</template>
|
||||
<template #action>
|
||||
<button
|
||||
data-test-id="variables-create-collection"
|
||||
class="mt-2 cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
|
||||
class="cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
|
||||
@click="ctx.addCollection"
|
||||
>
|
||||
{{ dialogs.createCollection }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
|
|
|||
35
src/theme/placeholder.ts
Normal file
35
src/theme/placeholder.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const placeholderTheme = {
|
||||
slots: {
|
||||
root: 'flex min-h-0 w-full items-center justify-center text-center',
|
||||
content: 'flex max-w-sm flex-col items-center',
|
||||
icon: 'mb-3 flex size-10 items-center justify-center rounded-full bg-panel-field text-muted',
|
||||
label: 'text-xs font-medium text-surface',
|
||||
description: 'mt-1 text-xs leading-relaxed text-muted',
|
||||
action: 'mt-4 flex w-full items-center justify-center gap-2'
|
||||
},
|
||||
variants: {
|
||||
fill: {
|
||||
true: { root: 'flex-1' },
|
||||
false: {}
|
||||
},
|
||||
size: {
|
||||
compact: {
|
||||
root: 'px-3 py-4',
|
||||
content: 'max-w-xs',
|
||||
icon: 'mb-2 size-8',
|
||||
label: 'font-normal text-muted',
|
||||
description: 'text-[11px]',
|
||||
action: 'mt-3'
|
||||
},
|
||||
panel: { root: 'p-6' },
|
||||
page: { root: 'p-8' }
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
fill: true,
|
||||
size: 'panel' as const
|
||||
}
|
||||
} as const
|
||||
|
||||
export type PlaceholderTheme = typeof placeholderTheme
|
||||
export default placeholderTheme
|
||||
|
|
@ -11,10 +11,10 @@ import {
|
|||
storageProviderRegistry,
|
||||
type StorageDocument
|
||||
} from '@/app/integrations/storage'
|
||||
import { fadeOutGlobalLoader } from '@/app/editor/canvas/loader-overlay'
|
||||
import { openSettingsDialog, settingsDialogOpen } from '@/app/settings/dialog'
|
||||
import type { CredentialStatus } from '@/app/settings/credentials/types'
|
||||
import { createCanvasId } from '@/app/storage/id'
|
||||
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
|
||||
import { getLocalCanvasStore } from '@/app/storage/local-store'
|
||||
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
|
||||
|
||||
|
|
@ -117,13 +117,12 @@ watch(settingsDialogOpen, (open, wasOpen) => {
|
|||
})
|
||||
|
||||
onMounted(() => {
|
||||
fadeOutGlobalLoader()
|
||||
void refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-screen bg-app text-surface" data-test-id="storage-workspace">
|
||||
<main class="flex min-h-screen flex-col bg-app text-surface" data-test-id="storage-workspace">
|
||||
<header class="flex h-14 items-center border-b border-border px-6">
|
||||
<div>
|
||||
<h1 class="text-sm font-semibold">{{ dialogs.storageWorkspace }}</h1>
|
||||
|
|
@ -149,10 +148,9 @@ onMounted(() => {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mx-auto max-w-6xl p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p v-if="loading" class="text-xs text-muted">{{ dialogs.loadingDocuments }}</p>
|
||||
<p v-else-if="error && configured" class="text-xs text-danger" role="alert">
|
||||
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between">
|
||||
<p v-if="error && configured" class="text-xs text-danger" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<span v-else />
|
||||
|
|
@ -188,26 +186,32 @@ onMounted(() => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading && configured" class="py-24 text-center text-xs text-muted">
|
||||
{{ dialogs.emptyStorageWorkspace }}
|
||||
</div>
|
||||
<AppPlaceholder v-else-if="loading" :label="dialogs.loadingDocuments" size="page">
|
||||
<template #icon>
|
||||
<icon-lucide-loader-circle class="size-5 animate-spin" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="mx-auto flex max-w-sm flex-col items-center py-24 text-center"
|
||||
>
|
||||
<div class="mb-3 flex size-10 items-center justify-center rounded-full bg-panel-field">
|
||||
<icon-lucide-cloud class="size-5 text-muted" />
|
||||
</div>
|
||||
<p class="text-xs text-surface">{{ dialogs.storageNotConfigured }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
@click="openSettingsDialog('storage')"
|
||||
>
|
||||
{{ dialogs.settings }}
|
||||
</button>
|
||||
</div>
|
||||
<AppPlaceholder v-else-if="configured" :label="dialogs.emptyStorageWorkspace" size="page">
|
||||
<template #icon>
|
||||
<icon-lucide-files class="size-5" />
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
|
||||
<AppPlaceholder v-else :label="dialogs.storageNotConfigured" size="page">
|
||||
<template #icon>
|
||||
<icon-lucide-cloud class="size-5" />
|
||||
</template>
|
||||
<template #action>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
|
||||
@click="openSettingsDialog('storage')"
|
||||
>
|
||||
{{ dialogs.settings }}
|
||||
</button>
|
||||
</template>
|
||||
</AppPlaceholder>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Reference in a new issue