ref: change ui

This commit is contained in:
2026-09-14 21:24:59 +07:00
parent 36860aec5f
commit a0dfb9399a
10 changed files with 326 additions and 169 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@600;700;800&family=Inter:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500..800&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>My Workspace</title> <title>My Workspace</title>
+2 -2
View File
@@ -1,11 +1,11 @@
<template> <template>
<div class="flex min-h-0 flex-1 flex-col"> <div class="flex min-h-0 flex-1 flex-col">
<div v-if="$slots.filters" class="mb-4 flex flex-wrap items-end gap-3"> <div v-if="$slots.filters" class="mb-5 flex flex-wrap items-end gap-3">
<slot name="filters" /> <slot name="filters" />
</div> </div>
<section class="panel flex min-h-0 flex-1 flex-col overflow-hidden"> <section class="panel flex min-h-0 flex-1 flex-col overflow-hidden">
<header <header
class="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3" class="flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3.5"
style="border-color: var(--hairline)" style="border-color: var(--hairline)"
> >
<h2 class="table-title m-0">{{ title }}</h2> <h2 class="table-title m-0">{{ title }}</h2>
+98 -16
View File
@@ -1,32 +1,43 @@
<template> <template>
<div class="panel flex h-full flex-col overflow-hidden"> <div class="flex h-full flex-col">
<div class="flex flex-col gap-3 border-b p-3 sm:flex-row sm:items-center" style="border-color: var(--hairline)"> <AppTableShell title="Documents">
<IconField class="min-w-0 flex-1"> <template #filters>
<IconField class="sm:w-[320px]">
<InputIcon class="pi pi-search" /> <InputIcon class="pi pi-search" />
<InputText <InputText
:model-value="searchTerm" :model-value="searchTerm"
placeholder="Search documents" placeholder="Search documents..."
class="search-input w-full" class="search-input w-full"
@update:model-value="onSearch" @update:model-value="onSearch"
/> />
</IconField> </IconField>
<div v-if="canCreate" class="flex shrink-0 gap-2"> <Select v-model="filters.type" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="Type" showClear class="w-[140px]" />
<Button label="Document" icon="pi pi-plus" size="small" @click="$emit('create', 'Document')" /> <Select v-model="filters.owner" :options="members" optionLabel="displayName" optionValue="userId" placeholder="Owner" showClear class="w-[170px]" />
<Button label="Folder" icon="pi pi-folder-open" severity="secondary" outlined size="small" @click="$emit('create', 'Folder')" /> <Select v-model="filters.updatedBy" :options="members" optionLabel="displayName" optionValue="userId" placeholder="Last edited by" showClear class="w-[180px]" />
</div> </template>
</div> <template #actions>
<template v-if="canCreate">
<Button label="Document" icon="pi pi-plus" severity="secondary" outlined @click="$emit('create', 'Document')" />
<Button label="Folder" icon="pi pi-folder-open" @click="$emit('create', 'Folder')" />
</template>
</template>
<p v-if="canCreate" class="muted-note border-b px-3 py-2" style="border-color: var(--hairline)"> <p v-if="canCreate" class="muted-note border-b px-4 py-2" style="border-color: var(--hairline)">
{{ creatingLabel }} {{ creatingLabel }}
</p> </p>
<div class="min-h-0 flex-1 overflow-auto">
<TreeTable <TreeTable
v-model:selectionKeys="selectionKeys" v-model:selectionKeys="selectionKeys"
v-model:expandedKeys="expandedKeys"
:value="nodes" :value="nodes"
:loading="loading" :loading="loading"
dataKey="key"
selectionMode="single" selectionMode="single"
scrollable
scrollHeight="flex"
class="min-h-0 min-w-[720px] flex-1"
@node-select="onNodeSelect" @node-select="onNodeSelect"
@node-unselect="onNodeUnselect"
> >
<Column field="title" header="Name" expander style="min-width: 240px"> <Column field="title" header="Name" expander style="min-width: 240px">
<template #body="{ node }"> <template #body="{ node }">
@@ -55,21 +66,46 @@
<span class="muted-note">{{ formatDate(node.data.updatedAt) }}</span> <span class="muted-note">{{ formatDate(node.data.updatedAt) }}</span>
</template> </template>
</Column> </Column>
<Column header="" style="width: 110px">
<template #body="{ node }">
<div v-if="canEdit || canDelete" class="flex justify-end gap-1">
<Button
v-if="canEdit"
icon="pi pi-pencil"
text
severity="secondary"
size="small"
:aria-label="`Rename ${node.data.title}`"
@click.stop="$emit('rename', node.data)"
/>
<Button
v-if="canDelete"
icon="pi pi-trash"
text
severity="danger"
size="small"
:aria-label="`Delete ${node.data.title}`"
@click.stop="$emit('delete', node.data)"
/>
</div>
</template>
</Column>
<template #empty> <template #empty>
<div class="grid place-items-center gap-2 px-4 py-14 text-center"> <div class="grid place-items-center gap-2 px-4 py-14 text-center">
<i class="pi pi-file-edit text-2xl" style="color: var(--ink-muted)"></i> <i class="pi pi-file-edit text-2xl" style="color: var(--ink-muted)"></i>
<p class="font-semibold" style="color: var(--ink)">No documents yet</p> <p class="font-semibold" style="color: var(--ink)">{{ hasActiveFilters ? 'No matching documents' : 'No documents yet' }}</p>
<p class="muted-note max-w-[280px]"> <p class="muted-note max-w-[280px]">
{{ canCreate ? 'Create a document to start writing, or a folder to group them.' : 'Ask a project owner for document access.' }} {{ hasActiveFilters ? 'Try clearing the filters.' : (canCreate ? 'Create a document to start writing, or a folder to group them.' : 'Ask a project owner for document access.') }}
</p> </p>
</div> </div>
</template> </template>
</TreeTable> </TreeTable>
</div> </AppTableShell>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppTableShell from './AppTableShell.vue'
import type { DocumentNode, DocumentType, ProjectMember } from '../types' import type { DocumentNode, DocumentType, ProjectMember } from '../types'
const props = defineProps<{ const props = defineProps<{
@@ -79,6 +115,8 @@ const props = defineProps<{
searchTerm: string searchTerm: string
creatingLabel: string creatingLabel: string
canCreate: boolean canCreate: boolean
canEdit: boolean
canDelete: boolean
members: ProjectMember[] members: ProjectMember[]
}>() }>()
@@ -86,6 +124,8 @@ const emit = defineEmits<{
'update:searchTerm': [value: string] 'update:searchTerm': [value: string]
select: [id: string] select: [id: string]
create: [type: DocumentType] create: [type: DocumentType]
rename: [node: DocumentNode]
delete: [node: DocumentNode]
}>() }>()
function onSearch(value: unknown) { function onSearch(value: unknown) {
@@ -111,8 +151,39 @@ function toTreeTableNode(node: DocumentNode): DocumentTreeTableNode {
return { key: node.id, data: node, children: sortNodes(node.children).map(toTreeTableNode) } return { key: node.id, data: node, children: sortNodes(node.children).map(toTreeTableNode) }
} }
const nodes = computed(() => sortNodes(props.tree).map(toTreeTableNode)) const filters = reactive<{ type: DocumentType | null; owner: string | null; updatedBy: string | null }>({
type: null,
owner: null,
updatedBy: null,
})
const typeOptions = [
{ label: 'Document', value: 'Document' },
{ label: 'Folder', value: 'Folder' },
]
const hasActiveFilters = computed(() => !!(filters.type || filters.owner || filters.updatedBy))
function matchesFilters(node: DocumentNode): boolean {
if (filters.type && node.type !== filters.type) return false
if (filters.owner && node.createdBy !== filters.owner) return false
if (filters.updatedBy && node.updatedBy !== filters.updatedBy) return false
return true
}
function filterNodes(nodes: DocumentNode[]): DocumentNode[] {
const result: DocumentNode[] = []
for (const node of nodes) {
const children = filterNodes(node.children)
if (matchesFilters(node) || children.length > 0) result.push({ ...node, children })
}
return result
}
const filteredTree = computed(() => (hasActiveFilters.value ? filterNodes(props.tree) : props.tree))
const nodes = computed(() => sortNodes(filteredTree.value).map(toTreeTableNode))
const selectionKeys = ref<Record<string, boolean>>({}) const selectionKeys = ref<Record<string, boolean>>({})
const expandedKeys = ref<Record<string, boolean>>({})
function resolveUserName(userId: string) { function resolveUserName(userId: string) {
return props.members.find((m) => m.userId === userId)?.displayName ?? '—' return props.members.find((m) => m.userId === userId)?.displayName ?? '—'
@@ -123,7 +194,18 @@ function formatDate(v?: string | null) {
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleDateString() return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleDateString()
} }
function toggleFolder(node?: { key?: string; data?: DocumentNode }) {
if (node?.key && node.data?.type === 'Folder') {
expandedKeys.value = { ...expandedKeys.value, [node.key]: !expandedKeys.value[node.key] }
}
}
function onNodeSelect(node: { key?: string; data?: DocumentNode }) { function onNodeSelect(node: { key?: string; data?: DocumentNode }) {
if (node.key) emit('select', node.key) if (node.data?.type === 'Document' && node.key) emit('select', node.key)
else toggleFolder(node)
}
function onNodeUnselect(node: { key?: string; data?: DocumentNode }) {
toggleFolder(node)
} }
</script> </script>
+20 -19
View File
@@ -12,16 +12,16 @@
<aside <aside
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static" class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
style="background: var(--panel); border-color: var(--hairline)" style="background: var(--primary); border-color: var(--primary)"
> >
<div class="mb-5 flex items-center justify-between px-2"> <div class="mb-5 flex items-center justify-between px-2">
<router-link to="/projects" class="flex items-center gap-2.5"> <router-link to="/projects" class="flex items-center gap-2.5">
<span <span
class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white" class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold"
style="background: var(--primary)" style="background: #fff; color: var(--primary)"
>M</span >M</span
> >
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: var(--ink)"> <span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: #fff">
Workspace Workspace
</span> </span>
</router-link> </router-link>
@@ -31,6 +31,7 @@
rounded rounded
severity="secondary" severity="secondary"
class="lg:hidden" class="lg:hidden"
style="color: #fff"
aria-label="Close menu" aria-label="Close menu"
@click="sidebarOpen = false" @click="sidebarOpen = false"
/> />
@@ -54,7 +55,7 @@
<i class="pi pi-cog"></i> Settings <i class="pi pi-cog"></i> Settings
<i class="pi ml-auto text-xs" :class="settingsExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"></i> <i class="pi ml-auto text-xs" :class="settingsExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"></i>
</button> </button>
<div v-show="settingsExpanded" class="ml-3 mt-2 flex flex-col gap-0.5 border-l pl-2" style="border-color: var(--hairline)"> <div v-show="settingsExpanded" class="ml-3 mt-2 flex flex-col gap-0.5 border-l pl-2" style="border-color: rgba(255, 255, 255, 0.3)">
<router-link v-if="auth.canView('masterdata')" class="nav-link nav-link-sub submenu-link" to="/settings/masterdata" @click="handleNavClick"> <router-link v-if="auth.canView('masterdata')" class="nav-link nav-link-sub submenu-link" to="/settings/masterdata" @click="handleNavClick">
Master Data Master Data
</router-link> </router-link>
@@ -100,7 +101,7 @@
</header> </header>
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto">
<div class="mx-auto h-full w-full max-w-[1280px] p-4 sm:p-6 lg:px-8 lg:py-7"> <div class="mx-auto h-full w-full max-w-[1200px] p-5 sm:p-7 lg:px-9 lg:py-8">
<router-view /> <router-view />
</div> </div>
</main> </main>
@@ -149,23 +150,23 @@ watch(
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
border-radius: 12px; border-radius: 10px;
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--ink-muted); color: rgba(255, 255, 255, 0.85);
transition: background-color 0.15s ease, color 0.15s ease; transition: background-color 0.15s ease, color 0.15s ease;
} }
.nav-link i { .nav-link i {
font-size: 0.9rem; font-size: 0.9rem;
} }
.nav-link:hover { .nav-link:hover {
background-color: var(--primary-soft); background-color: rgba(255, 255, 255, 0.14);
color: var(--ink); color: #fff;
} }
.nav-link.router-link-active { .nav-link.router-link-active {
background-color: var(--primary-soft); background-color: rgba(255, 255, 255, 0.2);
color: var(--primary); color: #fff;
font-weight: 600; font-weight: 600;
} }
.nav-link-sub { .nav-link-sub {
@@ -175,8 +176,8 @@ watch(
border-radius: 8px; border-radius: 8px;
} }
.nav-link-sub.router-link-active { .nav-link-sub.router-link-active {
background-color: var(--primary-soft); background-color: rgba(255, 255, 255, 0.2);
color: var(--primary); color: #fff;
font-weight: 600; font-weight: 600;
} }
.nav-link-sub.router-link-active::before { .nav-link-sub.router-link-active::before {
@@ -187,10 +188,10 @@ watch(
height: 60%; height: 60%;
width: 3px; width: 3px;
border-radius: 999px; border-radius: 999px;
background: var(--magenta); background: #fff;
} }
.app-dark .nav-link.router-link-active { .app-dark .nav-link.router-link-active {
color: #9ba6f8; color: #fff;
} }
.nav-link.router-link-active::before { .nav-link.router-link-active::before {
content: ''; content: '';
@@ -200,11 +201,11 @@ watch(
height: 60%; height: 60%;
width: 3px; width: 3px;
border-radius: 999px; border-radius: 999px;
background: var(--magenta); background: #fff;
} }
.submenu-link.router-link-active { .submenu-link.router-link-active {
background: transparent !important; background: transparent !important;
color: #6a7cd0 !important; color: #fff !important;
} }
.submenu-link.router-link-active::before { .submenu-link.router-link-active::before {
content: ''; content: '';
@@ -214,6 +215,6 @@ watch(
height: 60%; height: 60%;
width: 2px; width: 2px;
border-radius: 999px; border-radius: 999px;
background: var(--magenta); background: #fff;
} }
</style> </style>
+15 -14
View File
@@ -12,16 +12,16 @@
<aside <aside
class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static" class="fixed inset-y-0 left-0 z-40 flex w-[236px] transform flex-col border-r px-3 py-4 transition-transform duration-200 lg:static"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:hidden'"
style="background: var(--panel); border-color: var(--hairline)" style="background: var(--primary); border-color: var(--primary)"
> >
<div class="mb-5 flex items-center justify-between px-2"> <div class="mb-5 flex items-center justify-between px-2">
<router-link :to="auth.isAdmin ? '/projects' : '/select-project'" class="flex items-center gap-2.5"> <router-link :to="auth.isAdmin ? '/projects' : '/select-project'" class="flex items-center gap-2.5">
<span <span
class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold text-white" class="grid h-8 w-8 place-items-center rounded-[10px] font-display text-[15px] font-extrabold"
style="background: var(--primary)" style="background: #fff; color: var(--primary)"
>M</span >M</span
> >
<span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: var(--ink)"> <span class="font-display text-[15px] font-extrabold tracking-[-0.01em]" style="color: #fff">
Workspace Workspace
</span> </span>
</router-link> </router-link>
@@ -31,13 +31,14 @@
rounded rounded
severity="secondary" severity="secondary"
class="lg:hidden" class="lg:hidden"
style="color: #fff"
aria-label="Close menu" aria-label="Close menu"
@click="sidebarOpen = false" @click="sidebarOpen = false"
/> />
</div> </div>
<nav v-if="currentProject" class="flex flex-col gap-0.5"> <nav v-if="currentProject" class="flex flex-col gap-0.5">
<div class="truncate px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide" style="color: var(--ink-muted)"> <div class="truncate px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide" style="color: rgba(255, 255, 255, 0.75)">
{{ currentProject.name }} {{ currentProject.name }}
</div> </div>
<router-link <router-link
@@ -107,7 +108,7 @@
</header> </header>
<main class="flex-1 overflow-auto"> <main class="flex-1 overflow-auto">
<div class="mx-auto h-full w-full max-w-[1280px] p-4 sm:p-6 lg:px-8 lg:py-7"> <div class="mx-auto h-full w-full max-w-[1200px] p-5 sm:p-7 lg:px-9 lg:py-8">
<router-view /> <router-view />
</div> </div>
</main> </main>
@@ -209,27 +210,27 @@ onMounted(() => {
cursor: pointer; cursor: pointer;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
border-radius: 12px; border-radius: 10px;
padding: 0.5rem 0.875rem; padding: 0.5rem 0.875rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--ink-muted); color: rgba(255, 255, 255, 0.85);
transition: background-color 0.15s ease, color 0.15s ease; transition: background-color 0.15s ease, color 0.15s ease;
} }
.nav-link i { .nav-link i {
font-size: 0.9rem; font-size: 0.9rem;
} }
.nav-link:hover { .nav-link:hover {
background-color: var(--primary-soft); background-color: rgba(255, 255, 255, 0.14);
color: var(--ink); color: #fff;
} }
.nav-link.router-link-active { .nav-link.router-link-active {
background-color: var(--primary-soft); background-color: rgba(255, 255, 255, 0.2);
color: var(--primary); color: #fff;
font-weight: 600; font-weight: 600;
} }
.app-dark .nav-link.router-link-active { .app-dark .nav-link.router-link-active {
color: #9ba6f8; color: #fff;
} }
.nav-link.router-link-active::before { .nav-link.router-link-active::before {
content: ''; content: '';
@@ -239,6 +240,6 @@ onMounted(() => {
height: 60%; height: 60%;
width: 3px; width: 3px;
border-radius: 999px; border-radius: 999px;
background: var(--magenta); background: #fff;
} }
</style> </style>
+78 -58
View File
@@ -3,9 +3,9 @@
@custom-variant dark (&:where(.app-dark, .app-dark *)); @custom-variant dark (&:where(.app-dark, .app-dark *));
@theme { @theme {
/* Display = heavy geometric grotesque (DESIGN.md substitute for ABC Ginto Nord) */ /* Display = Bricolage Grotesque (characterful grotesque), body = IBM Plex Sans */
--font-sans: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif; --font-sans: 'IBM Plex Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-display: 'Hanken Grotesk', 'Inter', Helvetica, Arial, sans-serif; --font-display: 'Bricolage Grotesque', 'IBM Plex Sans', Helvetica, Arial, sans-serif;
/* Blurple — brand primary, remaps every indigo-* utility */ /* Blurple — brand primary, remaps every indigo-* utility */
--color-indigo-50: #eef0fe; --color-indigo-50: #eef0fe;
@@ -51,42 +51,42 @@
--color-green-900: #073a1f; --color-green-900: #073a1f;
--color-green-950: #042313; --color-green-950: #042313;
/* Cool indigo-tinted neutrals — remaps every slate-* utility */ /* Warm neutral stones — remaps every slate-* utility (no indigo tint) */
--color-slate-50: #f6f7fb; --color-slate-50: #f8f7f4;
--color-slate-100: #eef0f7; --color-slate-100: #f0eee9;
--color-slate-200: #dde0ed; --color-slate-200: #e5e2db;
--color-slate-300: #c1c6dd; --color-slate-300: #cfcbc2;
--color-slate-400: #8e95b5; --color-slate-400: #a3a09a;
--color-slate-500: #666d92; --color-slate-500: #6e6b66;
--color-slate-600: #4a5177; --color-slate-600: #52504c;
--color-slate-700: #2d3358; --color-slate-700: #3b3a37;
--color-slate-800: #1e2353; --color-slate-800: #26262a;
--color-slate-900: #141840; --color-slate-900: #17181c;
--color-slate-950: #0a0d3a; --color-slate-950: #0e0f12;
--shadow-subtle: rgba(69, 42, 124, 0.1) 0px 3px 34px; --shadow-subtle: rgba(28, 25, 20, 0.08) 0 8px 32px;
--shadow-micro: rgba(20, 24, 64, 0.06) 0px 1px 3px; --shadow-micro: rgba(28, 25, 20, 0.05) 0 1px 2px;
} }
:root { :root {
--font-family: var(--font-sans); --font-family: var(--font-sans);
--ink: #141840; --ink: #17181c;
--ink-muted: #666d92; --ink-muted: #6e6b66;
--primary: #5865f2; --primary: #5865f2;
--magenta: #ec48bd; --magenta: #ec48bd;
--canvas: #f6f7fb; --canvas: #f8f7f4;
--panel: #ffffff; --panel: #ffffff;
--hairline: #dde0ed; --hairline: #e5e2db;
--primary-soft: rgba(88, 101, 242, 0.12); --primary-soft: rgba(88, 101, 242, 0.1);
} }
.app-dark { .app-dark {
--ink: #ffffff; --ink: #f3f2ef;
--ink-muted: #8e95b5; --ink-muted: #a3a09a;
--canvas: #0a0d3a; --canvas: #0e0f12;
--panel: #141840; --panel: #17191e;
--hairline: #2d3358; --hairline: #2c2f36;
--primary-soft: rgba(123, 136, 245, 0.2); --primary-soft: rgba(123, 136, 245, 0.18);
} }
@layer base { @layer base {
@@ -174,25 +174,25 @@
} }
.app-dark .ck.ck-editor { .app-dark .ck.ck-editor {
--ck-color-base-background: #0a0d3a; --ck-color-base-background: #0e0f12;
--ck-color-base-border: #2d3358; --ck-color-base-border: #2c2f36;
--ck-color-base-text: #ffffff; --ck-color-base-text: #f3f2ef;
--ck-color-text: #ffffff; --ck-color-text: #f3f2ef;
--ck-color-focus-border: #5865f2; --ck-color-focus-border: #5865f2;
--ck-color-toolbar-background: #141840; --ck-color-toolbar-background: #17191e;
--ck-color-toolbar-border: #2d3358; --ck-color-toolbar-border: #2c2f36;
--ck-color-dropdown-panel-background: #141840; --ck-color-dropdown-panel-background: #17191e;
--ck-color-panel-background: #141840; --ck-color-panel-background: #17191e;
--ck-color-panel-border: #2d3358; --ck-color-panel-border: #2c2f36;
--ck-color-button-default-hover-background: #2d3358; --ck-color-button-default-hover-background: #2c2f36;
--ck-color-button-default-active-background: #2d3358; --ck-color-button-default-active-background: #2c2f36;
--ck-color-button-on-background: #2d3358; --ck-color-button-on-background: #2c2f36;
--ck-color-input-background: #0a0d3a; --ck-color-input-background: #0e0f12;
--ck-color-input-border: #2d3358; --ck-color-input-border: #2c2f36;
--ck-color-input-text: #ffffff; --ck-color-input-text: #f3f2ef;
--ck-color-tooltip-background: #2d3358; --ck-color-tooltip-background: #2c2f36;
--ck-color-tooltip-text: #ffffff; --ck-color-tooltip-text: #f3f2ef;
--ck-color-table-border: #4a5177; --ck-color-table-border: #52504c;
--ck-color-link-default: #9ba6f8; --ck-color-link-default: #9ba6f8;
} }
} }
@@ -203,28 +203,28 @@
} }
.field > label { .field > label {
@apply mb-1.5 block text-[13px] font-semibold uppercase tracking-[0.06em]; @apply mb-1.5 block text-[12px] font-semibold uppercase tracking-[0.08em];
color: var(--ink-muted); color: var(--ink-muted);
} }
.page-title { .page-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: clamp(28px, 3vw, 38px); font-size: clamp(26px, 2.6vw, 36px);
font-weight: 800; font-weight: 700;
line-height: 1.05; line-height: 1.1;
letter-spacing: -0.02em; letter-spacing: -0.025em;
color: var(--ink); color: var(--ink);
} }
.page-subtitle { .page-subtitle {
font-size: 15px; font-size: 14px;
line-height: 1.5; line-height: 1.55;
color: var(--ink-muted); color: var(--ink-muted);
} }
.muted-note { .muted-note {
font-size: 13px; font-size: 13px;
line-height: 1.45; line-height: 1.5;
color: var(--ink-muted); color: var(--ink-muted);
} }
@@ -232,16 +232,16 @@
.eyebrow { .eyebrow {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.18em; letter-spacing: 0.16em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-muted); color: var(--ink-muted);
} }
.table-title { .table-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.25rem; font-size: 1.0625rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.015em;
color: var(--ink); color: var(--ink);
} }
@@ -249,13 +249,33 @@
.panel { .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
border-radius: 16px; border-radius: 14px;
box-shadow: var(--shadow-micro);
}
/* Table headers read as quiet eyebrows, not rows of body text */
.p-datatable .p-datatable-header-cell,
.p-treetable .p-treetable-header-cell {
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.06em;
} }
.search-input.p-inputtext, .search-input.p-inputtext,
.search-input.p-select { .search-input.p-select {
border-radius: 12px !important; border-radius: 12px !important;
} }
/* Numeric columns line up table to table */
.p-datatable-table,
.p-treetable-table {
font-variant-numeric: tabular-nums;
}
/* Restrained "blurple" is an accent, not a wash */
::selection {
background: var(--primary-soft);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+29 -29
View File
@@ -32,32 +32,32 @@ const green = {
const lightSurface = { const lightSurface = {
0: '#ffffff', 0: '#ffffff',
50: '#f6f7fb', 50: '#f8f7f4',
100: '#eef0f7', 100: '#f0eee9',
200: '#dde0ed', 200: '#e5e2db',
300: '#c1c6dd', 300: '#cfcbc2',
400: '#8e95b5', 400: '#a3a09a',
500: '#666d92', 500: '#6e6b66',
600: '#4a5177', 600: '#52504c',
700: '#2d3358', 700: '#3b3a37',
800: '#1e2353', 800: '#26262a',
900: '#141840', 900: '#17181c',
950: '#0a0d3a', 950: '#0e0f12',
} }
const darkSurface = { const darkSurface = {
0: '#ffffff', 0: '#ffffff',
50: '#f6f7fb', 50: '#f8f7f4',
100: '#dde0ed', 100: '#e5e2db',
200: '#c1c6dd', 200: '#cfcbc2',
300: '#8e95b5', 300: '#a3a09a',
400: '#666d92', 400: '#6e6b66',
500: '#4a5177', 500: '#52504c',
600: '#2d3358', 600: '#2c2f36',
700: '#242a4d', 700: '#26282e',
800: '#1e2353', 800: '#1d1f24',
900: '#141840', 900: '#17191e',
950: '#0a0d3a', 950: '#0e0f12',
} }
export default definePreset(Aura, { export default definePreset(Aura, {
@@ -113,34 +113,34 @@ export default definePreset(Aura, {
background: '#ffffff', background: '#ffffff',
borderColor: '{surface.200}', borderColor: '{surface.200}',
color: '{text.color}', color: '{text.color}',
shadow: 'rgba(69, 42, 124, 0.14) 0px 12px 48px', shadow: 'rgba(24, 22, 18, 0.12) 0px 12px 48px',
}, },
}, },
}, },
dark: { dark: {
surface: darkSurface, surface: darkSurface,
text: { text: {
color: '#ffffff', color: '#f3f2ef',
hoverColor: '#ffffff', hoverColor: '#ffffff',
mutedColor: '{surface.300}', mutedColor: '{surface.300}',
hoverMutedColor: '{surface.200}', hoverMutedColor: '{surface.200}',
}, },
primary: { primary: {
color: '{primary.400}', color: '{primary.400}',
contrastColor: '#0a0d3a', contrastColor: '#17191e',
hoverColor: '{primary.300}', hoverColor: '{primary.300}',
activeColor: '{primary.200}', activeColor: '{primary.200}',
}, },
content: { content: {
background: '#141840', background: '#17191e',
borderColor: '{surface.600}', borderColor: '{surface.600}',
}, },
overlay: { overlay: {
modal: { modal: {
background: '#141840', background: '#17191e',
borderColor: '{surface.600}', borderColor: '{surface.600}',
color: '#ffffff', color: '#f3f2ef',
shadow: 'rgba(0, 0, 0, 0.5) 0px 12px 48px', shadow: 'rgba(0, 0, 0, 0.55) 0px 12px 48px',
}, },
}, },
}, },
+5 -3
View File
@@ -13,13 +13,15 @@
<!-- Left panel: login image --> <!-- Left panel: login image -->
<section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center"> <section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
<img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" /> <img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
<h1 class="relative z-10 -mt-48 font-display text-5xl font-extrabold tracking-tight text-white drop-shadow-lg">MY WORKSPACE</h1> <h1 class="relative z-10 -mt-48 font-display text-5xl font-extrabold tracking-[-0.03em] text-white drop-shadow-lg">MY WORKSPACE</h1>
</section> </section>
<section class="flex items-center justify-center p-6" style="background: var(--canvas)"> <section class="flex items-center justify-center p-6 sm:p-10" style="background: var(--canvas)">
<div class="w-full max-w-[360px]"> <div class="w-full max-w-[360px]">
<div class="mb-7"> <div class="mb-8">
<span class="eyebrow">My Workspace</span>
<h2 class="page-title mt-2">Sign in</h2> <h2 class="page-title mt-2">Sign in</h2>
<p class="page-subtitle mt-2">Projects, documents and tasks in one place.</p>
</div> </div>
<form @submit.prevent="submit"> <form @submit.prevent="submit">
+29 -13
View File
@@ -7,9 +7,13 @@
v-model:search-term="searchTerm" v-model:search-term="searchTerm"
:creating-label="creatingLabel" :creating-label="creatingLabel"
:can-create="canCreate" :can-create="canCreate"
:can-edit="canEdit"
:can-delete="canDelete"
:members="members" :members="members"
@select="selectDocument" @select="selectDocument"
@create="openCreate" @create="openCreate"
@rename="openRowRename"
@delete="confirmDelete"
/> />
</div> </div>
@@ -118,6 +122,7 @@ const createPath = ref('/')
const creating = ref(false) const creating = ref(false)
const renameDialog = ref(false) const renameDialog = ref(false)
const renameTitle = ref('') const renameTitle = ref('')
const renameTarget = ref<{ id: string; title: string; type: DocumentType } | null>(null)
const moveDialog = ref(false) const moveDialog = ref(false)
const moveTarget = ref<string | null>(null) const moveTarget = ref<string | null>(null)
@@ -294,16 +299,25 @@ async function onCreate() {
function openRename() { function openRename() {
if (doc.value) { if (doc.value) {
renameTarget.value = doc.value
renameTitle.value = doc.value.title renameTitle.value = doc.value.title
renameDialog.value = true renameDialog.value = true
} }
} }
function openRowRename(node: DocumentNode) {
renameTarget.value = node
renameTitle.value = node.title
renameDialog.value = true
}
async function onRename() { async function onRename() {
if (!doc.value || !renameTitle.value) return const target = renameTarget.value
if (!target || !renameTitle.value) return
try { try {
const updated = await updateDocument(doc.value.id, { title: renameTitle.value, content: doc.value.content }) const content = target.type === 'Document' ? (await getDocument(target.id)).content : undefined
doc.value = updated const updated = await updateDocument(target.id, { title: renameTitle.value, content })
if (doc.value?.id === target.id) doc.value = updated
renameDialog.value = false renameDialog.value = false
toast.add({ severity: 'success', summary: 'Renamed', life: 2000 }) toast.add({ severity: 'success', summary: 'Renamed', life: 2000 })
await loadTree() await loadTree()
@@ -342,29 +356,31 @@ function findNode(nodes: DocumentNode[], id: string): DocumentNode | null {
return null return null
} }
function deleteMessage(): string { function deleteMessage(target: { id: string; title: string; type: DocumentType }): string {
if (!doc.value) return '' if (target.type !== 'Folder') return `Delete "${target.title}"?`
if (doc.value.type !== 'Folder') return `Delete "${doc.value.title}"?` const node = findNode(tree.value, target.id)
const node = findNode(tree.value, doc.value.id)
const count = node ? countDescendants(node.children) : 0 const count = node ? countDescendants(node.children) : 0
return count > 0 return count > 0
? `Delete folder "${doc.value.title}" and the ${count} item${count === 1 ? '' : 's'} inside it? This cannot be undone.` ? `Delete folder "${target.title}" and the ${count} item${count === 1 ? '' : 's'} inside it? This cannot be undone.`
: `Delete folder "${doc.value.title}"?` : `Delete folder "${target.title}"?`
} }
function confirmDelete() { function confirmDelete(target?: { id: string; title: string; type: DocumentType }) {
if (!doc.value) return const item = target ?? doc.value
if (!item) return
confirm.require({ confirm.require({
message: deleteMessage(), message: deleteMessage(item),
header: 'Delete', header: 'Delete',
acceptProps: { severity: 'danger' }, acceptProps: { severity: 'danger' },
rejectProps: { severity: 'secondary', outlined: true }, rejectProps: { severity: 'secondary', outlined: true },
accept: async () => { accept: async () => {
try { try {
await deleteDocument(doc.value!.id) await deleteDocument(item.id)
if (doc.value?.id === item.id) {
doc.value = null doc.value = null
selectedId.value = null selectedId.value = null
viewerVisible.value = false viewerVisible.value = false
}
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 }) toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
await loadTree() await loadTree()
} catch (e) { } catch (e) {
+37 -2
View File
@@ -1,6 +1,21 @@
<template> <template>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<div class="mb-3 flex items-center justify-end"> <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<IconField class="sm:w-[280px]">
<InputIcon class="pi pi-search" />
<InputText v-model.trim="taskSearch" placeholder="Search tasks..." class="search-input w-full" />
</IconField>
<Select
v-model="assigneeFilter"
:options="assigneeOptions"
optionLabel="label"
optionValue="value"
placeholder="Assignee"
showClear
class="w-[180px]"
/>
</div>
<Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" /> <Button v-if="auth.can('tasks', 'create')" label="New Task" icon="pi pi-plus" @click="openCreate" />
</div> </div>
@@ -87,6 +102,26 @@ const members = ref<{ userId: string; displayName: string }[]>([])
const draggingId = ref<string | null>(null) const draggingId = ref<string | null>(null)
const dragOverStatus = ref<TaskStatus | null>(null) const dragOverStatus = ref<TaskStatus | null>(null)
const taskSearch = ref('')
const assigneeFilter = ref<string | null>(null)
const assigneeOptions = computed(() =>
members.value.map((m) => ({ label: m.displayName, value: m.userId })),
)
const filteredTasks = computed(() => {
const q = taskSearch.value.toLowerCase()
return tasks.value.filter((t) => {
if (assigneeFilter.value && t.assigneeId !== assigneeFilter.value) return false
if (!q) return true
return (
t.title.toLowerCase().includes(q) ||
(t.description ?? '').toLowerCase().includes(q) ||
(t.assigneeName ?? '').toLowerCase().includes(q)
)
})
})
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingTask = ref<Task | null>(null) const editingTask = ref<Task | null>(null)
@@ -103,7 +138,7 @@ const columns = [
] ]
function tasksIn(status: TaskStatus) { function tasksIn(status: TaskStatus) {
return tasks.value.filter((t) => t.status === status) return filteredTasks.value.filter((t) => t.status === status)
} }
async function load() { async function load() {