Build Beautiful Interfaces Fast
Battle tested components built for real world applications.
Good defaults helping you ship faster.
vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
Badge,
Button,
DesktopShell,
Dropdown,
PageHeader,
PageHeaderTitle,
Rail,
RailItem,
ScrollArea,
Select,
SettingsBody,
SettingsContent,
SettingsDialog,
SettingsHeader,
SettingsNavGroup,
SettingsNavItem,
SettingsPanel,
SettingsRow,
SettingsSidebar,
Sidebar,
SidebarHeader,
SidebarItem,
SidebarLabel,
Switch,
TabButtons,
Textarea,
TextInput,
Tooltip,
} from 'frappe-ui'
import {
List,
ListCell,
ListHeader,
ListHeaderCell,
ListRow,
ListRows,
} from 'frappe-ui/list'
const communities = [
{
id: 'design',
name: 'Design',
image: 'https://github.com/figma.png?size=200',
},
{
id: 'engineering',
name: 'Engineering',
unread: 4,
image: 'https://avatars.githubusercontent.com/u/6128107?v=4',
},
{
id: 'marketing',
name: 'Marketing',
image: 'https://avatars.githubusercontent.com/u/9919?v=4',
},
]
const activeCommunity = ref('design')
const userMenu = [
{ label: 'My profile', icon: 'lucide-user' },
{
label: 'Settings',
icon: 'lucide-settings',
onClick: () => (showSettings.value = true),
},
{ label: 'Log out', icon: 'lucide-log-out' },
]
const spaces = [
{ name: 'Announcements', icon: 'lucide-megaphone', unread: 2 },
{ name: 'Design System', icon: 'lucide-shapes', unread: 3 },
{ name: 'Website', icon: 'lucide-globe', unread: 0 },
{ name: 'Brand', icon: 'lucide-sparkles', unread: 0 },
{ name: 'Illustrations', icon: 'lucide-pen-tool', unread: 1 },
{ name: 'Research', icon: 'lucide-flask-conical', unread: 0 },
{ name: 'Archive', icon: 'lucide-archive', unread: 0 },
]
const activeSpace = ref('Design System')
const discussions = [
{
title: 'Design review: new onboarding flow',
author: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
excerpt:
'I went through the latest prototype and left comments on the empty states…',
comments: 14,
lastActivity: '2h',
unread: 3,
},
{
title: 'Proposal: consolidate icon sizes to a 4px grid',
author: 'Priya Nair',
image: 'https://i.pravatar.cc/150?img=5',
excerpt:
'We currently ship icons in 7 sizes. I propose we standardize on 16 / 20 / 24…',
comments: 32,
lastActivity: '5h',
unread: 1,
},
{
title: 'Dark mode tokens are ready for review',
author: 'Sam Rivera',
image: 'https://i.pravatar.cc/150?img=12',
excerpt:
'All semantic tokens now have dark values. The contrast checks pass except…',
comments: 21,
lastActivity: '1d',
unread: 5,
},
{
title: 'Q3 roadmap discussion',
author: 'Ana Costa',
image: 'https://i.pravatar.cc/150?img=20',
excerpt:
'Carrying over from the planning call — here are the three themes we agreed on…',
comments: 8,
lastActivity: '2d',
unread: 0,
},
{
title: 'Weekly design crit — notes and action items',
author: 'Maya Iyer',
image: 'https://i.pravatar.cc/150?img=27',
excerpt:
'Thanks everyone for joining. Summary of the feedback on the settings redesign…',
comments: 5,
lastActivity: '3d',
unread: 0,
},
{
title: 'Rethinking the empty state illustrations',
author: 'Leo Martins',
image: 'https://i.pravatar.cc/150?img=33',
excerpt:
'The current set feels dated. I sketched a lighter, more geometric direction…',
comments: 17,
lastActivity: '4h',
unread: 2,
},
{
title: 'Naming convention for spacing tokens',
author: 'Nadia Haddad',
image: 'https://i.pravatar.cc/150?img=40',
excerpt:
'Are we going with t-shirt sizes or numeric steps? Both have tradeoffs for…',
comments: 26,
lastActivity: '6h',
unread: 4,
},
{
title: 'Accessibility audit results are in',
author: 'Tom Becker',
image: 'https://i.pravatar.cc/150?img=47',
excerpt:
'We failed 6 of the WCAG AA checks, mostly around focus visibility and…',
comments: 41,
lastActivity: '8h',
unread: 7,
},
{
title: 'Should tooltips have a max width?',
author: 'Yuki Tanaka',
image: 'https://i.pravatar.cc/150?img=53',
excerpt:
'Long labels wrap awkwardly right now. I think capping at 240px reads better…',
comments: 9,
lastActivity: '10h',
unread: 0,
},
{
title: 'Migrating buttons to the new variant API',
author: 'Chris Doyle',
image: 'https://i.pravatar.cc/150?img=60',
excerpt:
'Codemod is ready. It covers ~90% of call sites; the rest need a manual pass…',
comments: 18,
lastActivity: '12h',
unread: 1,
},
{
title: 'Feedback wanted: revised color ramp',
author: 'Isabel Ortiz',
image: 'https://i.pravatar.cc/150?img=64',
excerpt:
'Bumped the mid-tones for better contrast. Take a look at gray-5 through 7…',
comments: 23,
lastActivity: '1d',
unread: 0,
},
{
title: 'Motion guidelines — first draft',
author: 'Omar Farouk',
image: 'https://i.pravatar.cc/150?img=68',
excerpt:
'Durations, easing curves, and when not to animate. Would love a sanity check…',
comments: 12,
lastActivity: '1d',
unread: 3,
},
{
title: 'Consolidating our avatar sizes',
author: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
excerpt:
'We have nine avatar sizes across the app. Proposing we trim to five…',
comments: 15,
lastActivity: '1d',
unread: 0,
},
{
title: 'New illustration style exploration',
author: 'Priya Nair',
image: 'https://i.pravatar.cc/150?img=5',
excerpt:
'Playing with a two-tone approach that scales down cleanly to 16px marks…',
comments: 7,
lastActivity: '2d',
unread: 2,
},
{
title: 'Form validation patterns need a rethink',
author: 'Sam Rivera',
image: 'https://i.pravatar.cc/150?img=12',
excerpt:
'Inline vs. on-submit is inconsistent. Here is a proposal to unify the rules…',
comments: 29,
lastActivity: '2d',
unread: 1,
},
{
title: 'Grid system: 12 columns or 16?',
author: 'Ana Costa',
image: 'https://i.pravatar.cc/150?img=20',
excerpt:
'Marketing pages want 16 for flexibility; app screens are fine with 12…',
comments: 34,
lastActivity: '2d',
unread: 0,
},
{
title: 'Deprecating the old card component',
author: 'Maya Iyer',
image: 'https://i.pravatar.cc/150?img=27',
excerpt:
'Usage is down to a handful of pages. Plan and timeline for removal inside…',
comments: 11,
lastActivity: '3d',
unread: 4,
},
{
title: 'Typography scale is drifting',
author: 'Leo Martins',
image: 'https://i.pravatar.cc/150?img=33',
excerpt:
'Found four one-off font sizes shipped last month. We should lock the scale…',
comments: 20,
lastActivity: '3d',
unread: 0,
},
{
title: 'Redesigning the notification center',
author: 'Nadia Haddad',
image: 'https://i.pravatar.cc/150?img=40',
excerpt:
'Grouping by source instead of time tested much better with the research group…',
comments: 16,
lastActivity: '3d',
unread: 2,
},
{
title: 'Loading states: skeletons vs. spinners',
author: 'Tom Becker',
image: 'https://i.pravatar.cc/150?img=47',
excerpt:
'For lists, skeletons feel faster. For actions, a spinner is clearer. Thoughts?',
comments: 13,
lastActivity: '4d',
unread: 0,
},
{
title: 'Standardizing our elevation shadows',
author: 'Yuki Tanaka',
image: 'https://i.pravatar.cc/150?img=53',
excerpt:
'We have five ad-hoc shadow values. Proposing a three-step elevation scale…',
comments: 10,
lastActivity: '4d',
unread: 1,
},
{
title: 'Rewriting the getting-started docs',
author: 'Chris Doyle',
image: 'https://i.pravatar.cc/150?img=60',
excerpt:
'The install section is confusing newcomers. Drafted a shorter, task-first flow…',
comments: 8,
lastActivity: '4d',
unread: 0,
},
{
title: 'Should we ship a compact density mode?',
author: 'Isabel Ortiz',
image: 'https://i.pravatar.cc/150?img=64',
excerpt:
'Power users keep asking for tighter rows. Here is what a density toggle costs…',
comments: 27,
lastActivity: '5d',
unread: 3,
},
{
title: 'Iconography for the new task types',
author: 'Omar Farouk',
image: 'https://i.pravatar.cc/150?img=68',
excerpt:
'Need six new glyphs. First pass attached — the "blocked" one still feels off…',
comments: 14,
lastActivity: '5d',
unread: 0,
},
{
title: 'Consistent focus-ring across components',
author: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
excerpt:
'Some components use outline, others box-shadow. Unifying on the token now…',
comments: 19,
lastActivity: '5d',
unread: 2,
},
{
title: 'Rethinking our modal sizes',
author: 'Priya Nair',
image: 'https://i.pravatar.cc/150?img=5',
excerpt:
'The 5xl modal is overused for content that would fit a drawer. A quick audit…',
comments: 6,
lastActivity: '6d',
unread: 0,
},
{
title: 'Brand refresh: logo lockups',
author: 'Sam Rivera',
image: 'https://i.pravatar.cc/150?img=12',
excerpt:
'Three lockup options for the wordmark. My vote is B, but curious what you think…',
comments: 31,
lastActivity: '6d',
unread: 1,
},
{
title: 'Data table: sticky headers and columns',
author: 'Ana Costa',
image: 'https://i.pravatar.cc/150?img=20',
excerpt:
'Prototype works but perf drops past 500 rows. Looking for virtualization ideas…',
comments: 22,
lastActivity: '6d',
unread: 0,
},
{
title: 'Emoji reactions — which set to support?',
author: 'Maya Iyer',
image: 'https://i.pravatar.cc/150?img=27',
excerpt:
'Native vs. a custom curated set. Custom is on-brand but a maintenance cost…',
comments: 17,
lastActivity: '1w',
unread: 5,
},
{
title: 'Standard page header anatomy',
author: 'Leo Martins',
image: 'https://i.pravatar.cc/150?img=33',
excerpt:
'Title, actions, tabs — where does each go? Documenting the canonical layout…',
comments: 9,
lastActivity: '1w',
unread: 0,
},
{
title: 'Onboarding checklist component',
author: 'Nadia Haddad',
image: 'https://i.pravatar.cc/150?img=40',
excerpt:
'A reusable progress checklist for first-run. Spec and edge cases inside…',
comments: 12,
lastActivity: '1w',
unread: 0,
},
{
title: 'Retiring the legacy TextEditor styles',
author: 'Tom Becker',
image: 'https://i.pravatar.cc/150?img=47',
excerpt:
'Old prose styles still bleed into the new editor. Mapped every conflict here…',
comments: 24,
lastActivity: '1w',
unread: 2,
},
{
title: 'Postmortem: the settings dialog regression',
author: 'Yuki Tanaka',
image: 'https://i.pravatar.cc/150?img=53',
excerpt:
'Cold-load rendered a blank panel. Root cause and the fix we shipped inside…',
comments: 15,
lastActivity: '2w',
unread: 0,
},
]
const feedTab = ref('All')
const visibleDiscussions = computed(() =>
feedTab.value === 'Unread'
? discussions.filter((d) => d.unread)
: discussions,
)
const showSettings = ref(false)
const settingsTab = ref('profile')
// Profile
const firstName = ref('Rhea')
const lastName = ref('Kapoor')
const bio = ref('Product designer. I care about type scales and empty states.')
const fullName = computed(() => `${firstName.value} ${lastName.value}`.trim())
const userImage = 'https://i.pravatar.cc/150?img=3'
// Preferences
const theme = ref('system')
const cursorStyle = ref('pointer')
const badgeStyle = ref('Unread count')
const spaceSort = ref('Recent activity')
const hideInactiveSpaces = ref(false)
// Notifications
const emailDigestEnabled = ref(true)
const digestFrequency = ref('Weekly')
const digestDay = ref('Monday')
// App settings + administration
const managedCommunities = [
{
name: 'Design',
spaces: 7,
members: 18,
image: 'https://i.pravatar.cc/150?img=33',
},
{
name: 'Engineering',
spaces: 12,
members: 34,
image: 'https://i.pravatar.cc/150?img=40',
},
{
name: 'Marketing',
spaces: 5,
members: 11,
image: 'https://i.pravatar.cc/150?img=47',
},
]
const customEmojis = ['🎉', '🚀', '👀', '💡', '✅', '🔥']
const members = [
{
name: 'Rhea Kapoor',
email: '[email protected]',
role: 'Admin',
image: 'https://i.pravatar.cc/150?img=53',
},
{
name: 'Evan You',
email: '[email protected]',
role: 'Member',
image: 'https://i.pravatar.cc/150?img=60',
},
{
name: 'Priya Nair',
email: '[email protected]',
role: 'Member',
image: 'https://i.pravatar.cc/150?img=64',
},
{
name: 'Sam Rivera',
email: '[email protected]',
role: 'Guest',
image: 'https://i.pravatar.cc/150?img=68',
},
]
const spaceActions = [
{ label: 'Copy link', icon: 'lucide-link' },
{ label: 'Mark all as read', icon: 'lucide-check' },
{ label: 'Manage access', icon: 'lucide-users' },
{ label: 'Archive', icon: 'lucide-archive' },
]
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell>
<template #rail>
<Rail class="border-r">
<!-- Home is a bespoke button, not a RailItem: the Frappe logo fills
the whole cell and carries no tooltip of its own. -->
<button
type="button"
class="flex size-7 items-center justify-center rounded-[7px] transition hover:opacity-90 focus-visible:ring-0 focus-visible:focus-ring"
aria-label="Home"
>
<img src="/frappe-logo.svg" alt="" class="size-7 rounded-[7px]" />
</button>
<div class="flex w-full flex-1 flex-col items-center gap-3 pt-3">
<RailItem
v-for="c in communities"
:key="c.id"
:label="c.name"
:active="activeCommunity === c.id"
:badge="c.unread"
badge-style="count"
@click="activeCommunity = c.id"
>
<Avatar
:image="c.image"
:label="c.name"
size="lg"
shape="square"
class="size-7"
/>
</RailItem>
</div>
<!-- Bottom cluster: extra gap keeps the utility items and account
avatar from crowding each other at the foot of the rail. -->
<div class="flex flex-col items-center gap-2.5">
<RailItem label="Search" variant="ghost" icon="lucide-search" />
<RailItem
label="Settings"
variant="ghost"
icon="lucide-settings"
@click="showSettings = true"
/>
<!-- User menu: a Dropdown whose trigger is the avatar cell at the
bottom of the rail. -->
<Dropdown :options="userMenu">
<template #trigger="{ open }">
<button
type="button"
class="flex size-7 items-center justify-center rounded-full transition focus-visible:ring-0 focus-visible:focus-ring"
:class="open ? '' : 'hover:opacity-90'"
aria-label="Account"
>
<Avatar
image="https://avatars.githubusercontent.com/u/499550?v=4"
label="Evan You"
size="lg"
class="size-7"
/>
</button>
</template>
</Dropdown>
</div>
</Rail>
</template>
<template #sidebar>
<Sidebar width="14rem" class="border-r">
<!-- No logo here: the left rail already shows the active
community's avatar, so a header logo would just duplicate it. -->
<SidebarHeader
title="Design"
subtitle="18 members"
:show-logo="false"
:menu-items="[
{
label: 'Invite people',
icon: 'lucide-user-plus',
onClick: () => (showSettings = true),
},
{
label: 'Community settings',
icon: 'lucide-settings-2',
onClick: () => (showSettings = true),
},
]"
/>
<!-- The app owns the scroll region: ScrollArea keeps the thin,
auto-hiding overlay scrollbar. -->
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<nav class="space-y-0.5">
<SidebarItem>
<template #prefix>
<span class="lucide-home size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">Home</span>
</SidebarItem>
<SidebarItem>
<template #prefix>
<span class="lucide-search size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">Search</span>
</SidebarItem>
</nav>
<div class="mt-4 flex h-7 items-center justify-between">
<SidebarLabel>Spaces</SidebarLabel>
<div class="flex items-center">
<Button
variant="ghost"
size="sm"
icon="lucide-arrow-up-down text-ink-gray-5"
label="Sort spaces"
/>
</div>
</div>
<nav class="mt-0.5 space-y-0.5">
<SidebarItem
v-for="space in spaces"
:key="space.name"
:active="space.name === activeSpace"
@click="activeSpace = space.name"
>
<template #prefix>
<span :class="space.icon" class="size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">{{ space.name }}</span>
<template #suffix>
<span
v-if="space.unread"
class="mr-1 size-4 grid place-content-center text-xs text-ink-gray-5"
>
{{ space.unread }}
</span>
</template>
</SidebarItem>
</nav>
</ScrollArea>
</Sidebar>
</template>
<!-- Declared by the page; teleports into the shell's pinned header target. -->
<PageHeader>
<div class="flex items-center gap-1">
<PageHeaderTitle>{{ activeSpace }}</PageHeaderTitle>
<Dropdown :options="spaceActions">
<Button
variant="ghost"
icon="lucide-ellipsis"
label="Space actions"
/>
</Dropdown>
</div>
<Button label="Add new" icon-left="lucide-plus" />
</PageHeader>
<!-- Body container: max-w-[940px] px-3 sm:px-5 mx-auto -->
<div class="mx-auto mt-5 w-full max-w-[940px] px-3 pb-10 sm:px-5">
<div class="mb-4 flex items-center justify-between">
<TabButtons
v-model="feedTab"
:options="[{ label: 'Unread' }, { label: 'All' }]"
/>
<span class="text-sm text-ink-gray-5">
{{ visibleDiscussions.length }} discussions
</span>
</div>
<!-- -mx-3 lets row hover surfaces bleed past the text edge, so the
content stays aligned with the container. -->
<List class="-mx-3 sm:list-gap-4">
<ListRow
v-for="discussion in visibleDiscussions"
:key="discussion.title"
class="h-15"
@click="() => {}"
>
<ListCell>
<Avatar
:image="discussion.image"
:label="discussion.author"
size="2xl"
/>
</ListCell>
<ListCell>
<div class="min-w-0 flex-1">
<!-- The sized text lives in an inner span so it keeps its own
line-height. Putting `leading-none` on the same element as
`truncate` (overflow-hidden) would shear off descenders like
the tail of "g". -->
<div class="truncate leading-none text-ink-gray-8">
<span
:class="
discussion.unread ? 'text-base-semibold' : 'text-base'
"
>
{{ discussion.title }}
</span>
</div>
<div
class="mt-1.5 flex min-w-0 items-center text-base text-ink-gray-5"
>
<span
class="lucide-reply mr-1 size-4 shrink-0"
aria-hidden="true"
/>
<span class="shrink-0">{{ discussion.author }}: </span>
<span class="truncate">{{ discussion.excerpt }}</span>
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<div>
<div
class="whitespace-nowrap text-right text-sm text-ink-gray-5"
>
{{ discussion.lastActivity }}
</div>
<div class="mt-1.5 flex items-center justify-end">
<Tooltip
v-if="discussion.unread"
:text="`${discussion.unread} unread`"
>
<Badge theme="amber" variant="solid" size="sm">
{{ discussion.unread }}
</Badge>
</Tooltip>
<Badge v-else>{{ discussion.comments + 1 }}</Badge>
</div>
</div>
</ListCell>
</ListRow>
</List>
</div>
</DesktopShell>
<SettingsDialog v-model="showSettings" v-model:tab="settingsTab" size="5xl">
<SettingsSidebar>
<SettingsNavGroup label="User settings">
<SettingsNavItem value="profile">
<template #prefix>
<Avatar
size="xs"
:image="userImage"
:label="fullName"
class="shrink-0"
/>
</template>
Profile
</SettingsNavItem>
<SettingsNavItem value="preferences">
<template #prefix>
<span
class="lucide-sliders-horizontal size-4 shrink-0 text-ink-gray-6"
/>
</template>
Preferences
</SettingsNavItem>
<SettingsNavItem value="notifications">
<template #prefix>
<span class="lucide-bell size-4 shrink-0 text-ink-gray-6" />
</template>
Notifications
</SettingsNavItem>
</SettingsNavGroup>
<SettingsNavGroup label="App settings">
<SettingsNavItem value="communities">
<template #prefix>
<span class="lucide-building-2 size-4 shrink-0 text-ink-gray-6" />
</template>
Communities
</SettingsNavItem>
<SettingsNavItem value="emojis">
<template #prefix>
<span class="lucide-smile-plus size-4 shrink-0 text-ink-gray-6" />
</template>
Emojis
</SettingsNavItem>
</SettingsNavGroup>
<SettingsNavGroup label="Administration">
<SettingsNavItem value="users">
<template #prefix>
<span class="lucide-users size-4 shrink-0 text-ink-gray-6" />
</template>
Users
</SettingsNavItem>
</SettingsNavGroup>
</SettingsSidebar>
<SettingsContent>
<SettingsPanel value="profile">
<SettingsHeader title="Profile" />
<SettingsBody>
<div class="space-y-11 pt-6">
<section class="space-y-6">
<div class="flex items-center gap-4">
<Avatar
:image="userImage"
:label="fullName"
class="size-16"
/>
<div>
<div class="text-base-medium text-ink-gray-8">
Profile picture
</div>
<p class="text-p-sm text-ink-gray-5">
Helps people recognise you
</p>
</div>
</div>
<div class="grid gap-6 sm:grid-cols-2">
<TextInput
label="First name"
class="w-full"
v-model="firstName"
/>
<TextInput
label="Last name"
class="w-full"
v-model="lastName"
/>
</div>
<Textarea
label="Bio"
class="w-full"
maxlength="280"
v-model="bio"
/>
</section>
<section>
<h2 class="text-lg-semibold text-ink-gray-8">Account</h2>
<div class="mt-2 divide-y divide-outline-gray-1">
<SettingsRow
title="Public profile"
description="View your public page or customize its card layout"
>
<div class="flex gap-2">
<Button icon-left="lucide-user">View</Button>
<Button icon-left="lucide-layout-dashboard"
>Customize</Button
>
</div>
</SettingsRow>
<SettingsRow
title="Password"
description="Manage password and account access"
>
<Button>Update Password</Button>
</SettingsRow>
</div>
</section>
</div>
</SettingsBody>
</SettingsPanel>
<SettingsPanel value="preferences">
<SettingsHeader title="Preferences" />
<SettingsBody>
<div class="space-y-11 pt-6">
<section>
<div class="divide-y divide-outline-gray-1">
<SettingsRow
title="Appearance"
description="Choose a light, dark, or system-matched interface"
>
<Select
v-model="theme"
:options="[
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
{ label: 'System Default', value: 'system' },
]"
>
<template #item-prefix="{ item }">
<div
v-if="item.value === 'system'"
class="flex size-3 overflow-hidden rounded-full border border-outline-gray-2"
>
<div class="w-1/2 bg-white"></div>
<div class="w-1/2 bg-gray-950"></div>
</div>
<div
v-else
class="size-3 rounded-full border"
:class="
item.value == 'light'
? 'border-outline-gray-2 bg-white'
: 'bg-gray-950'
"
></div>
</template>
</Select>
</SettingsRow>
<SettingsRow
title="Cursor"
description="Show the pointer on everything clickable, or only on external links"
>
<Select
v-model="cursorStyle"
:options="[
{ label: 'Pointer', value: 'pointer' },
{ label: 'Normal', value: 'normal' },
]"
/>
</SettingsRow>
</div>
</section>
<section>
<h2 class="text-lg-semibold text-ink-gray-8">Sidebar</h2>
<div class="mt-2 divide-y divide-outline-gray-1">
<SettingsRow
title="Unread badge"
description="Show unread activity as a dot or a count"
>
<Select
v-model="badgeStyle"
:options="['Dot', 'Unread count']"
/>
</SettingsRow>
<SettingsRow
title="Communities"
description="Show, hide, and reorder communities in the left rail"
>
<Button>Customize</Button>
</SettingsRow>
<SettingsRow
title="Space sorting"
description="Choose how spaces are ordered in the current community sidebar"
>
<Select
v-model="spaceSort"
:options="['Recent activity', 'Alphabetical']"
/>
</SettingsRow>
<SettingsRow
title="Inactive spaces"
description="Hide spaces with no activity for the last 2 months"
>
<Switch v-model="hideInactiveSpaces" />
</SettingsRow>
</div>
</section>
</div>
</SettingsBody>
</SettingsPanel>
<SettingsPanel value="notifications">
<SettingsHeader title="Notifications" />
<SettingsBody>
<div class="space-y-11 pt-6">
<section>
<div class="divide-y divide-outline-gray-1">
<SettingsRow
title="Enable email digests"
description="Send a summary of missed activity"
>
<Switch v-model="emailDigestEnabled" />
</SettingsRow>
<SettingsRow
v-if="emailDigestEnabled"
title="Digest frequency"
description="Choose how often you receive your digest"
>
<Select
v-model="digestFrequency"
:options="['Weekly', 'Fortnightly', 'Monthly']"
/>
</SettingsRow>
<SettingsRow
v-if="emailDigestEnabled"
title="Send on"
description="Choose the weekday for your digest"
>
<Select
v-model="digestDay"
:options="['Monday', 'Wednesday', 'Friday']"
/>
</SettingsRow>
<SettingsRow
title="Last sent"
description="The most recent digest sent to you"
>
<div class="text-base text-ink-gray-6">28 Jun 2026</div>
</SettingsRow>
</div>
</section>
</div>
</SettingsBody>
</SettingsPanel>
<SettingsPanel value="communities">
<SettingsHeader title="Communities">
<template #actions>
<Button icon-left="lucide-plus">New community</Button>
</template>
</SettingsHeader>
<SettingsBody>
<List class="-mx-3 pt-4" :row-height="56">
<ListRow
v-for="community in managedCommunities"
:key="community.name"
>
<ListCell>
<Avatar
:image="community.image"
:label="community.name"
size="xl"
shape="square"
/>
</ListCell>
<ListCell>
<div class="min-w-0">
<div class="truncate text-base text-ink-gray-8">
{{ community.name }}
</div>
<div class="mt-0.5 truncate text-sm text-ink-gray-5">
{{ community.spaces }} spaces ·
{{ community.members }} members
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<Button
variant="ghost"
icon="lucide-ellipsis"
label="Community options"
/>
</ListCell>
</ListRow>
</List>
</SettingsBody>
</SettingsPanel>
<SettingsPanel value="emojis">
<SettingsHeader
title="Emojis"
description="Custom emojis are available to everyone in reactions"
>
<template #actions>
<Button icon-left="lucide-plus">Add emoji</Button>
</template>
</SettingsHeader>
<SettingsBody>
<div class="flex flex-wrap gap-2 pt-6">
<div
v-for="emoji in customEmojis"
:key="emoji"
class="grid size-10 place-content-center rounded-lg border text-xl"
>
{{ emoji }}
</div>
</div>
</SettingsBody>
</SettingsPanel>
<SettingsPanel value="users">
<SettingsHeader title="Users">
<template #actions>
<Button icon-left="lucide-user-plus">Invite</Button>
</template>
</SettingsHeader>
<SettingsBody>
<List
class="-mx-3 pt-4"
:columns="['minmax(0,1fr)', '6rem', '3rem']"
:row-height="56"
>
<ListHeader>
<ListHeaderCell>Member</ListHeaderCell>
<ListHeaderCell>Role</ListHeaderCell>
<ListHeaderCell />
</ListHeader>
<ListRows :items="members" v-slot="{ item: member }">
<ListRow>
<ListCell>
<Avatar
:image="member.image"
:label="member.name"
size="xl"
/>
<div class="ml-3 min-w-0">
<div class="truncate text-base text-ink-gray-8">
{{ member.name }}
</div>
<div class="mt-0.5 truncate text-sm text-ink-gray-5">
{{ member.email }}
</div>
</div>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-7">{{
member.role
}}</span>
</ListCell>
<ListCell class="justify-end">
<Button
variant="ghost"
icon="lucide-ellipsis"
label="Member options"
/>
</ListCell>
</ListRow>
</ListRows>
</List>
</SettingsBody>
</SettingsPanel>
</SettingsContent>
</SettingsDialog>
</div>
</template>vue
<script setup>
import { ref } from 'vue'
import {
Avatar,
Badge,
BottomSheet,
Button,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
Tooltip,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
const tab = ref('home')
// Spaces live in the desktop sidebar; on mobile they surface in a bottom sheet.
const showSpaces = ref(false)
const activeSpace = ref('Design System')
const spaces = [
{ name: 'Announcements', icon: 'lucide-megaphone', unread: 2 },
{ name: 'Design System', icon: 'lucide-shapes', unread: 3 },
{ name: 'Website', icon: 'lucide-globe', unread: 0 },
{ name: 'Brand', icon: 'lucide-sparkles', unread: 0 },
{ name: 'Illustrations', icon: 'lucide-pen-tool', unread: 1 },
{ name: 'Research', icon: 'lucide-flask-conical', unread: 0 },
{ name: 'Archive', icon: 'lucide-archive', unread: 0 },
]
function selectSpace(name) {
activeSpace.value = name
showSpaces.value = false
}
const pinned = [
{
title: 'Welcome! Start here',
author: 'Rhea Kapoor',
image: 'https://i.pravatar.cc/150?img=1',
excerpt: 'House rules, useful links, and how we work asynchronously.',
comments: 42,
lastActivity: '1w',
unread: 0,
},
]
const discussions = [
{
title: 'Design review: new onboarding flow',
author: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
excerpt: 'I went through the latest prototype and left comments…',
comments: 14,
lastActivity: '2h',
unread: 3,
},
{
title: 'Proposal: consolidate icon sizes to a 4px grid',
author: 'Priya Nair',
image: 'https://i.pravatar.cc/150?img=2',
excerpt: 'We currently ship icons in 7 sizes. I propose we standardize…',
comments: 32,
lastActivity: '5h',
unread: 1,
},
{
title: 'Dark mode tokens are ready for review',
author: 'Sam Rivera',
image: 'https://i.pravatar.cc/150?img=3',
excerpt: 'All semantic tokens now have dark values. The contrast checks…',
comments: 21,
lastActivity: '1d',
unread: 5,
},
{
title: 'Q3 roadmap discussion',
author: 'Ana Costa',
image: 'https://i.pravatar.cc/150?img=4',
excerpt: 'Carrying over from the planning call — here are the themes…',
comments: 8,
lastActivity: '2d',
unread: 0,
},
{
title: 'Weekly design crit — notes and action items',
author: 'Maya Iyer',
image: 'https://i.pravatar.cc/150?img=5',
excerpt: 'Thanks everyone for joining. Summary of the feedback…',
comments: 5,
lastActivity: '3d',
unread: 0,
},
]
</script>
<template>
<MobileShell>
<PageHeaderMobile title="Home">
<template #left>
<Button
variant="ghost"
icon="lucide-panel-left"
label="Spaces"
@click="showSpaces = true"
/>
</template>
<template #right>
<Button
variant="ghost"
icon="lucide-square-pen"
label="New discussion"
/>
</template>
</PageHeaderMobile>
<div class="pb-6 pt-3">
<div class="mb-5">
<div class="mb-2 flex items-center space-x-1 px-4">
<span class="lucide-pin size-4 text-ink-gray-4" aria-hidden="true" />
<span class="text-base text-ink-gray-8">Pinned</span>
</div>
<List class="list-gap-3 list-row-px-4">
<ListRow
v-for="discussion in pinned"
:key="discussion.title"
class="h-17"
@click="() => {}"
>
<ListCell>
<Avatar
:image="discussion.image"
:label="discussion.author"
size="2xl"
/>
</ListCell>
<ListCell>
<div class="min-w-0 flex-1">
<!-- The sized text lives in an inner span so it keeps its own
line-height. Putting `leading-none` on the same element as
`truncate` (overflow-hidden) would shear off descenders like
the tail of "g". -->
<div class="truncate leading-none text-ink-gray-8">
<span class="text-lg">{{ discussion.title }}</span>
</div>
<div
class="mt-1.5 flex min-w-0 items-center text-md text-ink-gray-5"
>
<span class="shrink-0">{{ discussion.author }}: </span>
<span class="truncate">{{ discussion.excerpt }}</span>
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<div>
<div
class="whitespace-nowrap text-right text-sm text-ink-gray-5"
>
{{ discussion.lastActivity }}
</div>
<div class="mt-1.5 flex items-center justify-end">
<Badge>{{ discussion.comments + 1 }}</Badge>
</div>
</div>
</ListCell>
</ListRow>
</List>
</div>
<List class="list-gap-3 list-row-px-4">
<ListRow
v-for="discussion in discussions"
:key="discussion.title"
class="h-17"
@click="() => {}"
>
<ListCell>
<Avatar
:image="discussion.image"
:label="discussion.author"
size="2xl"
/>
</ListCell>
<ListCell>
<div class="min-w-0 flex-1">
<!-- The sized text lives in an inner span so it keeps its own
line-height. Putting `leading-none` on the same element as
`truncate` (overflow-hidden) would shear off descenders like
the tail of "g". -->
<div class="truncate leading-none text-ink-gray-8">
<span
:class="discussion.unread ? 'text-lg-semibold' : 'text-lg'"
>
{{ discussion.title }}
</span>
</div>
<div
class="mt-1.5 flex min-w-0 items-center text-md text-ink-gray-5"
>
<span
class="lucide-reply mr-1 size-4 shrink-0"
aria-hidden="true"
/>
<span class="shrink-0">{{ discussion.author }}: </span>
<span class="truncate">{{ discussion.excerpt }}</span>
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<div>
<div class="whitespace-nowrap text-right text-sm text-ink-gray-5">
{{ discussion.lastActivity }}
</div>
<div class="mt-1.5 flex items-center justify-end">
<Tooltip
v-if="discussion.unread"
:text="`${discussion.unread} unread`"
>
<Badge theme="amber" variant="solid" size="sm">
{{ discussion.unread }}
</Badge>
</Tooltip>
<Badge v-else>{{ discussion.comments + 1 }}</Badge>
</div>
</div>
</ListCell>
</ListRow>
</List>
</div>
<BottomSheet v-model:open="showSpaces" title="Spaces">
<!-- v-model:active highlights the current space; the List's default
`auto minmax(0,1fr) auto` columns lay out icon / label / count. -->
<List v-model:active="activeSpace" class="px-2 pb-4">
<ListRow
v-for="space in spaces"
:key="space.name"
:value="space.name"
class="h-11 rounded-md"
@click="selectSpace(space.name)"
>
<ListCell>
<span
:class="space.icon"
class="size-5 shrink-0 text-ink-gray-5"
aria-hidden="true"
/>
</ListCell>
<ListCell>
<span class="truncate text-lg text-ink-gray-8">
{{ space.name }}
</span>
</ListCell>
<ListCell class="justify-end">
<Badge v-if="space.unread">{{ space.unread }}</Badge>
</ListCell>
</ListRow>
</List>
</BottomSheet>
<template #nav>
<MobileNav>
<MobileNavItem
label="Home"
icon="lucide-house"
:active="tab === 'home'"
@click="tab = 'home'"
/>
<MobileNavItem
label="Notifications"
icon="lucide-bell"
:active="tab === 'notifications'"
@click="tab = 'notifications'"
/>
<MobileNavItem
label="Search"
icon="lucide-search"
:active="tab === 'search'"
@click="tab = 'search'"
/>
<MobileNavItem label="You" :active="tab === 'you'" @click="tab = 'you'">
<template #default="{ active }">
<Avatar
image="https://i.pravatar.cc/150?img=1"
label="Rhea Kapoor"
size="md"
:class="active ? 'ring-2 ring-outline-gray-4' : ''"
/>
</template>
</MobileNavItem>
</MobileNav>
</template>
</MobileShell>
</template>vue
<script setup>
import { ref } from 'vue'
import { useTextareaAutosize } from '@vueuse/core'
import {
Breadcrumbs,
Button,
DesktopShell,
PageHeader,
PageHeaderBase,
} from 'frappe-ui'
import {
AlignCenter,
AlignLeft,
AlignRight,
Blockquote,
Bold,
BulletList,
Editor,
EditorContent,
EditorFixedMenu,
FontColor,
H2,
H3,
H4,
HorizontalRule,
InlineCode,
InsertImage,
InsertLink,
InsertTable,
InsertVideo,
Italic,
OrderedList,
Paragraph,
Redo,
RichTextKit,
Separator,
Strike,
Undo,
} from 'frappe-ui/editor'
const people = [
{ id: 'evan', label: 'Evan You' },
{ id: 'priya', label: 'Priya Nair' },
{ id: 'sam', label: 'Sam Rivera' },
{ id: 'ana', label: 'Ana Costa' },
]
const extensions = [RichTextKit.configure({ mention: { items: people } })]
// The rich-text compose toolbar, item for item.
const toolbar = [
Paragraph,
H2,
H3,
H4,
Separator,
Bold,
Italic,
Strike,
Separator,
BulletList,
OrderedList,
Separator,
AlignLeft,
AlignCenter,
AlignRight,
FontColor,
Separator,
InsertImage,
InsertVideo,
InsertLink,
Blockquote,
InlineCode,
HorizontalRule,
Separator,
InsertTable,
Separator,
Undo,
Redo,
]
const title = ref('Design review: new onboarding flow')
const content = ref(`
<p>I went through the latest onboarding prototype this morning and left inline comments in Figma. It is close to shippable. The new checklist makes the first run much clearer than the old three-step wizard, and the empty states no longer look broken when someone skips a step.</p>
<p>Here is a walkthrough of what I saw, plus a few things we need to decide before this ships.</p>
<img src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1200&q=80" alt="First-run screen with the new setup checklist" />
<p>The new first-run screen. The checklist on the right replaces the old modal wizard.</p>
<h2>What works well</h2>
<ul>
<li><p>The progress bar shows how many steps are left, so nobody feels stuck.</p></li>
<li><p>Skipping a step now lands you on a useful screen instead of a blank one.</p></li>
<li><p>Sample data loads fast enough that the workspace feels alive on the first visit.</p></li>
</ul>
<h3>Numbers from the last test round</h3>
<p>Five people ran through both versions. The new flow won on every measure we tracked.</p>
<table>
<tbody>
<tr><th><p>Metric</p></th><th><p>Old wizard</p></th><th><p>New checklist</p></th></tr>
<tr><td><p>Setup completion</p></td><td><p>61%</p></td><td><p>78%</p></td></tr>
<tr><td><p>Time to first post</p></td><td><p>4m 12s</p></td><td><p>2m 30s</p></td></tr>
<tr><td><p>Dropped at invite step</p></td><td><p>3 of 5</p></td><td><p>1 of 5</p></td></tr>
</tbody>
</table>
<h3>Open questions</h3>
<ol>
<li><p>Should sample data seed automatically, or sit behind a button?</p></li>
<li><p>The invite step assumes email. What do we show for SSO-only workspaces?</p></li>
<li><p>Do we keep the tour for returning users, or show it only once?</p></li>
</ol>
<p>One thing to sort out on the backend: the checklist state has to persist per user, not per session. Right now the prototype reads it from <code>localStorage</code>, which resets when you switch devices. Here is the response shape I am proposing:</p>
<pre><code class="language-json">{
"onboarding": {
"completed": ["create_space", "invite_team"],
"skipped": ["import_data"],
"dismissed": false
}
}</code></pre>
<blockquote><p>Let's timebox this to one more revision and ship it behind the <code>new_onboarding</code> flag next week.</p></blockquote>
<p>Full comments are in the design channel. Add anything I missed before Friday.</p>
`)
// Demo-only: turns dropped/pasted files into local object URLs. Real apps
// return a persisted URL from their upload endpoint.
const uploadFunction = async (file) => ({
file_url: URL.createObjectURL(file),
file_name: file.name,
})
// Autosized single-visual-line title.
const { textarea: titleTextarea } = useTextareaAutosize({ input: title })
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell>
<Editor
v-model="content"
:extensions="extensions"
:upload-function="uploadFunction"
placeholder="Type '/' for commands or select text to format"
>
<template #default="{ editor }">
<PageHeader>
<Breadcrumbs
:items="[{ label: 'Drafts' }, { label: 'New discussion' }]"
/>
<div class="flex shrink-0 items-center space-x-2">
<Button
variant="ghost"
icon="lucide-trash-2"
label="Delete draft"
/>
<Button variant="solid">Publish</Button>
</div>
</PageHeader>
<PageHeaderBase
class="flex h-10 items-center border-b bg-surface-base px-3 sm:px-5"
>
<div class="w-full overflow-x-auto">
<EditorFixedMenu :editor="editor" :items="toolbar" />
</div>
</PageHeaderBase>
<!-- Prose container: 720px of prose + padding. -->
<div class="mx-auto w-full max-w-[770px] px-3 pt-4 sm:px-5">
<textarea
ref="titleTextarea"
class="mt-1 w-full resize-none border-0 bg-transparent px-0 py-0.5 text-4xl-semibold text-ink-gray-8 placeholder-ink-gray-3 focus:ring-0"
v-model="title"
placeholder="Title"
rows="1"
wrap="soft"
maxlength="140"
@keydown.enter.prevent="editor.commands.focus()"
@keydown.down.prevent="editor.commands.focus()"
/>
<EditorContent
:editor="editor"
class="prose-v3 -mx-2 min-h-[calc(100vh-200px)] max-w-[unset] overflow-auto px-2 pb-40"
/>
</div>
</template>
</Editor>
</DesktopShell>
</div>
</template>vue
<script setup>
import { ref } from 'vue'
import { useTextareaAutosize } from '@vueuse/core'
import { Button, MobileShell, PageHeaderMobile } from 'frappe-ui'
import {
AlignCenter,
AlignLeft,
AlignRight,
Blockquote,
Bold,
BulletList,
Editor,
EditorContent,
EditorFixedMenu,
FontColor,
H2,
H3,
H4,
HorizontalRule,
InlineCode,
InsertImage,
InsertLink,
InsertTable,
InsertVideo,
Italic,
OrderedList,
Paragraph,
Redo,
RichTextKit,
Separator,
Strike,
Undo,
} from 'frappe-ui/editor'
const people = [
{ id: 'evan', label: 'Evan You' },
{ id: 'priya', label: 'Priya Nair' },
{ id: 'sam', label: 'Sam Rivera' },
{ id: 'ana', label: 'Ana Costa' },
]
const extensions = [RichTextKit.configure({ mention: { items: people } })]
// Same toolbar as the desktop recipe; on mobile it lives in a sticky bar that
// scrolls horizontally instead of a full-width menu.
const toolbar = [
Paragraph,
H2,
H3,
H4,
Separator,
Bold,
Italic,
Strike,
Separator,
BulletList,
OrderedList,
Separator,
AlignLeft,
AlignCenter,
AlignRight,
FontColor,
Separator,
InsertImage,
InsertVideo,
InsertLink,
Blockquote,
InlineCode,
HorizontalRule,
Separator,
InsertTable,
Separator,
Undo,
Redo,
]
const title = ref('Design review: new onboarding flow')
const content = ref(`
<p>I went through the latest onboarding prototype this morning and left inline comments in Figma. It is close to shippable. The new checklist makes the first run much clearer than the old three-step wizard, and the empty states no longer look broken when someone skips a step.</p>
<p>Here is a walkthrough of what I saw, plus a few things we need to decide before this ships.</p>
<img src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1200&q=80" alt="First-run screen with the new setup checklist" />
<p>The new first-run screen. The checklist on the right replaces the old modal wizard.</p>
<h2>What works well</h2>
<ul>
<li><p>The progress bar shows how many steps are left, so nobody feels stuck.</p></li>
<li><p>Skipping a step now lands you on a useful screen instead of a blank one.</p></li>
<li><p>Sample data loads fast enough that the workspace feels alive on the first visit.</p></li>
</ul>
<h3>Numbers from the last test round</h3>
<p>Five people ran through both versions. The new flow won on every measure we tracked.</p>
<table>
<tbody>
<tr><th><p>Metric</p></th><th><p>Old wizard</p></th><th><p>New checklist</p></th></tr>
<tr><td><p>Setup completion</p></td><td><p>61%</p></td><td><p>78%</p></td></tr>
<tr><td><p>Time to first post</p></td><td><p>4m 12s</p></td><td><p>2m 30s</p></td></tr>
<tr><td><p>Dropped at invite step</p></td><td><p>3 of 5</p></td><td><p>1 of 5</p></td></tr>
</tbody>
</table>
<h3>Open questions</h3>
<ol>
<li><p>Should sample data seed automatically, or sit behind a button?</p></li>
<li><p>The invite step assumes email. What do we show for SSO-only workspaces?</p></li>
<li><p>Do we keep the tour for returning users, or show it only once?</p></li>
</ol>
<p>One thing to sort out on the backend: the checklist state has to persist per user, not per session. Right now the prototype reads it from <code>localStorage</code>, which resets when you switch devices. Here is the response shape I am proposing:</p>
<pre><code class="language-json">{
"onboarding": {
"completed": ["create_space", "invite_team"],
"skipped": ["import_data"],
"dismissed": false
}
}</code></pre>
<blockquote><p>Let's timebox this to one more revision and ship it behind the <code>new_onboarding</code> flag next week.</p></blockquote>
<p>Full comments are in the design channel. Add anything I missed before Friday.</p>
`)
// Demo-only: turns dropped/pasted files into local object URLs. Real apps
// return a persisted URL from their upload endpoint.
const uploadFunction = async (file) => ({
file_url: URL.createObjectURL(file),
file_name: file.name,
})
// Autosized single-visual-line title.
const { textarea: titleTextarea } = useTextareaAutosize({ input: title })
</script>
<template>
<MobileShell>
<Editor
v-model="content"
:extensions="extensions"
:upload-function="uploadFunction"
placeholder="Type '/' for commands or select text to format"
>
<template #default="{ editor }">
<PageHeaderMobile title="New discussion">
<template #left>
<Button variant="ghost" icon="lucide-chevron-left" label="Back" />
</template>
<template #right>
<Button variant="solid">Publish</Button>
</template>
</PageHeaderMobile>
<!-- Toolbar pins under the header; the row scrolls sideways on narrow
screens so every tool stays reachable. -->
<div class="sticky top-0 z-10 border-b bg-surface-base">
<div class="overflow-x-auto px-2 py-1">
<EditorFixedMenu :editor="editor" :items="toolbar" />
</div>
</div>
<div class="px-4 pb-40 pt-4">
<textarea
ref="titleTextarea"
class="w-full resize-none border-0 bg-transparent px-0 py-0.5 text-3xl-semibold text-ink-gray-8 placeholder-ink-gray-3 focus:ring-0"
v-model="title"
placeholder="Title"
rows="1"
wrap="soft"
maxlength="140"
@keydown.enter.prevent="editor.commands.focus()"
@keydown.down.prevent="editor.commands.focus()"
/>
<EditorContent
:editor="editor"
class="prose-v3 mt-1 max-w-[unset] overflow-auto"
/>
</div>
</template>
</Editor>
</MobileShell>
</template>vue
<script setup>
import { reactive, ref, onBeforeUnmount, useTemplateRef } from 'vue'
import {
Badge,
Button,
DesktopShell,
PageHeader,
PageHeaderTitle,
ScrollArea,
Sidebar,
SidebarCollapseToggle,
SidebarHeader,
SidebarItem,
SidebarLabel,
TabButtons,
} from 'frappe-ui'
import DealCard from './DealCard.vue'
const nav = [
{ label: 'Notifications', icon: 'lucide-inbox', count: 4 },
{ label: 'Leads', icon: 'lucide-users' },
{ label: 'Deals', icon: 'lucide-handshake' },
{ label: 'Contacts', icon: 'lucide-contact' },
{ label: 'Organizations', icon: 'lucide-building-2' },
{ label: 'Notes', icon: 'lucide-notebook-pen' },
{ label: 'Tasks', icon: 'lucide-list-todo' },
]
const activeNav = ref('Deals')
const view = ref('Board')
const owners = {
evan: {
name: 'Evan You',
title: 'Account Executive',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
deals: 8,
},
priya: {
name: 'Priya Nair',
title: 'Sales Manager',
image: 'https://i.pravatar.cc/150?img=5',
deals: 12,
},
sam: {
name: 'Sam Rivera',
title: 'Account Executive',
image: 'https://i.pravatar.cc/150?img=12',
deals: 5,
},
ana: {
name: 'Ana Costa',
title: 'SDR',
image: 'https://i.pravatar.cc/150?img=9',
deals: 9,
},
}
const logo = (org) =>
`https://api.dicebear.com/9.x/shapes/svg?seed=${encodeURIComponent(org)}`
const statusDot = {
gray: 'bg-surface-gray-7',
amber: 'bg-surface-amber-7',
blue: 'bg-surface-blue-7',
green: 'bg-surface-green-7',
}
const columns = ref([
{
status: 'Qualification',
theme: 'gray',
deals: [
{
org: 'Globex',
value: '$ 45,000',
owner: 'priya',
due: 'Jul 18',
tag: 'Inbound',
},
{
org: 'Stark Industries',
value: '$ 1,10,000',
owner: 'ana',
due: 'Jul 22',
tag: 'Referral',
},
{
org: 'Wayne Corp',
value: '$ 32,000',
owner: 'sam',
due: 'Aug 2',
tag: 'Outbound',
},
{
org: 'Cyberdyne',
value: '$ 76,000',
owner: 'evan',
due: 'Aug 9',
tag: 'Outbound',
},
{
org: 'Vandelay',
value: '$ 28,500',
owner: 'priya',
due: 'Aug 14',
tag: 'Inbound',
},
],
},
{
status: 'Negotiation',
theme: 'amber',
deals: [
{
org: 'Acme Corp',
value: '$ 1,20,000',
owner: 'evan',
due: 'Jul 12',
tag: 'Inbound',
},
{
org: 'Umbrella Labs',
value: '$ 88,000',
owner: 'ana',
due: 'Jul 15',
tag: 'Partner',
},
{
org: 'Wonka Industries',
value: '$ 54,000',
owner: 'sam',
due: 'Jul 21',
tag: 'Outbound',
},
{
org: 'Duff Co',
value: '$ 39,000',
owner: 'priya',
due: 'Jul 25',
tag: 'Referral',
},
],
},
{
status: 'Ready to Close',
theme: 'blue',
deals: [
{
org: 'Hooli',
value: '$ 2,05,000',
owner: 'evan',
due: 'Jul 9',
tag: 'Expansion',
},
{
org: 'Pied Piper',
value: '$ 64,000',
owner: 'priya',
due: 'Jul 11',
tag: 'Inbound',
},
{
org: 'Massive Dynamic',
value: '$ 1,75,000',
owner: 'ana',
due: 'Jul 14',
tag: 'Partner',
},
],
},
{
status: 'Won',
theme: 'green',
deals: [
{
org: 'Initech',
value: '$ 2,40,000',
owner: 'sam',
due: 'Closed Jul 1',
tag: 'Renewal',
},
{
org: 'Soylent Corp',
value: '$ 96,000',
owner: 'evan',
due: 'Closed Jun 28',
tag: 'Expansion',
},
{
org: 'Tyrell Corp',
value: '$ 1,32,000',
owner: 'priya',
due: 'Closed Jun 20',
tag: 'Referral',
},
],
},
])
/* -- Drag and drop (pointer events) ---------------------------------------
A mouse lifts a card after 4px of movement; a touch lifts after holding
still for 250ms (drifting earlier stays a native scroll).
The dragged deal NEVER leaves the data model — once lifted it's rendered as
an empty slot in place while a ghost follows the pointer. Dragging live-
reorders the deal into the slot under the pointer, so the *same* element
glides (FLIP) instead of a placeholder fading in and out. Dropping only
stops the drag; the deal is already where it belongs, so releasing it in the
same spot animates nothing. */
const LIFT_DELAY = 250 // ms a touch must hold still before it becomes a drag
const DRIFT_TOLERANCE = 8 // px a held touch may drift before we call it a scroll
const MOUSE_THRESHOLD = 4 // px a mouse must move before the drag starts
const EDGE = 48 // px-wide edge zones that trigger auto-scroll
const SETTLE_MS = 150 // ms the ghost glides onto the slot on release
const boardRef = useTemplateRef('boardRef')
const columnRefs = [] // per-column ScrollArea instances, indexed by column
const boardEl = () => boardRef.value
const columnEl = (col) => columnRefs[col]?.viewportElement
const pointer = { x: 0, y: 0 }
let pending = null // pointerdown that may still become a drag
let rafId = 0
let settleTimer = 0 // the release "settle" animation timer
const drag = reactive({
active: false,
settling: false, // release animation: ghost gliding onto its slot
deal: null, // the deal being dragged — stays inside `columns` the whole time
from: null, // { col, index } — restore point if the drag is cancelled
overCol: 0, // column under the pointer, drives vertical auto-scroll
width: 0,
height: 0,
offsetX: 0,
offsetY: 0,
x: 0,
y: 0,
})
// Where the dragged deal currently sits in the data model.
function locate() {
for (let col = 0; col < columns.value.length; col++) {
const index = columns.value[col].deals.indexOf(drag.deal)
if (index !== -1) return { col, index }
}
return null
}
function onCardPointerDown(event, col, deal) {
if (drag.active || pending) return
if (event.pointerType === 'mouse' && event.button !== 0) return
// Grabbing a control inside the card (dropdown, owner hover card) is a
// click, not a drag.
if (event.target.closest('button')) return
pointer.x = event.clientX
pointer.y = event.clientY
const mouse = event.pointerType === 'mouse'
pending = {
col,
deal,
el: event.currentTarget,
startX: event.clientX,
startY: event.clientY,
mouse,
timer: mouse ? 0 : window.setTimeout(lift, LIFT_DELAY),
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
}
function lift() {
const { col, deal, el, timer } = pending
clearTimeout(timer)
pending = null
const rect = el.getBoundingClientRect()
drag.deal = deal
drag.from = { col, index: columns.value[col].deals.indexOf(deal) }
drag.overCol = col
drag.width = rect.width
drag.height = rect.height
drag.offsetX = pointer.x - rect.left
drag.offsetY = pointer.y - rect.top
drag.x = rect.left
drag.y = rect.top
drag.active = true
// The gesture is ours now: a passive:false touchmove listener is the only
// way to stop the browser from starting a scroll (which would fire
// pointercancel and kill the drag).
window.addEventListener('touchmove', preventScroll, { passive: false })
navigator.vibrate?.(10)
rafId = requestAnimationFrame(tick)
}
function onPointerMove(event) {
pointer.x = event.clientX
pointer.y = event.clientY
if (!pending) return
const drift = Math.hypot(
event.clientX - pending.startX,
event.clientY - pending.startY,
)
if (pending.mouse) {
if (drift > MOUSE_THRESHOLD) lift()
} else if (drift > DRIFT_TOLERANCE) {
cancelPending() // the finger is scrolling, not holding
}
}
// Runs every frame while dragging — pointermove alone isn't enough because a
// pointer held still at an edge keeps auto-scrolling, moving the containers
// (and slots) under it.
function tick() {
drag.x = pointer.x - drag.offsetX
drag.y = pointer.y - drag.offsetY
autoscroll()
updateDropSlot()
rafId = requestAnimationFrame(tick)
}
// Move the dragged deal into the slot the pointer is over. The insertion index
// is counted over the OTHER cards only (the dragged slot is skipped), which
// makes it a no-op when nothing changed and self-stabilising otherwise:
// inserting pushes the boundary card away from the pointer, so the choice never
// oscillates between two neighbours.
function updateDropSlot() {
for (let col = 0; col < columns.value.length; col++) {
const el = columnEl(col)
if (!el) continue
const rect = el.getBoundingClientRect()
if (pointer.x < rect.left || pointer.x > rect.right) continue
drag.overCol = col
let index = 0
for (const card of el.querySelectorAll(
'[data-deal-card]:not([data-dragging])',
)) {
const r = card.getBoundingClientRect()
if (pointer.y > r.top + r.height / 2) index++
}
moveDealTo(col, index)
return
}
// Pointer is in a gap between columns: leave the deal where it is.
}
function moveDealTo(col, index) {
const cur = locate()
if (!cur || (cur.col === col && cur.index === index)) return
columns.value[cur.col].deals.splice(cur.index, 1)
columns.value[col].deals.splice(index, 0, drag.deal)
}
function autoscroll() {
const scrollSpeed = (overshoot) =>
Math.ceil((Math.min(overshoot, EDGE) / EDGE) * 14)
const b = boardEl()
if (b) {
const r = b.getBoundingClientRect()
if (pointer.x < r.left + EDGE)
b.scrollLeft -= scrollSpeed(r.left + EDGE - pointer.x)
else if (pointer.x > r.right - EDGE)
b.scrollLeft += scrollSpeed(pointer.x - (r.right - EDGE))
}
const colEl = columnEl(drag.overCol)
if (colEl) {
const r = colEl.getBoundingClientRect()
if (pointer.y < r.top + EDGE)
colEl.scrollTop -= scrollSpeed(r.top + EDGE - pointer.y)
else if (pointer.y > r.bottom - EDGE)
colEl.scrollTop += scrollSpeed(pointer.y - (r.bottom - EDGE))
}
}
// Land the ghost on the slot, then reveal the real card. The slot is the same
// element that has held the deal's place all along, so revealing it just swaps
// dashed → card with no enter animation and nothing else moves.
function onPointerUp() {
if (pending) return cancelPending()
if (!drag.active || drag.settling) return
cancelAnimationFrame(rafId)
removeGestureListeners()
const slot = document.querySelector('[data-dragging]')
if (!slot) return endDrag()
const rect = slot.getBoundingClientRect()
drag.settling = true
drag.x = rect.left
drag.y = rect.top
settleTimer = window.setTimeout(endDrag, SETTLE_MS)
}
function onPointerCancel() {
if (pending) return cancelPending()
if (!drag.active) return
const cur = locate()
if (cur && (cur.col !== drag.from.col || cur.index !== drag.from.index)) {
columns.value[cur.col].deals.splice(cur.index, 1)
columns.value[drag.from.col].deals.splice(drag.from.index, 0, drag.deal)
}
endDrag()
}
function cancelPending() {
clearTimeout(pending?.timer)
pending = null
removeGestureListeners()
}
function endDrag() {
cancelAnimationFrame(rafId)
clearTimeout(settleTimer)
removeGestureListeners()
drag.active = false
drag.settling = false
drag.deal = null
drag.from = null
}
function removeGestureListeners() {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('touchmove', preventScroll)
}
function preventScroll(event) {
event.preventDefault()
}
onBeforeUnmount(() => {
clearTimeout(pending?.timer)
clearTimeout(settleTimer)
cancelAnimationFrame(rafId)
removeGestureListeners()
})
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell :scroll="false">
<template #sidebar>
<Sidebar width="14rem" class="border-r">
<SidebarHeader
title="Deals"
subtitle="brightloom.frappe.cloud"
logo="https://api.dicebear.com/10.x/disco/svg?seed=Brightloom"
:menu-items="[
{ label: 'Switch team', icon: 'lucide-arrow-left-right' },
{ label: 'Log out', icon: 'lucide-log-out' },
]"
/>
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<nav class="space-y-0.5">
<SidebarItem
v-for="item in nav"
:key="item.label"
:active="activeNav === item.label"
@click="activeNav = item.label"
>
<template #prefix>
<span :class="item.icon" class="size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">{{ item.label }}</span>
<template #suffix>
<Badge
v-if="item.count"
variant="ghost"
:label="String(item.count)"
/>
</template>
</SidebarItem>
</nav>
<div class="mt-4 flex h-7 items-center">
<SidebarLabel>Pinned views</SidebarLabel>
</div>
<nav class="mt-0.5 space-y-0.5">
<SidebarItem>
<template #prefix>
<span class="lucide-star size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">My open deals</span>
</SidebarItem>
<SidebarItem>
<template #prefix>
<span class="lucide-star size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">Closing this month</span>
</SidebarItem>
</nav>
</ScrollArea>
<div class="mt-auto px-2 pb-2">
<SidebarCollapseToggle />
</div>
</Sidebar>
</template>
<PageHeader>
<PageHeaderTitle title="Deals" />
<div class="flex items-center gap-2">
<TabButtons
v-model="view"
:options="[{ label: 'Board' }, { label: 'List' }]"
/>
<Button label="Filter" icon-left="lucide-list-filter" />
<Button label="Sort" icon-left="lucide-arrow-down-up" />
<Button variant="solid" label="New deal" icon-left="lucide-plus" />
</div>
</PageHeader>
<!-- DesktopShell `:scroll="false"` makes the content area fill the height
below the header without page-scrolling. The board itself owns
horizontal overflow while each column owns vertical overflow. -->
<div
ref="boardRef"
class="min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
>
<div class="flex h-full select-none gap-3 p-4">
<div
v-for="(column, ci) in columns"
:key="column.status"
class="flex min-h-0 w-72 shrink-0 flex-col rounded-lg bg-surface-gray-1 dark:bg-transparent dark:border"
>
<div class="flex items-center justify-between pl-3 pr-1 pt-1">
<div class="flex items-center gap-2">
<span
class="size-2 rounded-full"
:class="statusDot[column.theme]"
aria-hidden="true"
/>
<span class="text-sm font-medium text-ink-gray-8">
{{ column.status }}
</span>
<span class="text-sm text-ink-gray-5">
{{ column.deals.length }}
</span>
</div>
<Button variant="ghost" icon="lucide-plus" label="Add deal" />
</div>
<!-- The card list carries its own padding (instead of the column)
so card shadows aren't clipped by the viewport's edge. -->
<ScrollArea
:ref="(r) => (columnRefs[ci] = r)"
class="min-h-0 flex-1"
>
<TransitionGroup
name="deals"
tag="div"
class="flex flex-col gap-2 p-2"
>
<div
v-for="deal in column.deals"
:key="deal.org"
class="shrink-0"
data-deal-card
:data-dragging="deal === drag.deal ? '' : null"
@pointerdown="onCardPointerDown($event, ci, deal)"
@dragstart.prevent
@contextmenu="drag.active && $event.preventDefault()"
>
<!-- The dragged card leaves an empty slot in place; the ghost
carries its content while it's lifted. -->
<div
v-if="deal === drag.deal"
class="rounded-lg border-2 border-dashed border-outline-gray-2 bg-surface-gray-2"
:style="{ height: drag.height + 'px' }"
/>
<div
v-else
class="group cursor-grab rounded-lg border bg-surface-elevation-1 p-3 transition hover:shadow-sm"
>
<DealCard :deal="deal" :owners="owners" :logo="logo" />
</div>
</div>
</TransitionGroup>
</ScrollArea>
</div>
</div>
</div>
</DesktopShell>
<!-- The lifted card: a fixed-position ghost that tracks the pointer. It
ignores pointer events so hit-testing sees the board beneath it. -->
<Teleport to="body">
<div
v-if="drag.active"
class="pointer-events-none fixed left-0 top-0 z-50 will-change-transform"
:class="
drag.settling ? 'transition-transform duration-150 ease-out' : ''
"
:style="{
width: drag.width + 'px',
transform: `translate(${drag.x}px, ${drag.y}px)`,
}"
>
<div
class="drag-ghost rounded-lg border bg-surface-elevation-1 p-3 transition-[transform,box-shadow] duration-150 ease-out"
:class="drag.settling ? '' : 'rotate-2 scale-[1.02] shadow-xl'"
>
<DealCard :deal="drag.deal" :owners="owners" :logo="logo" ghost />
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
/* The only in-list motion is FLIP: as the dragged deal is reordered, the slot
and the cards around it slide to their new positions. The lifted ghost pops
up on grab. No enter/leave — the dragged card never leaves the list, so
there's nothing to fade in or out. */
.deals-move {
transition: transform 0.2s ease;
}
.drag-ghost {
animation: drag-lift 0.15s ease-out;
}
@keyframes drag-lift {
from {
transform: none;
box-shadow: none;
}
}
</style>vue
<script setup>
import { ref } from 'vue'
import {
Avatar,
Badge,
Button,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
TabButtons,
} from 'frappe-ui'
const tab = ref('deals')
const view = ref('Board')
const owners = {
evan: {
name: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
},
priya: { name: 'Priya Nair', image: 'https://i.pravatar.cc/150?img=5' },
sam: { name: 'Sam Rivera', image: 'https://i.pravatar.cc/150?img=12' },
ana: { name: 'Ana Costa', image: 'https://i.pravatar.cc/150?img=9' },
}
const logo = (org) =>
`https://api.dicebear.com/9.x/shapes/svg?seed=${encodeURIComponent(org)}`
const statusDot = {
gray: 'bg-surface-gray-7',
amber: 'bg-surface-amber-7',
blue: 'bg-surface-blue-7',
green: 'bg-surface-green-7',
}
const columns = ref([
{
status: 'Qualification',
theme: 'gray',
deals: [
{ org: 'Globex', value: '$ 45,000', owner: 'priya', due: 'Jul 18', tag: 'Inbound' },
{ org: 'Stark Industries', value: '$ 1,10,000', owner: 'ana', due: 'Jul 22', tag: 'Referral' },
{ org: 'Wayne Corp', value: '$ 32,000', owner: 'sam', due: 'Aug 2', tag: 'Outbound' },
{ org: 'Cyberdyne', value: '$ 76,000', owner: 'evan', due: 'Aug 9', tag: 'Outbound' },
{ org: 'Vandelay', value: '$ 28,500', owner: 'priya', due: 'Aug 14', tag: 'Inbound' },
],
},
{
status: 'Negotiation',
theme: 'amber',
deals: [
{ org: 'Acme Corp', value: '$ 1,20,000', owner: 'evan', due: 'Jul 12', tag: 'Inbound' },
{ org: 'Umbrella Labs', value: '$ 88,000', owner: 'ana', due: 'Jul 15', tag: 'Partner' },
{ org: 'Wonka Industries', value: '$ 54,000', owner: 'sam', due: 'Jul 21', tag: 'Outbound' },
{ org: 'Duff Co', value: '$ 39,000', owner: 'priya', due: 'Jul 25', tag: 'Referral' },
],
},
{
status: 'Ready to Close',
theme: 'blue',
deals: [
{ org: 'Hooli', value: '$ 2,05,000', owner: 'evan', due: 'Jul 9', tag: 'Expansion' },
{ org: 'Pied Piper', value: '$ 64,000', owner: 'priya', due: 'Jul 11', tag: 'Inbound' },
{ org: 'Massive Dynamic', value: '$ 1,75,000', owner: 'ana', due: 'Jul 14', tag: 'Partner' },
],
},
{
status: 'Won',
theme: 'green',
deals: [
{ org: 'Initech', value: '$ 2,40,000', owner: 'sam', due: 'Closed Jul 1', tag: 'Renewal' },
{ org: 'Soylent Corp', value: '$ 96,000', owner: 'evan', due: 'Closed Jun 28', tag: 'Expansion' },
{ org: 'Tyrell Corp', value: '$ 1,32,000', owner: 'priya', due: 'Closed Jun 20', tag: 'Referral' },
],
},
])
</script>
<template>
<MobileShell>
<PageHeaderMobile title="Deals">
<template #right>
<Button variant="ghost" icon="lucide-plus" label="New deal" />
</template>
</PageHeaderMobile>
<div class="flex h-full flex-col">
<div class="flex items-center gap-2 px-4 py-3">
<TabButtons
v-model="view"
class="flex-1"
:options="[{ label: 'Board' }, { label: 'List' }]"
/>
<Button icon="lucide-list-filter" label="Filter" />
<Button icon="lucide-arrow-down-up" label="Sort" />
</div>
<!-- The board pans horizontally with column snapping; each column scrolls
its own cards. -->
<div
class="flex min-h-0 flex-1 snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain scroll-px-4 px-4 pb-4"
>
<div
v-for="column in columns"
:key="column.status"
class="flex min-h-0 w-72 shrink-0 snap-start flex-col rounded-lg bg-surface-gray-1"
>
<div class="flex items-center justify-between pl-3 pr-1 pt-1">
<div class="flex items-center gap-2">
<span
class="size-2 rounded-full"
:class="statusDot[column.theme]"
aria-hidden="true"
/>
<span class="text-sm font-medium text-ink-gray-8">
{{ column.status }}
</span>
<span class="text-sm text-ink-gray-5">
{{ column.deals.length }}
</span>
</div>
<Button variant="ghost" icon="lucide-plus" label="Add deal" />
</div>
<!-- The card list carries its own padding (instead of the column)
so card shadows aren't clipped by the scroll container's edge. -->
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-2 p-2">
<div
v-for="deal in column.deals"
:key="deal.org"
class="rounded-lg border bg-surface-base p-3 shadow-sm"
>
<div class="flex items-center gap-2">
<Avatar
:label="deal.org"
:image="logo(deal.org)"
size="sm"
shape="square"
/>
<span
class="flex-1 truncate text-base font-medium text-ink-gray-9"
>
{{ deal.org }}
</span>
</div>
<div class="mt-2.5 flex items-center justify-between">
<span class="text-base font-semibold text-ink-gray-8">
{{ deal.value }}
</span>
<Badge variant="outline" :label="deal.tag" />
</div>
<div class="mt-2.5 flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Avatar
:label="owners[deal.owner].name"
:image="owners[deal.owner].image"
size="xs"
/>
<span class="text-sm text-ink-gray-6">
{{ owners[deal.owner].name }}
</span>
</div>
<span class="flex items-center gap-1 text-sm text-ink-gray-5">
<span class="lucide-calendar size-3.5" aria-hidden="true" />
{{ deal.due }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<template #nav>
<MobileNav>
<MobileNavItem
label="Notifications"
icon="lucide-inbox"
:active="tab === 'notifications'"
@click="tab = 'notifications'"
/>
<MobileNavItem
label="Leads"
icon="lucide-users"
:active="tab === 'leads'"
@click="tab = 'leads'"
/>
<MobileNavItem
label="Deals"
icon="lucide-handshake"
:active="tab === 'deals'"
@click="tab = 'deals'"
/>
<MobileNavItem
label="Contacts"
icon="lucide-contact"
:active="tab === 'contacts'"
@click="tab = 'contacts'"
/>
</MobileNav>
</template>
</MobileShell>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
Badge,
Button,
DesktopShell,
PageHeader,
PageHeaderTitle,
ScrollArea,
Sidebar,
SidebarCollapseToggle,
SidebarHeader,
SidebarItem,
SidebarLabel,
TabButtons,
} from 'frappe-ui'
import {
List,
ListCell,
ListHeader,
ListHeaderCell,
ListHeaderCellSort,
ListRow,
ListRows,
} from 'frappe-ui/list'
const nav = [
{ label: 'Tickets', icon: 'lucide-ticket', count: 24 },
{ label: 'Customers', icon: 'lucide-building-2' },
{ label: 'Contacts', icon: 'lucide-contact' },
{ label: 'Agents', icon: 'lucide-headset' },
{ label: 'Knowledge base', icon: 'lucide-book-open' },
{ label: 'Canned responses', icon: 'lucide-message-square-text' },
]
const activeNav = ref('Tickets')
const views = ['My open tickets', 'Urgent', 'Unassigned', 'Solved this week']
const statusTheme = {
Open: 'red',
Replied: 'blue',
Resolved: 'green',
Paused: 'gray',
}
const priorityDot = {
Urgent: 'bg-surface-red-7',
High: 'bg-surface-amber-7',
Medium: 'bg-surface-gray-7',
Low: 'bg-surface-gray-4',
}
// SLA state → text color. `met`/`breached` read as pass/fail against the
// target, `pending` is still counting down, `none` means no target applies.
const slaTextClass = {
met: 'text-ink-green-6',
breached: 'text-ink-red-6',
pending: 'text-ink-gray-5',
none: 'text-ink-gray-4',
}
// `firstResponse` / `resolution` mirror a helpdesk SLA: `label` is the elapsed
// or remaining time, `state` drives the color (met / breached / pending / none).
const tickets = [
{
id: 1024,
subject: 'Cannot connect custom domain to my site',
customer: 'Acme Corp',
status: 'Open',
priority: 'Urgent',
agent: 'Priya Nair',
agentImage: 'https://i.pravatar.cc/150?img=8',
firstResponse: { label: 'Due 8 m', state: 'pending' },
resolution: { label: 'Due 3 h', state: 'pending' },
modified: 12,
modifiedLabel: '12 m',
},
{
id: 1023,
subject: 'Invoice shows wrong billing address',
customer: 'Globex',
status: 'Replied',
priority: 'High',
agent: 'Evan You',
agentImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
firstResponse: { label: '6 m', state: 'met' },
resolution: { label: 'Due 5 h', state: 'pending' },
modified: 45,
modifiedLabel: '45 m',
},
{
id: 1022,
subject: 'Webhook deliveries failing since yesterday',
customer: 'Initech',
status: 'Open',
priority: 'High',
agent: 'Sam Rivera',
agentImage: 'https://i.pravatar.cc/150?img=15',
firstResponse: { label: 'Overdue 20 m', state: 'breached' },
resolution: { label: 'Due 1 h', state: 'pending' },
modified: 120,
modifiedLabel: '2 h',
},
{
id: 1021,
subject: 'How do I export all my data?',
customer: 'Hooli',
status: 'Replied',
priority: 'Medium',
agent: 'Ana Costa',
agentImage: 'https://i.pravatar.cc/150?img=25',
firstResponse: { label: '18 m', state: 'met' },
resolution: { label: 'Due 1 d', state: 'pending' },
modified: 300,
modifiedLabel: '5 h',
},
{
id: 1017,
subject: 'Payment gateway returns 500 on checkout',
customer: 'Wayne Enterprises',
status: 'Open',
priority: 'Urgent',
agent: 'Leo Park',
agentImage: 'https://i.pravatar.cc/150?img=33',
firstResponse: { label: 'Due 4 m', state: 'pending' },
resolution: { label: 'Due 2 h', state: 'pending' },
modified: 5,
modifiedLabel: '5 m',
},
{
id: 1016,
subject: 'API rate limit hit unexpectedly',
customer: 'Soylent',
status: 'Replied',
priority: 'High',
agent: 'Mara Singh',
agentImage: 'https://i.pravatar.cc/150?img=45',
firstResponse: { label: '11 m', state: 'met' },
resolution: { label: 'Due 6 h', state: 'pending' },
modified: 30,
modifiedLabel: '30 m',
},
{
id: 1015,
subject: 'Cannot invite new team members',
customer: 'Cyberdyne',
status: 'Open',
priority: 'Medium',
agent: 'Ana Costa',
agentImage: 'https://i.pravatar.cc/150?img=25',
firstResponse: { label: 'Overdue 5 m', state: 'breached' },
resolution: { label: 'Due 9 h', state: 'pending' },
modified: 75,
modifiedLabel: '1 h',
},
{
id: 1014,
subject: 'Emails landing in the spam folder',
customer: 'Tyrell Corp',
status: 'Replied',
priority: 'Medium',
agent: 'Sam Rivera',
agentImage: 'https://i.pravatar.cc/150?img=15',
firstResponse: { label: '24 m', state: 'met' },
resolution: { label: 'Due 12 h', state: 'pending' },
modified: 210,
modifiedLabel: '3 h 30 m',
},
{
id: 1013,
subject: 'Report export times out for large datasets',
customer: 'Massive Dynamic',
status: 'Replied',
priority: 'High',
agent: 'Leo Park',
agentImage: 'https://i.pravatar.cc/150?img=33',
firstResponse: { label: '9 m', state: 'met' },
resolution: { label: 'Due 4 h', state: 'pending' },
modified: 260,
modifiedLabel: '4 h',
},
{
id: 1012,
subject: 'Typo in the onboarding welcome email',
customer: 'Wonka Industries',
status: 'Open',
priority: 'Low',
agent: 'Mara Singh',
agentImage: 'https://i.pravatar.cc/150?img=45',
firstResponse: { label: 'Due 2 d', state: 'pending' },
resolution: { label: 'Due 5 d', state: 'pending' },
modified: 480,
modifiedLabel: '8 h',
},
{
id: 1020,
subject: 'Two-factor authentication reset request',
customer: 'Umbrella Labs',
status: 'Paused',
priority: 'Medium',
agent: 'Priya Nair',
agentImage: 'https://i.pravatar.cc/150?img=8',
firstResponse: { label: '14 m', state: 'met' },
resolution: { label: 'On hold', state: 'none' },
modified: 1440,
modifiedLabel: '1 d',
},
{
id: 1011,
subject: 'Production site down after last deploy',
customer: 'Oscorp',
status: 'Replied',
priority: 'Urgent',
agent: 'Sam Rivera',
agentImage: 'https://i.pravatar.cc/150?img=15',
firstResponse: { label: '3 m', state: 'met' },
resolution: { label: 'Overdue 40 m', state: 'breached' },
modified: 1500,
modifiedLabel: '1 d',
},
{
id: 1019,
subject: 'Feature request: dark mode for the portal',
customer: 'Pied Piper',
status: 'Resolved',
priority: 'Low',
agent: 'Evan You',
agentImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
firstResponse: { label: '32 m', state: 'met' },
resolution: { label: '1 d 4 h', state: 'met' },
modified: 2880,
modifiedLabel: '2 d',
},
{
id: 1010,
subject: 'Refund not reflected on my statement',
customer: 'Gringotts',
status: 'Resolved',
priority: 'Medium',
agent: 'Ana Costa',
agentImage: 'https://i.pravatar.cc/150?img=25',
firstResponse: { label: '19 m', state: 'met' },
resolution: { label: '6 h 12 m', state: 'met' },
modified: 3600,
modifiedLabel: '2 d',
},
{
id: 1018,
subject: 'SSO login loops back to the sign-in page',
customer: 'Stark Industries',
status: 'Resolved',
priority: 'Urgent',
agent: 'Sam Rivera',
agentImage: 'https://i.pravatar.cc/150?img=15',
firstResponse: { label: '52 m', state: 'breached' },
resolution: { label: '2 d 1 h', state: 'breached' },
modified: 4320,
modifiedLabel: '3 d',
},
{
id: 1009,
subject: 'Update company logo on generated invoices',
customer: 'Nakatomi',
status: 'Resolved',
priority: 'Low',
agent: 'Leo Park',
agentImage: 'https://i.pravatar.cc/150?img=33',
firstResponse: { label: '41 m', state: 'met' },
resolution: { label: '3 d 2 h', state: 'met' },
modified: 5760,
modifiedLabel: '4 d',
},
]
const filterTab = ref('Open')
const filteredTickets = computed(() => {
if (filterTab.value === 'Open') {
return tickets.filter((t) => ['Open', 'Replied'].includes(t.status))
}
return tickets
})
// Sort state, toggle rules, and the comparator are app code — the header
// cells only render chrome for whatever `direction` you hand them.
const sortField = ref('modified')
const sortDirection = ref('asc')
function toggleSort(field) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
}
function directionFor(field) {
return sortField.value === field ? sortDirection.value : null
}
const priorityRank = { Urgent: 0, High: 1, Medium: 2, Low: 3 }
const sortedTickets = computed(() => {
const factor = sortDirection.value === 'desc' ? -1 : 1
return [...filteredTickets.value].sort((a, b) => {
if (sortField.value === 'priority') {
return factor * (priorityRank[a.priority] - priorityRank[b.priority])
}
return factor * (a.modified - b.modified)
})
})
const selection = ref([])
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell>
<template #sidebar>
<Sidebar width="14rem" class="border-r">
<SidebarHeader
title="Tickets"
subtitle="helpdesk.fernwood.io"
logo="https://api.dicebear.com/10.x/disco/svg?seed=Fernwood"
:menu-items="[
{ label: 'Switch team', icon: 'lucide-arrow-left-right' },
{ label: 'Log out', icon: 'lucide-log-out' },
]"
/>
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<nav class="space-y-0.5">
<SidebarItem
v-for="item in nav"
:key="item.label"
:active="activeNav === item.label"
@click="activeNav = item.label"
>
<template #prefix>
<span :class="item.icon" class="size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">{{ item.label }}</span>
<template #suffix>
<Badge
v-if="item.count"
variant="ghost"
:label="String(item.count)"
/>
</template>
</SidebarItem>
</nav>
<div class="mt-4 flex h-7 items-center">
<SidebarLabel>Views</SidebarLabel>
</div>
<nav class="mt-0.5 space-y-0.5">
<SidebarItem v-for="view in views" :key="view">
<template #prefix>
<span class="lucide-list-filter size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">{{ view }}</span>
</SidebarItem>
</nav>
</ScrollArea>
<div class="mt-auto px-2 pb-2">
<SidebarCollapseToggle />
</div>
</Sidebar>
</template>
<PageHeader>
<div class="flex items-center gap-2">
<PageHeaderTitle title="Tickets" />
<span v-if="selection.length" class="text-sm text-ink-gray-5">
{{ selection.length }} selected
</span>
</div>
<div class="flex items-center gap-2">
<template v-if="selection.length">
<Button label="Assign" icon-left="lucide-user-plus" />
<Button label="Close tickets" icon-left="lucide-check" />
</template>
<template v-else>
<TabButtons
v-model="filterTab"
:options="[{ label: 'Open' }, { label: 'All' }]"
/>
<Button label="Filter" icon-left="lucide-list-filter" />
<Button
variant="solid"
label="New ticket"
icon-left="lucide-plus"
/>
</template>
</div>
</PageHeader>
<div class="px-3 pt-3 sm:px-5">
<List
class="w-full list-row-px-3"
:columns="[
'minmax(0,1fr)',
'11rem',
'6.5rem',
'6rem',
'9rem',
'5rem',
]"
:row-height="60"
selectable
v-model:selection="selection"
>
<ListHeader>
<ListHeaderCell>Subject</ListHeaderCell>
<ListHeaderCell>Response / Resolution</ListHeaderCell>
<ListHeaderCell>Status</ListHeaderCell>
<ListHeaderCellSort
:direction="directionFor('priority')"
@click="toggleSort('priority')"
>
Priority
</ListHeaderCellSort>
<ListHeaderCell>Assigned to</ListHeaderCell>
<ListHeaderCellSort
:direction="directionFor('modified')"
align="end"
@click="toggleSort('modified')"
>
Modified
</ListHeaderCellSort>
</ListHeader>
<ListRows :items="sortedTickets" v-slot="{ item: ticket, value }">
<ListRow :value="value" @click="() => {}">
<ListCell>
<div class="min-w-0">
<div class="truncate text-base text-ink-gray-8">
{{ ticket.subject }}
</div>
<div class="mt-1.5 truncate text-sm text-ink-gray-5">
#{{ ticket.id }} · {{ ticket.customer }}
</div>
</div>
</ListCell>
<ListCell>
<div class="min-w-0 leading-tight">
<div class="flex items-center gap-1.5" title="First response">
<span
class="lucide-reply size-3.5 shrink-0 text-ink-gray-4"
aria-hidden="true"
/>
<span
class="truncate text-sm"
:class="slaTextClass[ticket.firstResponse.state]"
>
{{ ticket.firstResponse.label }}
</span>
</div>
<div
class="mt-1.5 flex items-center gap-1.5"
title="Resolution"
>
<span
class="lucide-circle-check size-3.5 shrink-0 text-ink-gray-4"
aria-hidden="true"
/>
<span
class="truncate text-sm"
:class="slaTextClass[ticket.resolution.state]"
>
{{ ticket.resolution.label }}
</span>
</div>
</div>
</ListCell>
<ListCell>
<Badge
:theme="statusTheme[ticket.status]"
:label="ticket.status"
/>
</ListCell>
<ListCell>
<span
class="size-2 shrink-0 rounded-full"
:class="priorityDot[ticket.priority]"
aria-hidden="true"
/>
<span class="ml-2 whitespace-nowrap text-base text-ink-gray-7">
{{ ticket.priority }}
</span>
</ListCell>
<ListCell>
<Avatar
:label="ticket.agent"
:image="ticket.agentImage"
size="sm"
/>
<span class="ml-2 truncate text-base text-ink-gray-7">
{{ ticket.agent }}
</span>
</ListCell>
<ListCell class="justify-end">
<span class="text-sm text-ink-gray-5">{{
ticket.modifiedLabel
}}</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</div>
</DesktopShell>
</div>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
Badge,
Button,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
TabButtons,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
const tab = ref('tickets')
const filterTab = ref('Open')
const statusTheme = {
Open: 'red',
Replied: 'blue',
Resolved: 'green',
Paused: 'gray',
}
// SLA state → text color, mirroring the desktop recipe: `met`/`breached` read
// as pass/fail against the target, `pending` is still counting down, `none`
// means no target applies (e.g. a paused ticket's resolution).
const slaTextClass = {
met: 'text-ink-green-6',
breached: 'text-ink-red-6',
pending: 'text-ink-gray-5',
none: 'text-ink-gray-4',
}
// Mirrors the desktop recipe's ticket set. `firstResponse` / `resolution` carry
// the SLA `{ label, state }` shown as a compact line on each card.
const tickets = [
{
id: 1024,
subject: 'Cannot connect custom domain to my site',
customer: 'Acme Corp',
image: 'https://i.pravatar.cc/150?img=11',
status: 'Open',
firstResponse: { label: 'Due 8m', state: 'pending' },
resolution: { label: 'Due 3h', state: 'pending' },
lastActivity: '12m',
},
{
id: 1023,
subject: 'Invoice shows wrong billing address',
customer: 'Globex',
image: 'https://i.pravatar.cc/150?img=12',
status: 'Replied',
firstResponse: { label: '6m', state: 'met' },
resolution: { label: 'Due 5h', state: 'pending' },
lastActivity: '45m',
},
{
id: 1022,
subject: 'Webhook deliveries failing since yesterday',
customer: 'Initech',
image: 'https://i.pravatar.cc/150?img=13',
status: 'Open',
firstResponse: { label: 'Overdue 20m', state: 'breached' },
resolution: { label: 'Due 1h', state: 'pending' },
lastActivity: '2h',
},
{
id: 1021,
subject: 'How do I export all my data?',
customer: 'Hooli',
image: 'https://i.pravatar.cc/150?img=14',
status: 'Replied',
firstResponse: { label: '18m', state: 'met' },
resolution: { label: 'Due 1d', state: 'pending' },
lastActivity: '5h',
},
{
id: 1017,
subject: 'Payment gateway returns 500 on checkout',
customer: 'Wayne Enterprises',
image: 'https://i.pravatar.cc/150?img=20',
status: 'Open',
firstResponse: { label: 'Due 4m', state: 'pending' },
resolution: { label: 'Due 2h', state: 'pending' },
lastActivity: '5m',
},
{
id: 1016,
subject: 'API rate limit hit unexpectedly',
customer: 'Soylent',
image: 'https://i.pravatar.cc/150?img=21',
status: 'Replied',
firstResponse: { label: '11m', state: 'met' },
resolution: { label: 'Due 6h', state: 'pending' },
lastActivity: '30m',
},
{
id: 1015,
subject: 'Cannot invite new team members',
customer: 'Cyberdyne',
image: 'https://i.pravatar.cc/150?img=22',
status: 'Open',
firstResponse: { label: 'Overdue 5m', state: 'breached' },
resolution: { label: 'Due 9h', state: 'pending' },
lastActivity: '1h',
},
{
id: 1014,
subject: 'Emails landing in the spam folder',
customer: 'Tyrell Corp',
image: 'https://i.pravatar.cc/150?img=23',
status: 'Replied',
firstResponse: { label: '24m', state: 'met' },
resolution: { label: 'Due 12h', state: 'pending' },
lastActivity: '3h',
},
{
id: 1013,
subject: 'Report export times out for large datasets',
customer: 'Massive Dynamic',
image: 'https://i.pravatar.cc/150?img=24',
status: 'Replied',
firstResponse: { label: '9m', state: 'met' },
resolution: { label: 'Due 4h', state: 'pending' },
lastActivity: '4h',
},
{
id: 1012,
subject: 'Typo in the onboarding welcome email',
customer: 'Wonka Industries',
image: 'https://i.pravatar.cc/150?img=25',
status: 'Open',
firstResponse: { label: 'Due 2d', state: 'pending' },
resolution: { label: 'Due 5d', state: 'pending' },
lastActivity: '8h',
},
{
id: 1020,
subject: 'Two-factor authentication reset request',
customer: 'Umbrella Labs',
image: 'https://i.pravatar.cc/150?img=15',
status: 'Paused',
firstResponse: { label: '14m', state: 'met' },
resolution: { label: 'On hold', state: 'none' },
lastActivity: '1d',
},
{
id: 1011,
subject: 'Production site down after last deploy',
customer: 'Oscorp',
image: 'https://i.pravatar.cc/150?img=26',
status: 'Replied',
firstResponse: { label: '3m', state: 'met' },
resolution: { label: 'Overdue 40m', state: 'breached' },
lastActivity: '1d',
},
{
id: 1019,
subject: 'Feature request: dark mode for the portal',
customer: 'Pied Piper',
image: 'https://i.pravatar.cc/150?img=16',
status: 'Resolved',
firstResponse: { label: '32m', state: 'met' },
resolution: { label: '1d 4h', state: 'met' },
lastActivity: '2d',
},
{
id: 1010,
subject: 'Refund not reflected on my statement',
customer: 'Gringotts',
image: 'https://i.pravatar.cc/150?img=27',
status: 'Resolved',
firstResponse: { label: '19m', state: 'met' },
resolution: { label: '6h 12m', state: 'met' },
lastActivity: '2d',
},
{
id: 1018,
subject: 'SSO login loops back to the sign-in page',
customer: 'Stark Industries',
image: 'https://i.pravatar.cc/150?img=28',
status: 'Resolved',
firstResponse: { label: '52m', state: 'breached' },
resolution: { label: '2d 1h', state: 'breached' },
lastActivity: '3d',
},
{
id: 1009,
subject: 'Update company logo on generated invoices',
customer: 'Nakatomi',
image: 'https://i.pravatar.cc/150?img=29',
status: 'Resolved',
firstResponse: { label: '41m', state: 'met' },
resolution: { label: '3d 2h', state: 'met' },
lastActivity: '4d',
},
]
const visibleTickets = computed(() => {
if (filterTab.value === 'Open') {
return tickets.filter((t) => ['Open', 'Replied'].includes(t.status))
}
return tickets
})
</script>
<template>
<MobileShell>
<PageHeaderMobile title="Tickets">
<template #right>
<Button
variant="ghost"
icon="lucide-list-filter"
label="Filter tickets"
/>
</template>
</PageHeaderMobile>
<div class="pb-6 pt-3">
<div class="mb-3 px-4">
<TabButtons
v-model="filterTab"
class="w-full"
:options="[{ label: 'Open' }, { label: 'All' }]"
/>
</div>
<List class="list-gap-3 list-row-px-4">
<ListRow
v-for="ticket in visibleTickets"
:key="ticket.id"
class="py-3"
@click="() => {}"
>
<ListCell class="self-start pt-0.5">
<Avatar
:image="ticket.image"
:label="ticket.customer"
size="2xl"
shape="circle"
/>
</ListCell>
<ListCell class="self-start">
<div class="min-w-0 flex-1">
<!-- The sized text lives in an inner span so it keeps its own
line-height. Putting `leading-none` on the same element as
`truncate` (overflow-hidden) would shear off descenders like
the tail of "g". -->
<div class="truncate leading-none text-ink-gray-8">
<span class="text-lg">{{ ticket.subject }}</span>
</div>
<div class="mt-1.5 truncate text-md text-ink-gray-5">
#{{ ticket.id }} · {{ ticket.customer }}
</div>
<!-- SLA line: first response + resolution, matching the desktop
Response / Resolution column. `overflow-hidden` clips rather
than wraps if a label pair runs long on a narrow screen. -->
<div
class="mt-1.5 flex items-center gap-2.5 overflow-hidden text-sm"
>
<span
class="flex items-center gap-1 whitespace-nowrap"
title="First response"
>
<span
class="lucide-reply size-3.5 shrink-0 text-ink-gray-4"
aria-hidden="true"
/>
<span :class="slaTextClass[ticket.firstResponse.state]">
{{ ticket.firstResponse.label }}
</span>
</span>
<span
class="flex items-center gap-1 whitespace-nowrap"
title="Resolution"
>
<span
class="lucide-circle-check size-3.5 shrink-0 text-ink-gray-4"
aria-hidden="true"
/>
<span :class="slaTextClass[ticket.resolution.state]">
{{ ticket.resolution.label }}
</span>
</span>
</div>
</div>
</ListCell>
<ListCell class="self-start justify-end pt-0.5">
<div>
<div class="whitespace-nowrap text-right text-sm text-ink-gray-5">
{{ ticket.lastActivity }}
</div>
<div class="mt-1.5 flex items-center justify-end">
<Badge
:theme="statusTheme[ticket.status]"
:label="ticket.status"
/>
</div>
</div>
</ListCell>
</ListRow>
</List>
</div>
<template #nav>
<MobileNav>
<MobileNavItem
label="Tickets"
icon="lucide-ticket"
:active="tab === 'tickets'"
@click="tab = 'tickets'"
/>
<MobileNavItem
label="Knowledge base"
icon="lucide-book-open"
:active="tab === 'kb'"
@click="tab = 'kb'"
/>
<MobileNavItem
label="Notifications"
icon="lucide-bell"
:active="tab === 'notifications'"
@click="tab = 'notifications'"
/>
<MobileNavItem label="You" :active="tab === 'you'" @click="tab = 'you'">
<template #default="{ active }">
<Avatar
image="https://i.pravatar.cc/150?img=17"
label="Priya Nair"
size="md"
:class="active ? 'ring-2 ring-outline-gray-4' : ''"
/>
</template>
</MobileNavItem>
</MobileNav>
</template>
</MobileShell>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
Badge,
Button,
DesktopShell,
Dropdown,
PageHeaderBase,
PageHeaderTitle,
ScrollArea,
Sidebar,
SidebarCollapseToggle,
SidebarHeader,
SidebarItem,
SidebarLabel,
TabButtons,
} from 'frappe-ui'
import { List, ListCell, ListRow, ListRows } from 'frappe-ui/list'
// Mailboxes and labels — the sidebar navigation. Counts render as a quiet
// suffix.
const mailboxes = [
{ label: 'Inbox', icon: 'lucide-inbox', count: 6 },
{ label: 'Starred', icon: 'lucide-star' },
{ label: 'Sent', icon: 'lucide-send' },
{ label: 'Drafts', icon: 'lucide-file-pen-line', count: 1 },
{ label: 'Trash', icon: 'lucide-trash-2' },
]
const labels = ['Work', 'Personal']
const activeMailbox = ref('Inbox')
// Gmail-style category tabs above the list. `activeTab` filters the rows.
const mailTabs = ['Primary', 'Transactions', 'Updates', 'Promotions']
const activeTab = ref('Primary')
const evan = 'https://avatars.githubusercontent.com/u/499550?v=4'
// Each thread is one row in the list pane and one conversation in the reading
// pane; `category` routes it to a tab.
const threads = [
{
id: 1,
category: 'Primary',
subject: 'Trouble connecting Slack integration',
time: '6h',
unread: false,
preview:
'Our team is trying to connect Slack with Northwind, but the authorization process fails with an OAuth error.',
messages: [
{
author: {
name: 'Sarah Tran',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=1',
},
date: 'Aug 29, 8:03 AM',
body: [
'Hi Northwind Support,',
'Our team is trying to connect Slack with Northwind, but the authorization process fails with the following error message: “OAuth token invalid.”',
'We’ve tried reconnecting a couple of times and even restarted the workspace, but no luck. Could you help us get this integration working?',
'Thanks,\nSarah Tran\nOps Manager, BrightWave Marketing',
],
},
{
author: {
name: 'Peter Lann',
email: '[email protected]',
image: evan,
},
date: 'Aug 29, 12:56 PM',
body: [
'Hi Sarah,',
'Thanks for reaching out — happy to help! That error usually happens when Slack doesn’t grant Northwind the right permissions during the connection step. Here are a few things to try:',
'1. Make sure you’re logged into the correct Slack workspace before starting the connection.\n2. Remove Northwind from your Slack app directory, then reconnect from Settings → Integrations.\n3. Confirm an admin is approving the OAuth request — restricted workspaces block it otherwise.',
'Let me know how it goes and I’ll dig deeper if needed.',
'Regards,\nPeter Lann',
],
},
],
},
{
id: 2,
category: 'Primary',
subject: 'Missing files in shared workspace',
time: '6h',
unread: true,
preview:
'Yesterday I uploaded a set of project files to our shared workspace. Today, two of the files are nowhere to be found.',
messages: [
{
author: {
name: 'Marcus Feng',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=12',
},
date: 'Aug 29, 7:40 AM',
body: [
'Hi team,',
'Yesterday I uploaded a set of project files to our shared workspace. Today, two of the files are nowhere to be found and the folder shows the wrong item count.',
'Could you check whether they were moved or deleted? These are time-sensitive.',
],
},
],
},
{
id: 3,
category: 'Primary',
subject: 'Can’t reset my password',
time: '12h',
unread: true,
preview:
'I tried to reset my Northwind password using the “Forgot Password” link, but the reset email never arrives.',
messages: [
{
author: {
name: 'Leo Nakamura',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=33',
},
date: 'Aug 28, 9:30 PM',
body: [
'Hi,',
'I tried to reset my Northwind password using the “Forgot Password” link, but the reset email never arrives. I’ve checked spam too.',
'Can you help me regain access?',
],
},
],
},
{
id: 4,
category: 'Primary',
subject: 'Dashboard analytics not updating',
time: '1d',
unread: false,
preview:
'The analytics dashboard stopped updating yesterday around 3 PM. All charts are stuck at the same values.',
messages: [
{
author: {
name: 'Priya Nair',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=47',
},
date: 'Aug 28, 3:14 PM',
body: [
'Hi team,',
'The analytics dashboard stopped updating yesterday around 3 PM. All charts are stuck at the same values even after a hard refresh.',
'Is there a known issue?',
],
},
],
},
{
id: 5,
category: 'Primary',
subject: 'Question about adding team seats',
time: '1d',
unread: false,
preview:
'We’re growing fast and need to add five more seats. Can we do that mid-cycle, and how is it prorated?',
messages: [
{
author: {
name: 'Nadia Osei',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=5',
},
date: 'Aug 28, 11:02 AM',
body: [
'Hello,',
'We’re growing fast and need to add five more seats this week. Can we do that mid-cycle, and how is the cost prorated?',
'Thanks,\nNadia',
],
},
],
},
{
id: 6,
category: 'Primary',
subject: 'Feedback on the new editor',
time: '2d',
unread: false,
preview:
'Just wanted to say the new editor is a huge improvement. One small thing — the slash menu sometimes opens off-screen.',
messages: [
{
author: {
name: 'Tom Becker',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=8',
},
date: 'Aug 27, 4:45 PM',
body: [
'Hi folks,',
'Just wanted to say the new editor is a huge improvement — the tables especially. One small thing: the slash menu sometimes opens off-screen near the bottom of the page.',
'Not urgent, just flagging it. Keep up the great work!',
],
},
],
},
{
id: 7,
category: 'Primary',
subject: 'Follow-up from our onboarding call',
time: '2d',
unread: false,
preview:
'Thanks for the walkthrough today. Sharing the notes and the two questions the team still had about permissions.',
messages: [
{
author: {
name: 'Grace Liu',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=16',
},
date: 'Aug 27, 1:20 PM',
body: [
'Hi Priya,',
'Thanks for the walkthrough today — really helpful. I’m sharing the notes with the team and following up on the two questions we had about role permissions.',
'Talk soon,\nGrace',
],
},
],
},
{
id: 8,
category: 'Primary',
subject: 'Hitting API rate limits in production',
time: '3d',
unread: true,
preview:
'Since this morning we’re getting 429s on the documents endpoint. Traffic hasn’t changed — has the limit been lowered?',
messages: [
{
author: {
name: 'Victor Alvarez',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=52',
},
date: 'Aug 26, 9:05 AM',
body: [
'Hi,',
'Since this morning we’re getting 429 responses on the documents endpoint in production. Our traffic hasn’t changed — has the rate limit been lowered recently?',
'This is affecting live users, so any quick guidance would help.',
],
},
],
},
{
id: 9,
category: 'Primary',
subject: 'Can we enable SSO for our org?',
time: '4d',
unread: false,
preview:
'Security is asking us to move to SAML SSO. What’s involved on your side, and is it available on our current plan?',
messages: [
{
author: {
name: 'Elena Fischer',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=20',
},
date: 'Aug 25, 2:30 PM',
body: [
'Hi team,',
'Our security team is asking us to move to SAML SSO. What’s involved on your side to set it up, and is it available on our current plan?',
'Best,\nElena',
],
},
],
},
{
id: 10,
category: 'Transactions',
subject: 'Receipt for your July payment',
time: '5d',
unread: false,
preview:
'Thanks for your payment of $480.00. Your receipt for the July billing period is attached below.',
messages: [
{
author: { name: 'Northwind Billing', email: '[email protected]' },
date: 'Aug 24, 6:00 AM',
body: [
'Hi,',
'Thanks for your payment of $480.00 for the July billing period. This email is your receipt — no action needed.',
'You can view or download past invoices anytime from Settings → Billing.',
],
},
],
},
{
id: 11,
category: 'Transactions',
subject: 'Invoice #2043 is ready to view',
time: '6d',
unread: true,
preview:
'Your invoice for the upcoming period is ready. The total is $600.00, due on September 1.',
messages: [
{
author: { name: 'Northwind Billing', email: '[email protected]' },
date: 'Aug 23, 6:00 AM',
body: [
'Hi,',
'Invoice #2043 for the upcoming billing period is ready to view. The total is $600.00, due on September 1.',
'No action is needed if you’re on auto-pay — we’ll charge your card on file.',
],
},
],
},
{
id: 12,
category: 'Transactions',
subject: 'Billing discrepancy on latest invoice',
time: '6d',
unread: true,
preview:
'Our invoice for this month shows 10 Pro licenses, but we only have 8 active users. Can you review the charge?',
messages: [
{
author: {
name: 'Dana Whitfield',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=25',
},
date: 'Aug 23, 5:12 AM',
body: [
'Hello,',
'Our invoice for this month shows 10 Pro licenses, but we only have 8 active users. Can you review the charge and issue a correction if needed?',
'Thanks,\nDana',
],
},
],
},
{
id: 13,
category: 'Updates',
subject: 'What’s new: faster search and saved views',
time: '1w',
unread: false,
preview:
'This month we rebuilt search to be up to 5× faster and added saved views so you can pin the filters you use most.',
messages: [
{
author: { name: 'Northwind', email: '[email protected]' },
date: 'Aug 22, 8:00 AM',
body: [
'Hi there,',
'This month we rebuilt search to be up to 5× faster, and added saved views so you can pin the filters you use most.',
'Read the full changelog in your dashboard under What’s New.',
],
},
],
},
{
id: 14,
category: 'Updates',
subject: 'New sign-in from Chrome on macOS',
time: '1w',
unread: false,
preview:
'We noticed a new sign-in to your Northwind account. If this was you, no action is needed.',
messages: [
{
author: { name: 'Northwind Security', email: '[email protected]' },
date: 'Aug 21, 10:14 PM',
body: [
'Hi,',
'We noticed a new sign-in to your Northwind account from Chrome on macOS, near San Francisco, CA.',
'If this was you, no action is needed. If not, reset your password and review active sessions right away.',
],
},
],
},
{
id: 15,
category: 'Updates',
subject: 'Scheduled maintenance this Sunday',
time: '1w',
unread: false,
preview:
'Northwind will be briefly unavailable on Sunday, 02:00–03:00 UTC while we upgrade our database cluster.',
messages: [
{
author: { name: 'Northwind', email: '[email protected]' },
date: 'Aug 21, 9:00 AM',
body: [
'Hi,',
'Northwind will be briefly unavailable on Sunday from 02:00 to 03:00 UTC while we upgrade our database cluster.',
'No action is needed — we’re sharing this so you can plan around the window.',
],
},
],
},
{
id: 16,
category: 'Promotions',
subject: 'Upgrade to Pro and save 20% this month',
time: '1w',
unread: false,
preview:
'Unlock automations, advanced permissions, and priority support. Upgrade before month-end to lock in 20% off.',
messages: [
{
author: { name: 'Northwind', email: '[email protected]' },
date: 'Aug 20, 8:30 AM',
body: [
'Hi there,',
'Unlock automations, advanced permissions, and priority support with Northwind Pro. Upgrade before month-end to lock in 20% off your first year.',
'Questions about the plan? Just reply to this email.',
],
},
],
},
{
id: 17,
category: 'Promotions',
subject: 'You’re invited: automation webinar',
time: '2w',
unread: false,
preview:
'Join our 30-minute live session on building no-code automations, with time for Q&A at the end.',
messages: [
{
author: { name: 'Northwind Partners', email: '[email protected]' },
date: 'Aug 15, 12:00 PM',
body: [
'Hi,',
'Join our 30-minute live session on building no-code automations in Northwind, with time for Q&A at the end.',
'Save your seat from the link in your dashboard — recordings go out to everyone who registers.',
],
},
],
},
]
// The open thread, bound to the list's `v-model:active`. Row `value`s are
// strings, so this is the string id.
const activeId = ref('1')
const selected = computed(
() => threads.find((t) => String(t.id) === activeId.value) ?? threads[0],
)
const filteredThreads = computed(() =>
threads.filter((t) => t.category === activeTab.value),
)
// Reading-pane subject reads as a reply once the thread has more than one message.
const readingSubject = computed(() =>
selected.value.messages.length > 1
? `Re: ${selected.value.subject}`
: selected.value.subject,
)
// Overflow actions for the reading toolbar — mail verbs, not ticket verbs.
const moreActions = [
{ label: 'Star', icon: 'lucide-star' },
{ label: 'Mark as important', icon: 'lucide-bookmark' },
{ label: 'Move to…', icon: 'lucide-folder-input' },
{ label: 'Mute', icon: 'lucide-bell-off' },
{ label: 'Print', icon: 'lucide-printer' },
{ label: 'Report spam', icon: 'lucide-shield-alert' },
{ label: 'Block sender', icon: 'lucide-user-x' },
]
// The list pane can be collapsed from the reading toolbar, like most mail apps.
const showList = ref(true)
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell :scroll="false">
<template #sidebar>
<Sidebar width="14rem" class="border-r">
<SidebarHeader
title="Northwind"
subtitle="[email protected]"
logo="https://api.dicebear.com/10.x/disco/svg?seed=Northwind"
:menu-items="[
{ label: 'Switch mailbox', icon: 'lucide-arrow-left-right' },
{ label: 'Settings', icon: 'lucide-settings' },
{ label: 'Log out', icon: 'lucide-log-out' },
]"
/>
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<nav class="space-y-0.5">
<SidebarItem>
<template #prefix>
<span class="lucide-search size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">Search</span>
<template #suffix>
<Badge variant="ghost" label="⌘K" />
</template>
</SidebarItem>
<SidebarItem
v-for="box in mailboxes"
:key="box.label"
:active="activeMailbox === box.label"
@click="activeMailbox = box.label"
>
<template #prefix>
<span :class="box.icon" class="size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">{{ box.label }}</span>
<template #suffix>
<Badge
v-if="box.count"
variant="ghost"
:label="String(box.count)"
/>
</template>
</SidebarItem>
</nav>
<div class="mt-4 flex h-7 items-center">
<SidebarLabel>Labels</SidebarLabel>
</div>
<nav class="mt-0.5 space-y-0.5">
<SidebarItem v-for="label in labels" :key="label">
<template #prefix>
<span
class="lucide-tag size-4 text-ink-gray-5"
aria-hidden="true"
/>
</template>
<span class="flex-1 truncate text-sm">{{ label }}</span>
</SidebarItem>
</nav>
</ScrollArea>
<div class="mt-auto px-2 pb-2">
<SidebarCollapseToggle />
</div>
</Sidebar>
</template>
<!--
One split header, not two. PageHeaderBase teleports its content to the
shell's pinned PageHeaderTarget (above the scroll region), so the bar
never scrolls with the panes. Using the padding-free base — rather than
PageHeader — lets the divider between the two halves line up exactly
with the column border below.
-->
<PageHeaderBase
class="z-10 flex h-12 border-b border-outline-gray-1 bg-surface-base"
>
<!-- List half — width + right border track the list pane below. -->
<div
v-show="showList"
class="flex w-[23rem] shrink-0 items-center justify-between border-r border-outline-gray-1 px-4"
>
<PageHeaderTitle title="Inbox" />
<Button variant="ghost" icon="lucide-pen-line" label="Compose" />
</div>
<!-- Reading half — fills the rest. -->
<div
class="flex min-w-0 flex-1 items-center justify-between gap-3 px-5"
>
<div class="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
:icon="showList ? 'lucide-panel-left-close' : 'lucide-panel-left'"
label="Toggle list"
@click="showList = !showList"
/>
<PageHeaderTitle>{{ readingSubject }}</PageHeaderTitle>
</div>
<!-- Mail actions: archive / snooze / delete / mark unread + more. -->
<div class="flex shrink-0 items-center gap-1">
<Button variant="ghost" icon="lucide-archive" label="Archive" />
<Button variant="ghost" icon="lucide-alarm-clock" label="Snooze" />
<Button variant="ghost" icon="lucide-trash-2" label="Delete" />
<Button variant="ghost" icon="lucide-mail" label="Mark as unread" />
<Dropdown :options="moreActions" align="end">
<Button variant="ghost" icon="lucide-ellipsis" label="More" />
</Dropdown>
</div>
</div>
</PageHeaderBase>
<!--
The panes below carry no headers of their own now — each just owns its
scroll. DesktopShell `:scroll="false"` makes the content area fill the
remaining height without page-scrolling, so this two-pane row just fills
it and each pane's ScrollArea becomes the real scroller.
-->
<div class="flex min-h-0 flex-1">
<!-- Message list pane. -->
<section
v-show="showList"
class="flex h-full min-h-0 w-[23rem] shrink-0 flex-col border-r border-outline-gray-1"
>
<!-- Category tabs — pinned above the list; they filter, not scroll. -->
<div
class="flex shrink-0 items-center border-b border-outline-gray-1 px-4 py-2"
>
<TabButtons
v-model="activeTab"
:options="mailTabs.map((tab) => ({ label: tab }))"
/>
</div>
<ScrollArea class="min-h-0 flex-1" viewport-class="p-1">
<!--
A two-track content / trailing grid — no leading media. Setting
`columns` flips the divider default from `inset` to `full`, so the
rules span the row edge to edge instead of clearing an avatar.
`--list-row-padding-x` widens the row's inline inset to 1rem; the
cells carry the vertical padding — together they restore the
roomier px-4 py-3 rhythm of the hand-rolled version.
`v-model:active` makes the open thread first-class: the List draws
the highlight and hides the dividers hugging it, and clicking a
row sets `activeId` — so no per-row :class or @click here.
-->
<List
v-model:active="activeId"
:columns="['minmax(0,1fr)', 'auto']"
:style="{ '--list-row-padding-x': '1rem' }"
>
<ListRows
:items="filteredThreads"
v-slot="{ item: thread, value }"
>
<ListRow :value="value">
<ListCell>
<div class="min-w-0 py-3">
<div
class="truncate inline-flex items-center text-base text-ink-gray-8"
:class="
thread.unread && 'font-semibold text-ink-gray-9'
"
>
<!-- Unread dot, inline before the subject — no reserved
column, so it only takes space when present. -->
<span
v-if="thread.unread"
class="mr-1.5 inline-block size-2 rounded-full bg-surface-blue-7 align-middle"
aria-hidden="true"
/>{{ thread.subject }}
</div>
<p class="mt-0.5 line-clamp-2 text-p-sm text-ink-gray-5">
{{ thread.preview }}
</p>
</div>
</ListCell>
<ListCell class="self-start justify-end pt-3">
<span class="shrink-0 text-xs text-ink-gray-5">
{{ thread.time }} ago
</span>
</ListCell>
</ListRow>
</ListRows>
</List>
<p
v-if="!filteredThreads.length"
class="px-3 py-10 text-center text-p-sm text-ink-gray-4"
>
No conversations in {{ activeTab }}.
</p>
</ScrollArea>
</section>
<!-- Reading pane. -->
<section class="flex h-full min-h-0 min-w-0 flex-1 flex-col">
<ScrollArea class="min-h-0 flex-1">
<div class="space-y-6 px-6 py-5">
<article
v-for="(message, i) in selected.messages"
:key="i"
class="border-t border-outline-gray-1 pt-6 first:border-0 first:pt-0"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<Avatar
size="xl"
:label="message.author.name"
:image="message.author.image"
/>
<div class="min-w-0">
<div class="text-base font-semibold text-ink-gray-9">
{{ message.author.name }}
</div>
<div class="truncate text-sm mt-0.5 text-ink-gray-5">
From {{ message.author.email }}
</div>
</div>
</div>
<span class="shrink-0 text-sm text-ink-gray-5">
{{ message.date }}
</span>
</div>
<div class="mt-4 space-y-4">
<p
v-for="(paragraph, p) in message.body"
:key="p"
class="whitespace-pre-line text-p-base text-ink-gray-8"
>
{{ paragraph }}
</p>
</div>
</article>
</div>
</ScrollArea>
<!-- Reply bar, pinned below the scroll region. -->
<footer
class="flex shrink-0 items-center gap-2 border-t border-outline-gray-1 px-5 py-3"
>
<Button variant="solid" label="Reply" icon-left="lucide-reply" />
<Button
variant="ghost"
label="Reply all"
icon-left="lucide-reply-all"
/>
<Button
variant="ghost"
label="Forward"
icon-left="lucide-forward"
/>
</footer>
</section>
</div>
</DesktopShell>
</div>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
Badge,
BottomSheet,
Button,
Dropdown,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
TabButtons,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
// Mailboxes and labels — the desktop sidebar's navigation. On mobile these
// become the Home screen: a full-screen list you drill into.
const mailboxes = [
{ label: 'Inbox', icon: 'lucide-inbox', count: 6 },
{ label: 'Starred', icon: 'lucide-star' },
{ label: 'Sent', icon: 'lucide-send' },
{ label: 'Drafts', icon: 'lucide-file-pen-line', count: 1 },
{ label: 'Trash', icon: 'lucide-trash-2' },
]
const labels = ['Work', 'Personal']
// Gmail-style category tabs above the thread list. `activeTab` filters the rows.
const mailTabs = ['Primary', 'Transactions', 'Updates', 'Promotions']
const activeTab = ref('Primary')
const evan = 'https://avatars.githubusercontent.com/u/499550?v=4'
// Each thread is one row in the list screen and one conversation in the detail
// screen; `category` routes it to a tab. Copied verbatim from MailDesktop so the
// two recipes stay in lockstep.
const threads = [
{
id: 1,
category: 'Primary',
subject: 'Trouble connecting Slack integration',
time: '6h',
unread: false,
preview:
'Our team is trying to connect Slack with Northwind, but the authorization process fails with an OAuth error.',
messages: [
{
author: {
name: 'Sarah Tran',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=1',
},
date: 'Aug 29, 8:03 AM',
body: [
'Hi Northwind Support,',
'Our team is trying to connect Slack with Northwind, but the authorization process fails with the following error message: “OAuth token invalid.”',
'We’ve tried reconnecting a couple of times and even restarted the workspace, but no luck. Could you help us get this integration working?',
'Thanks,\nSarah Tran\nOps Manager, BrightWave Marketing',
],
},
{
author: {
name: 'Peter Lann',
email: '[email protected]',
image: evan,
},
date: 'Aug 29, 12:56 PM',
body: [
'Hi Sarah,',
'Thanks for reaching out — happy to help! That error usually happens when Slack doesn’t grant Northwind the right permissions during the connection step. Here are a few things to try:',
'1. Make sure you’re logged into the correct Slack workspace before starting the connection.\n2. Remove Northwind from your Slack app directory, then reconnect from Settings → Integrations.\n3. Confirm an admin is approving the OAuth request — restricted workspaces block it otherwise.',
'Let me know how it goes and I’ll dig deeper if needed.',
'Regards,\nPeter Lann',
],
},
],
},
{
id: 2,
category: 'Primary',
subject: 'Missing files in shared workspace',
time: '6h',
unread: true,
preview:
'Yesterday I uploaded a set of project files to our shared workspace. Today, two of the files are nowhere to be found.',
messages: [
{
author: {
name: 'Marcus Feng',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=12',
},
date: 'Aug 29, 7:40 AM',
body: [
'Hi team,',
'Yesterday I uploaded a set of project files to our shared workspace. Today, two of the files are nowhere to be found and the folder shows the wrong item count.',
'Could you check whether they were moved or deleted? These are time-sensitive.',
],
},
],
},
{
id: 3,
category: 'Primary',
subject: 'Can’t reset my password',
time: '12h',
unread: true,
preview:
'I tried to reset my Northwind password using the “Forgot Password” link, but the reset email never arrives.',
messages: [
{
author: {
name: 'Leo Nakamura',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=33',
},
date: 'Aug 28, 9:30 PM',
body: [
'Hi,',
'I tried to reset my Northwind password using the “Forgot Password” link, but the reset email never arrives. I’ve checked spam too.',
'Can you help me regain access?',
],
},
],
},
{
id: 4,
category: 'Primary',
subject: 'Dashboard analytics not updating',
time: '1d',
unread: false,
preview:
'The analytics dashboard stopped updating yesterday around 3 PM. All charts are stuck at the same values.',
messages: [
{
author: {
name: 'Priya Nair',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=47',
},
date: 'Aug 28, 3:14 PM',
body: [
'Hi team,',
'The analytics dashboard stopped updating yesterday around 3 PM. All charts are stuck at the same values even after a hard refresh.',
'Is there a known issue?',
],
},
],
},
{
id: 5,
category: 'Primary',
subject: 'Question about adding team seats',
time: '1d',
unread: false,
preview:
'We’re growing fast and need to add five more seats. Can we do that mid-cycle, and how is it prorated?',
messages: [
{
author: {
name: 'Nadia Osei',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=5',
},
date: 'Aug 28, 11:02 AM',
body: [
'Hello,',
'We’re growing fast and need to add five more seats this week. Can we do that mid-cycle, and how is the cost prorated?',
'Thanks,\nNadia',
],
},
],
},
{
id: 6,
category: 'Primary',
subject: 'Feedback on the new editor',
time: '2d',
unread: false,
preview:
'Just wanted to say the new editor is a huge improvement. One small thing — the slash menu sometimes opens off-screen.',
messages: [
{
author: {
name: 'Tom Becker',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=8',
},
date: 'Aug 27, 4:45 PM',
body: [
'Hi folks,',
'Just wanted to say the new editor is a huge improvement — the tables especially. One small thing: the slash menu sometimes opens off-screen near the bottom of the page.',
'Not urgent, just flagging it. Keep up the great work!',
],
},
],
},
{
id: 7,
category: 'Primary',
subject: 'Follow-up from our onboarding call',
time: '2d',
unread: false,
preview:
'Thanks for the walkthrough today. Sharing the notes and the two questions the team still had about permissions.',
messages: [
{
author: {
name: 'Grace Liu',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=16',
},
date: 'Aug 27, 1:20 PM',
body: [
'Hi Priya,',
'Thanks for the walkthrough today — really helpful. I’m sharing the notes with the team and following up on the two questions we had about role permissions.',
'Talk soon,\nGrace',
],
},
],
},
{
id: 8,
category: 'Primary',
subject: 'Hitting API rate limits in production',
time: '3d',
unread: true,
preview:
'Since this morning we’re getting 429s on the documents endpoint. Traffic hasn’t changed — has the limit been lowered?',
messages: [
{
author: {
name: 'Victor Alvarez',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=52',
},
date: 'Aug 26, 9:05 AM',
body: [
'Hi,',
'Since this morning we’re getting 429 responses on the documents endpoint in production. Our traffic hasn’t changed — has the rate limit been lowered recently?',
'This is affecting live users, so any quick guidance would help.',
],
},
],
},
{
id: 9,
category: 'Primary',
subject: 'Can we enable SSO for our org?',
time: '4d',
unread: false,
preview:
'Security is asking us to move to SAML SSO. What’s involved on your side, and is it available on our current plan?',
messages: [
{
author: {
name: 'Elena Fischer',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=20',
},
date: 'Aug 25, 2:30 PM',
body: [
'Hi team,',
'Our security team is asking us to move to SAML SSO. What’s involved on your side to set it up, and is it available on our current plan?',
'Best,\nElena',
],
},
],
},
{
id: 10,
category: 'Transactions',
subject: 'Receipt for your July payment',
time: '5d',
unread: false,
preview:
'Thanks for your payment of $480.00. Your receipt for the July billing period is attached below.',
messages: [
{
author: { name: 'Northwind Billing', email: '[email protected]' },
date: 'Aug 24, 6:00 AM',
body: [
'Hi,',
'Thanks for your payment of $480.00 for the July billing period. This email is your receipt — no action needed.',
'You can view or download past invoices anytime from Settings → Billing.',
],
},
],
},
{
id: 11,
category: 'Transactions',
subject: 'Invoice #2043 is ready to view',
time: '6d',
unread: true,
preview:
'Your invoice for the upcoming period is ready. The total is $600.00, due on September 1.',
messages: [
{
author: { name: 'Northwind Billing', email: '[email protected]' },
date: 'Aug 23, 6:00 AM',
body: [
'Hi,',
'Invoice #2043 for the upcoming billing period is ready to view. The total is $600.00, due on September 1.',
'No action is needed if you’re on auto-pay — we’ll charge your card on file.',
],
},
],
},
{
id: 12,
category: 'Transactions',
subject: 'Billing discrepancy on latest invoice',
time: '6d',
unread: true,
preview:
'Our invoice for this month shows 10 Pro licenses, but we only have 8 active users. Can you review the charge?',
messages: [
{
author: {
name: 'Dana Whitfield',
email: '[email protected]',
image: 'https://i.pravatar.cc/150?img=25',
},
date: 'Aug 23, 5:12 AM',
body: [
'Hello,',
'Our invoice for this month shows 10 Pro licenses, but we only have 8 active users. Can you review the charge and issue a correction if needed?',
'Thanks,\nDana',
],
},
],
},
{
id: 13,
category: 'Updates',
subject: 'What’s new: faster search and saved views',
time: '1w',
unread: false,
preview:
'This month we rebuilt search to be up to 5× faster and added saved views so you can pin the filters you use most.',
messages: [
{
author: { name: 'Northwind', email: '[email protected]' },
date: 'Aug 22, 8:00 AM',
body: [
'Hi there,',
'This month we rebuilt search to be up to 5× faster, and added saved views so you can pin the filters you use most.',
'Read the full changelog in your dashboard under What’s New.',
],
},
],
},
{
id: 14,
category: 'Updates',
subject: 'New sign-in from Chrome on macOS',
time: '1w',
unread: false,
preview:
'We noticed a new sign-in to your Northwind account. If this was you, no action is needed.',
messages: [
{
author: { name: 'Northwind Security', email: '[email protected]' },
date: 'Aug 21, 10:14 PM',
body: [
'Hi,',
'We noticed a new sign-in to your Northwind account from Chrome on macOS, near San Francisco, CA.',
'If this was you, no action is needed. If not, reset your password and review active sessions right away.',
],
},
],
},
{
id: 15,
category: 'Updates',
subject: 'Scheduled maintenance this Sunday',
time: '1w',
unread: false,
preview:
'Northwind will be briefly unavailable on Sunday, 02:00–03:00 UTC while we upgrade our database cluster.',
messages: [
{
author: { name: 'Northwind', email: '[email protected]' },
date: 'Aug 21, 9:00 AM',
body: [
'Hi,',
'Northwind will be briefly unavailable on Sunday from 02:00 to 03:00 UTC while we upgrade our database cluster.',
'No action is needed — we’re sharing this so you can plan around the window.',
],
},
],
},
{
id: 16,
category: 'Promotions',
subject: 'Upgrade to Pro and save 20% this month',
time: '1w',
unread: false,
preview:
'Unlock automations, advanced permissions, and priority support. Upgrade before month-end to lock in 20% off.',
messages: [
{
author: { name: 'Northwind', email: '[email protected]' },
date: 'Aug 20, 8:30 AM',
body: [
'Hi there,',
'Unlock automations, advanced permissions, and priority support with Northwind Pro. Upgrade before month-end to lock in 20% off your first year.',
'Questions about the plan? Just reply to this email.',
],
},
],
},
{
id: 17,
category: 'Promotions',
subject: 'You’re invited: automation webinar',
time: '2w',
unread: false,
preview:
'Join our 30-minute live session on building no-code automations, with time for Q&A at the end.',
messages: [
{
author: { name: 'Northwind Partners', email: '[email protected]' },
date: 'Aug 15, 12:00 PM',
body: [
'Hi,',
'Join our 30-minute live session on building no-code automations in Northwind, with time for Q&A at the end.',
'Save your seat from the link in your dashboard — recordings go out to everyone who registers.',
],
},
],
},
]
// The list is the landing screen; you drill into a thread from there. A real
// app would map these to routes (/mailbox/:id, /thread/:id) and read the ids
// off the params.
const screen = ref('list') // 'list' | 'detail'
const activeMailbox = ref('Inbox')
// Mailboxes + labels live behind a bottom sheet (the desktop sidebar's mobile
// home), summoned from the list header rather than being a screen of their own.
const showMailboxes = ref(false)
// The open thread — which conversation the detail screen shows. Kept as a
// string id so `openThread` can set it straight from a row.
const activeId = ref('1')
const selected = computed(
() => threads.find((t) => String(t.id) === activeId.value) ?? threads[0],
)
const filteredThreads = computed(() =>
threads.filter((t) => t.category === activeTab.value),
)
// Detail subject reads as a reply once the thread has more than one message.
const readingSubject = computed(() =>
selected.value.messages.length > 1
? `Re: ${selected.value.subject}`
: selected.value.subject,
)
// Overflow actions for the detail toolbar — mail verbs, not ticket verbs. On
// mobile the header has room for one control, so archive/delete/etc. all live
// under a single ellipsis menu rather than as separate buttons.
const moreActions = [
{ label: 'Archive', icon: 'lucide-archive' },
{ label: 'Snooze', icon: 'lucide-alarm-clock' },
{ label: 'Mark as unread', icon: 'lucide-mail' },
{ label: 'Star', icon: 'lucide-star' },
{ label: 'Move to…', icon: 'lucide-folder-input' },
{ label: 'Report spam', icon: 'lucide-shield-alert' },
{ label: 'Delete', icon: 'lucide-trash-2' },
]
const navTab = ref('mail')
function openMailbox(name) {
activeMailbox.value = name
activeTab.value = 'Primary'
screen.value = 'list'
showMailboxes.value = false
}
function openThread(thread) {
activeId.value = String(thread.id)
screen.value = 'detail'
}
function goToMail() {
navTab.value = 'mail'
screen.value = 'list'
}
</script>
<template>
<MobileShell>
<!-- Screen 1 (landing): Mail list — category tabs + thread rows. -->
<template v-if="screen === 'list'">
<PageHeaderMobile :title="activeMailbox">
<template #left>
<Button
variant="ghost"
icon="lucide-panel-left"
label="Mailboxes"
@click="showMailboxes = true"
/>
</template>
<template #right>
<Button variant="ghost" icon="lucide-pen-line" label="Compose" />
</template>
</PageHeaderMobile>
<!-- Category tabs, pinned under the header as you scroll the list. A
horizontal scroller absorbs the four tabs on a narrow viewport. -->
<div
class="sticky top-0 z-10 flex items-center overflow-x-auto border-b border-outline-gray-1 bg-surface-base px-4 py-2"
>
<TabButtons
v-model="activeTab"
:options="mailTabs.map((tab) => ({ label: tab }))"
/>
</div>
<div class="pb-6 pt-3">
<!-- No active-row highlight on mobile: tapping drills straight into the
thread, so a lingering highlight on the last-opened row is noise. -->
<List class="list-gap-3 list-row-px-4">
<ListRow
v-for="thread in filteredThreads"
:key="thread.id"
@click="openThread(thread)"
>
<ListCell>
<div class="min-w-0 py-3">
<div
class="inline-flex items-center truncate text-lg text-ink-gray-8"
:class="thread.unread && 'font-semibold text-ink-gray-9'"
>
<!-- Unread dot, inline before the subject — the one
deliberate accent in an otherwise gray recipe. -->
<span
v-if="thread.unread"
class="mr-1.5 inline-block size-2 shrink-0 rounded-full bg-surface-blue-7 align-middle"
aria-hidden="true"
/>{{ thread.subject }}
</div>
<p class="mt-1 line-clamp-2 text-p-md text-ink-gray-5">
{{ thread.preview }}
</p>
</div>
</ListCell>
<ListCell class="self-start justify-end pt-3.5">
<span class="shrink-0 text-sm text-ink-gray-5">
{{ thread.time }}
</span>
</ListCell>
</ListRow>
</List>
<p
v-if="!filteredThreads.length"
class="px-4 py-10 text-center text-p-sm text-ink-gray-4"
>
No conversations in {{ activeTab }}.
</p>
</div>
</template>
<!-- Screen 2: Mail detail — threaded messages + a pinned reply bar. -->
<template v-else>
<PageHeaderMobile :title="readingSubject">
<template #left>
<Button
variant="ghost"
icon="lucide-chevron-left"
:label="activeMailbox"
@click="screen = 'list'"
/>
</template>
<template #right>
<Dropdown :options="moreActions" align="end">
<Button variant="ghost" icon="lucide-ellipsis" label="More" />
</Dropdown>
</template>
</PageHeaderMobile>
<div class="space-y-6 px-4 pb-8 pt-4">
<article
v-for="(message, i) in selected.messages"
:key="i"
class="border-t border-outline-gray-1 pt-6 first:border-0 first:pt-0"
>
<div class="flex items-start gap-3">
<Avatar
size="lg"
:label="message.author.name"
:image="message.author.image"
/>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold text-ink-gray-9">
{{ message.author.name }}
</div>
<div class="truncate text-sm text-ink-gray-5">
{{ message.author.email }}
</div>
<div class="mt-0.5 text-sm text-ink-gray-5">
{{ message.date }}
</div>
</div>
</div>
<div class="mt-4 space-y-4">
<p
v-for="(paragraph, p) in message.body"
:key="p"
class="whitespace-pre-line text-p-base text-ink-gray-8"
>
{{ paragraph }}
</p>
</div>
</article>
</div>
</template>
<!-- Mailbox switcher — the desktop sidebar, surfaced on mobile as a sheet
you pull up from the list header. Mailboxes carry their unread count;
labels sit below under their own heading. -->
<BottomSheet v-model:open="showMailboxes" title="Mailboxes">
<div class="px-2 pb-4">
<button
v-for="box in mailboxes"
:key="box.label"
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition"
:class="
box.label === activeMailbox
? 'bg-surface-gray-3'
: 'hover:bg-surface-gray-2'
"
@click="openMailbox(box.label)"
>
<span
:class="box.icon"
class="size-5 shrink-0 text-ink-gray-5"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-lg text-ink-gray-8">
{{ box.label }}
</span>
<Badge v-if="box.count" variant="ghost" :label="String(box.count)" />
</button>
<div class="mb-1 mt-4 px-3">
<span class="text-sm text-ink-gray-5">Labels</span>
</div>
<button
v-for="label in labels"
:key="label"
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition"
:class="
label === activeMailbox
? 'bg-surface-gray-3'
: 'hover:bg-surface-gray-2'
"
@click="openMailbox(label)"
>
<span
class="lucide-tag size-5 shrink-0 text-ink-gray-5"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-lg text-ink-gray-8">
{{ label }}
</span>
</button>
</div>
</BottomSheet>
<!--
The pinned bottom region does double duty: the reply bar on the detail
screen (focused reading view, like the desktop reading pane's footer) and
the app's bottom nav everywhere else.
-->
<template #nav>
<footer
v-if="screen === 'detail'"
class="flex items-center gap-2 border-t border-outline-gray-1 bg-surface-base px-4 py-3 [@media(display-mode:standalone)]:pb-[env(safe-area-inset-bottom)]"
>
<Button variant="solid" label="Reply" icon-left="lucide-reply" />
<Button
variant="ghost"
label="Reply all"
icon-left="lucide-reply-all"
/>
<Button variant="ghost" label="Forward" icon-left="lucide-forward" />
</footer>
<MobileNav v-else>
<MobileNavItem
label="Mail"
icon="lucide-inbox"
:active="navTab === 'mail'"
@click="goToMail"
/>
<MobileNavItem
label="Search"
icon="lucide-search"
:active="navTab === 'search'"
@click="navTab = 'search'"
/>
<MobileNavItem
label="Starred"
icon="lucide-star"
:active="navTab === 'starred'"
@click="navTab = 'starred'"
/>
<MobileNavItem
label="You"
:active="navTab === 'you'"
@click="navTab = 'you'"
>
<template #default="{ active }">
<Avatar
image="https://i.pravatar.cc/150?img=68"
label="Alex Rivera"
size="md"
:class="active ? 'ring-2 ring-outline-gray-4' : ''"
/>
</template>
</MobileNavItem>
</MobileNav>
</template>
</MobileShell>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
Breadcrumbs,
Button,
DesktopShell,
Dropdown,
PageHeader,
Progress,
ScrollArea,
Select,
Sidebar,
SidebarHeader,
SidebarItem,
TextInput,
} from 'frappe-ui'
import {
List,
ListCell,
ListGroup,
ListHeader,
ListHeaderCell,
ListHeaderCellSort,
ListRow,
} from 'frappe-ui/list'
const nav = [
{ label: 'Home', icon: 'lucide-house' },
{ label: 'Recents', icon: 'lucide-clock' },
{ label: 'Favourites', icon: 'lucide-star' },
{ label: 'Shared with me', icon: 'lucide-users' },
{ label: 'Trash', icon: 'lucide-trash-2' },
]
const activeNav = ref('Home')
// A flat tree: `parent` is the id of the containing folder (null at the root).
// Folders and files share one collection so they interleave in the time
// buckets. `daysAgo` is the single source of truth for age — both the relative
// label and the bucket a row lands in derive from it. Folders carry no byte
// `size`; their Size column shows a derived child count instead.
const allItems = [
// Root
{
id: 'design',
parent: null,
type: 'folder',
name: 'Design assets',
icon: 'lucide-folder',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 0,
},
{
id: 'contracts',
parent: null,
type: 'folder',
name: 'Contracts',
icon: 'lucide-folder',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 3,
},
{
id: 'screenshots',
parent: null,
type: 'folder',
name: 'Product screenshots',
icon: 'lucide-folder',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 8,
},
{
id: 'offsite',
parent: null,
type: 'folder',
name: 'Team offsite 2026',
icon: 'lucide-folder',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 21,
},
{
id: 'q2-deck',
parent: null,
type: 'file',
name: 'Q2 board deck.pdf',
icon: 'lucide-file-text',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 0,
size: 8.4,
sizeLabel: '8.4 MB',
},
{
id: 'standup',
parent: null,
type: 'file',
name: 'Standup notes.md',
icon: 'lucide-file-text',
owner: 'Evan You',
ownerImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
daysAgo: 0,
size: 0.02,
sizeLabel: '18 KB',
},
{
id: 'hero',
parent: null,
type: 'file',
name: 'Homepage hero.png',
icon: 'lucide-image',
owner: 'Evan You',
ownerImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
daysAgo: 1,
size: 2.1,
sizeLabel: '2.1 MB',
},
{
id: 'revenue',
parent: null,
type: 'file',
name: 'Revenue model.xlsx',
icon: 'lucide-file-spreadsheet',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 2,
size: 0.6,
sizeLabel: '640 KB',
},
{
id: 'sprint',
parent: null,
type: 'file',
name: 'Sprint plan.docx',
icon: 'lucide-file-text',
owner: 'Amy Santiago',
ownerImage: 'https://i.pravatar.cc/150?img=45',
daysAgo: 4,
size: 0.3,
sizeLabel: '312 KB',
},
{
id: 'teaser',
parent: null,
type: 'file',
name: 'Launch teaser.mp4',
icon: 'lucide-video',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 5,
size: 148,
sizeLabel: '148 MB',
},
{
id: 'old-logo',
parent: null,
type: 'file',
name: 'Old logo.ai',
icon: 'lucide-file-image',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 9,
size: 5.6,
sizeLabel: '5.6 MB',
},
{
id: 'brand-fonts',
parent: null,
type: 'file',
name: 'Brand fonts.zip',
icon: 'lucide-file-archive',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 12,
size: 24,
sizeLabel: '24 MB',
},
{
id: 'archive',
parent: null,
type: 'file',
name: 'Archive 2025.zip',
icon: 'lucide-file-archive',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 40,
size: 512,
sizeLabel: '512 MB',
},
// Inside "Design assets" — includes a sub-folder for multi-level nav
{
id: 'exports',
parent: 'design',
type: 'folder',
name: 'Exports',
icon: 'lucide-folder',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 1,
},
{
id: 'logo-master',
parent: 'design',
type: 'file',
name: 'Logo master.svg',
icon: 'lucide-file-image',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 0,
size: 0.4,
sizeLabel: '420 KB',
},
{
id: 'palette',
parent: 'design',
type: 'file',
name: 'Color palette.png',
icon: 'lucide-image',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 2,
size: 1.2,
sizeLabel: '1.2 MB',
},
{
id: 'type-scale',
parent: 'design',
type: 'file',
name: 'Type scale.pdf',
icon: 'lucide-file-text',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 6,
size: 3.1,
sizeLabel: '3.1 MB',
},
// Inside "Design assets / Exports"
{
id: 'logo-2x',
parent: 'exports',
type: 'file',
name: '[email protected]',
icon: 'lucide-image',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 1,
size: 0.8,
sizeLabel: '800 KB',
},
{
id: 'brand-sheet',
parent: 'exports',
type: 'file',
name: 'brand-sheet.pdf',
icon: 'lucide-file-text',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 1,
size: 2.4,
sizeLabel: '2.4 MB',
},
// Inside "Contracts"
{
id: 'msa',
parent: 'contracts',
type: 'file',
name: 'MSA 2026.pdf',
icon: 'lucide-file-text',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 3,
size: 0.5,
sizeLabel: '512 KB',
},
{
id: 'nda',
parent: 'contracts',
type: 'file',
name: 'NDA template.docx',
icon: 'lucide-file-text',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 9,
size: 0.1,
sizeLabel: '96 KB',
},
{
id: 'vendor',
parent: 'contracts',
type: 'file',
name: 'Vendor agreement.pdf',
icon: 'lucide-file-text',
owner: 'Amy Santiago',
ownerImage: 'https://i.pravatar.cc/150?img=45',
daysAgo: 20,
size: 0.7,
sizeLabel: '720 KB',
},
// Inside "Product screenshots"
{
id: 'ss-dashboard',
parent: 'screenshots',
type: 'file',
name: 'Dashboard.png',
icon: 'lucide-image',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 8,
size: 1.6,
sizeLabel: '1.6 MB',
},
{
id: 'ss-settings',
parent: 'screenshots',
type: 'file',
name: 'Settings.png',
icon: 'lucide-image',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 8,
size: 1.1,
sizeLabel: '1.1 MB',
},
{
id: 'ss-onboarding',
parent: 'screenshots',
type: 'file',
name: 'Onboarding.png',
icon: 'lucide-image',
owner: 'Evan You',
ownerImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
daysAgo: 10,
size: 1.3,
sizeLabel: '1.3 MB',
},
// Inside "Team offsite 2026"
{
id: 'off-agenda',
parent: 'offsite',
type: 'file',
name: 'Agenda.pdf',
icon: 'lucide-file-text',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 21,
size: 0.3,
sizeLabel: '320 KB',
},
{
id: 'off-photo',
parent: 'offsite',
type: 'file',
name: 'Group photo.jpg',
icon: 'lucide-image',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 22,
size: 6.2,
sizeLabel: '6.2 MB',
},
{
id: 'off-budget',
parent: 'offsite',
type: 'file',
name: 'Budget.xlsx',
icon: 'lucide-file-spreadsheet',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 25,
size: 0.4,
sizeLabel: '380 KB',
},
]
const itemsById = new Map(allItems.map((item) => [item.id, item]))
// Navigation: the id of the open folder (null = root). Files/folders shown are
// this folder's direct children.
const currentFolderId = ref(null)
function childrenOf(folderId) {
return allItems.filter((item) => item.parent === folderId)
}
function openFolder(folderId) {
currentFolderId.value = folderId
searchQuery.value = ''
}
// Home + one crumb per ancestor down to the open folder. Each crumb navigates.
const breadcrumbs = computed(() => {
const trail = []
let node = currentFolderId.value ? itemsById.get(currentFolderId.value) : null
while (node) {
trail.unshift(node)
node = node.parent ? itemsById.get(node.parent) : null
}
return [
{ label: 'Home', onClick: () => openFolder(null) },
...trail.map((folder) => ({
label: folder.name,
onClick: () => openFolder(folder.id),
})),
]
})
function onRowClick(item) {
if (item.type === 'folder') openFolder(item.id)
// Files would open a preview here; no-op in this recipe.
}
const fileActions = [
{ label: 'Download', icon: 'lucide-download' },
{ label: 'Share', icon: 'lucide-user-plus' },
{ label: 'Rename', icon: 'lucide-pencil' },
{ label: 'Move to trash', icon: 'lucide-trash-2' },
]
const folderActions = [
{ label: 'Open', icon: 'lucide-folder-open' },
{ label: 'Share', icon: 'lucide-user-plus' },
{ label: 'Rename', icon: 'lucide-pencil' },
{ label: 'Move to trash', icon: 'lucide-trash-2' },
]
// Fixed, chronological buckets. Each row lands in the first bucket whose
// `match` accepts its `daysAgo`; empty buckets drop out of the render.
const timeBuckets = [
{ key: 'today', label: 'Today', match: (d) => d <= 0 },
{ key: 'yesterday', label: 'Yesterday', match: (d) => d === 1 },
{ key: 'this-week', label: 'This week', match: (d) => d >= 2 && d <= 6 },
{ key: 'last-week', label: 'Last week', match: (d) => d >= 7 && d <= 13 },
{ key: 'earlier', label: 'Earlier', match: (d) => d >= 14 },
]
// One filterable category per row. Folders are their own category; files map
// from their icon so the data stays the single source of truth.
const categoryByIcon = {
'lucide-file-text': 'document',
'lucide-file-spreadsheet': 'document',
'lucide-image': 'image',
'lucide-file-image': 'image',
'lucide-video': 'video',
'lucide-file-archive': 'archive',
}
function categoryOf(item) {
if (item.type === 'folder') return 'folder'
return categoryByIcon[item.icon] ?? 'document'
}
const typeOptions = [
{ label: 'All types', value: 'all', icon: 'lucide-list-filter' },
{ label: 'Folders', value: 'folder', icon: 'lucide-folder' },
{ label: 'Documents', value: 'document', icon: 'lucide-file-text' },
{ label: 'Images', value: 'image', icon: 'lucide-image' },
{ label: 'Videos', value: 'video', icon: 'lucide-video' },
{ label: 'Archives', value: 'archive', icon: 'lucide-file-archive' },
]
const typeIcon = Object.fromEntries(typeOptions.map((o) => [o.value, o.icon]))
const selectedType = ref('all')
const searchQuery = ref('')
const isFiltering = computed(
() => !!searchQuery.value.trim() || selectedType.value !== 'all',
)
function relativeLabel(daysAgo) {
if (daysAgo <= 0) return 'Today'
if (daysAgo === 1) return 'Yesterday'
if (daysAgo < 7) return `${daysAgo} days ago`
if (daysAgo < 14) return 'Last week'
if (daysAgo < 30) return `${Math.floor(daysAgo / 7)} weeks ago`
const months = Math.floor(daysAgo / 30)
return `${months} ${months === 1 ? 'month' : 'months'} ago`
}
const sortField = ref('modified')
const sortDirection = ref('asc')
function toggleSort(field) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
}
function directionFor(field) {
return sortField.value === field ? sortDirection.value : null
}
// Folders have no byte size, so treat them as 0 for a size-sort (they cluster
// at the light end); a name tiebreaker keeps ties from shuffling on re-sort.
function compareRows(a, b) {
const factor = sortDirection.value === 'desc' ? -1 : 1
if (sortField.value === 'name') return factor * a.name.localeCompare(b.name)
if (sortField.value === 'size') {
return (
factor * ((a.size ?? 0) - (b.size ?? 0)) || a.name.localeCompare(b.name)
)
}
return factor * (a.daysAgo - b.daysAgo) || a.name.localeCompare(b.name)
}
// The time buckets are the primary structure; the active column sort only
// orders rows *within* each bucket. Group order stays chronological either way.
const groups = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
const visible = childrenOf(currentFolderId.value).filter((item) => {
const matchesType =
selectedType.value === 'all' || categoryOf(item) === selectedType.value
const matchesQuery = !query || item.name.toLowerCase().includes(query)
return matchesType && matchesQuery
})
const sorted = visible.sort(compareRows)
return timeBuckets
.map((bucket) => ({
...bucket,
items: sorted.filter((item) => bucket.match(item.daysAgo)),
}))
.filter((group) => group.items.length)
})
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell>
<template #sidebar>
<Sidebar width="14rem" class="border-r">
<SidebarHeader
title="Files"
subtitle="kestrel.frappe.cloud"
logo="https://api.dicebear.com/10.x/disco/svg?seed=Kestrel"
:menu-items="[
{ label: 'Switch team', icon: 'lucide-arrow-left-right' },
{ label: 'Log out', icon: 'lucide-log-out' },
]"
/>
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<nav class="space-y-0.5">
<SidebarItem
v-for="item in nav"
:key="item.label"
:active="activeNav === item.label"
@click="activeNav = item.label"
>
<template #prefix>
<span :class="item.icon" class="size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">{{ item.label }}</span>
</SidebarItem>
</nav>
</ScrollArea>
<div class="mt-auto px-4 pb-4">
<Progress :value="42" size="sm" label="Storage" hint />
<div class="mt-1.5 text-xs text-ink-gray-5">
4.2 GB of 10 GB used
</div>
</div>
</Sidebar>
</template>
<PageHeader>
<Breadcrumbs :items="breadcrumbs" />
<div class="flex items-center gap-2">
<Button label="New folder" icon-left="lucide-folder-plus" />
<Button variant="solid" label="Upload" icon-left="lucide-upload" />
</div>
</PageHeader>
<div class="px-3 pb-10 pt-4 sm:px-5">
<div class="flex items-center gap-2 pb-3">
<TextInput
v-model="searchQuery"
type="text"
placeholder="Search files"
class="w-64"
>
<template #prefix>
<span
class="lucide-search size-4 text-ink-gray-5"
aria-hidden="true"
/>
</template>
</TextInput>
<Select v-model="selectedType" :options="typeOptions">
<template #item-prefix="{ item }">
<span
:class="typeIcon[item.value]"
class="size-4 text-ink-gray-6"
aria-hidden="true"
/>
</template>
</Select>
</div>
<!-- -mx-3 pairs with list-row-px-3: the row padding insets the content
back to the toolbar's edge while the hover surface bleeds past it
into the gutter, so headers and rows stay aligned with the search
and filter controls above. -->
<List
class="-mx-3 list-row-px-3"
:columns="['minmax(0,1fr)', '11rem', '7.5rem', '5.5rem', '3rem']"
:row-height="40"
>
<ListHeader class="sticky top-0 z-10 bg-surface-base">
<ListHeaderCellSort
:direction="directionFor('name')"
@click="toggleSort('name')"
>
Name
</ListHeaderCellSort>
<ListHeaderCell>Owner</ListHeaderCell>
<ListHeaderCellSort
:direction="directionFor('modified')"
@click="toggleSort('modified')"
>
Modified
</ListHeaderCellSort>
<!-- Right-aligned column: `align="end"` right-aligns the header and
moves the sort glyph to the leading side, so "Size" stays flush
with the values below. -->
<ListHeaderCellSort
:direction="directionFor('size')"
align="end"
@click="toggleSort('size')"
>
Size
</ListHeaderCellSort>
<ListHeaderCell />
</ListHeader>
<!-- ListGroup wraps each time bucket in a labelled `role="rowgroup"`
and keeps the rows as direct children, so the `list-row + list-row`
dividers survive within a group. Folders and files interleave
within a bucket, ordered by the active column sort. -->
<ListGroup
v-for="group in groups"
:key="group.key"
:label="group.label"
>
<ListRow
v-for="item in group.items"
:key="item.id"
@click="onRowClick(item)"
>
<ListCell>
<span
:class="item.icon"
class="size-4 shrink-0 text-ink-gray-5"
/>
<span class="ml-3 truncate text-base text-ink-gray-8">
{{ item.name }}
</span>
</ListCell>
<ListCell>
<Avatar
:label="item.owner"
:image="item.ownerImage"
size="sm"
/>
<span class="ml-2 truncate text-base text-ink-gray-7">
{{ item.owner }}
</span>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-6">
{{ relativeLabel(item.daysAgo) }}
</span>
</ListCell>
<ListCell class="justify-end">
<span class="text-base text-ink-gray-6">
{{
item.type === 'folder'
? `${childrenOf(item.id).length} items`
: item.sizeLabel
}}
</span>
</ListCell>
<ListCell class="justify-end">
<Dropdown
:options="
item.type === 'folder' ? folderActions : fileActions
"
>
<Button
variant="ghost"
icon="lucide-ellipsis"
:label="
item.type === 'folder' ? 'Folder actions' : 'File actions'
"
/>
</Dropdown>
</ListCell>
</ListRow>
</ListGroup>
</List>
<div
v-if="!groups.length"
class="flex flex-col items-center gap-1 py-16 text-center"
>
<span
:class="isFiltering ? 'lucide-search-x' : 'lucide-folder-open'"
class="size-6 text-ink-gray-4"
aria-hidden="true"
/>
<p class="text-base font-medium text-ink-gray-7">
{{ isFiltering ? 'No files found' : 'This folder is empty' }}
</p>
<p class="text-p-sm text-ink-gray-5">
{{
isFiltering
? 'Try a different search or file type.'
: 'Upload a file or create a folder to get started.'
}}
</p>
</div>
</div>
</DesktopShell>
</div>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Avatar,
BottomSheet,
Button,
Dropdown,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
Progress,
Select,
TextInput,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
// Desktop's sidebar navigation. On mobile it lives behind a bottom sheet
// (summoned from the header) rather than being permanently on screen.
const nav = [
{ label: 'Home', icon: 'lucide-house' },
{ label: 'Recents', icon: 'lucide-clock' },
{ label: 'Favourites', icon: 'lucide-star' },
{ label: 'Shared with me', icon: 'lucide-users' },
{ label: 'Trash', icon: 'lucide-trash-2' },
]
const activeNav = ref('Home')
// A flat tree: `parent` is the id of the containing folder (null at the root).
// Folders and files share one collection so they interleave in the time
// buckets. `daysAgo` is the single source of truth for age — both the relative
// label and the bucket a row lands in derive from it. Folders carry no byte
// `size`; their meta line shows a derived child count instead. Copied verbatim
// from FilesDesktop so the two recipes stay in lockstep.
const allItems = [
// Root
{
id: 'design',
parent: null,
type: 'folder',
name: 'Design assets',
icon: 'lucide-folder',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 0,
},
{
id: 'contracts',
parent: null,
type: 'folder',
name: 'Contracts',
icon: 'lucide-folder',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 3,
},
{
id: 'screenshots',
parent: null,
type: 'folder',
name: 'Product screenshots',
icon: 'lucide-folder',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 8,
},
{
id: 'offsite',
parent: null,
type: 'folder',
name: 'Team offsite 2026',
icon: 'lucide-folder',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 21,
},
{
id: 'q2-deck',
parent: null,
type: 'file',
name: 'Q2 board deck.pdf',
icon: 'lucide-file-text',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 0,
size: 8.4,
sizeLabel: '8.4 MB',
},
{
id: 'standup',
parent: null,
type: 'file',
name: 'Standup notes.md',
icon: 'lucide-file-text',
owner: 'Evan You',
ownerImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
daysAgo: 0,
size: 0.02,
sizeLabel: '18 KB',
},
{
id: 'hero',
parent: null,
type: 'file',
name: 'Homepage hero.png',
icon: 'lucide-image',
owner: 'Evan You',
ownerImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
daysAgo: 1,
size: 2.1,
sizeLabel: '2.1 MB',
},
{
id: 'revenue',
parent: null,
type: 'file',
name: 'Revenue model.xlsx',
icon: 'lucide-file-spreadsheet',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 2,
size: 0.6,
sizeLabel: '640 KB',
},
{
id: 'sprint',
parent: null,
type: 'file',
name: 'Sprint plan.docx',
icon: 'lucide-file-text',
owner: 'Amy Santiago',
ownerImage: 'https://i.pravatar.cc/150?img=45',
daysAgo: 4,
size: 0.3,
sizeLabel: '312 KB',
},
{
id: 'teaser',
parent: null,
type: 'file',
name: 'Launch teaser.mp4',
icon: 'lucide-video',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 5,
size: 148,
sizeLabel: '148 MB',
},
{
id: 'old-logo',
parent: null,
type: 'file',
name: 'Old logo.ai',
icon: 'lucide-file-image',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 9,
size: 5.6,
sizeLabel: '5.6 MB',
},
{
id: 'brand-fonts',
parent: null,
type: 'file',
name: 'Brand fonts.zip',
icon: 'lucide-file-archive',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 12,
size: 24,
sizeLabel: '24 MB',
},
{
id: 'archive',
parent: null,
type: 'file',
name: 'Archive 2025.zip',
icon: 'lucide-file-archive',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 40,
size: 512,
sizeLabel: '512 MB',
},
// Inside "Design assets" — includes a sub-folder for multi-level nav
{
id: 'exports',
parent: 'design',
type: 'folder',
name: 'Exports',
icon: 'lucide-folder',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 1,
},
{
id: 'logo-master',
parent: 'design',
type: 'file',
name: 'Logo master.svg',
icon: 'lucide-file-image',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 0,
size: 0.4,
sizeLabel: '420 KB',
},
{
id: 'palette',
parent: 'design',
type: 'file',
name: 'Color palette.png',
icon: 'lucide-image',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 2,
size: 1.2,
sizeLabel: '1.2 MB',
},
{
id: 'type-scale',
parent: 'design',
type: 'file',
name: 'Type scale.pdf',
icon: 'lucide-file-text',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 6,
size: 3.1,
sizeLabel: '3.1 MB',
},
// Inside "Design assets / Exports"
{
id: 'logo-2x',
parent: 'exports',
type: 'file',
name: '[email protected]',
icon: 'lucide-image',
owner: 'Rhea Kapoor',
ownerImage: 'https://i.pravatar.cc/150?img=5',
daysAgo: 1,
size: 0.8,
sizeLabel: '800 KB',
},
{
id: 'brand-sheet',
parent: 'exports',
type: 'file',
name: 'brand-sheet.pdf',
icon: 'lucide-file-text',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 1,
size: 2.4,
sizeLabel: '2.4 MB',
},
// Inside "Contracts"
{
id: 'msa',
parent: 'contracts',
type: 'file',
name: 'MSA 2026.pdf',
icon: 'lucide-file-text',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 3,
size: 0.5,
sizeLabel: '512 KB',
},
{
id: 'nda',
parent: 'contracts',
type: 'file',
name: 'NDA template.docx',
icon: 'lucide-file-text',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 9,
size: 0.1,
sizeLabel: '96 KB',
},
{
id: 'vendor',
parent: 'contracts',
type: 'file',
name: 'Vendor agreement.pdf',
icon: 'lucide-file-text',
owner: 'Amy Santiago',
ownerImage: 'https://i.pravatar.cc/150?img=45',
daysAgo: 20,
size: 0.7,
sizeLabel: '720 KB',
},
// Inside "Product screenshots"
{
id: 'ss-dashboard',
parent: 'screenshots',
type: 'file',
name: 'Dashboard.png',
icon: 'lucide-image',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 8,
size: 1.6,
sizeLabel: '1.6 MB',
},
{
id: 'ss-settings',
parent: 'screenshots',
type: 'file',
name: 'Settings.png',
icon: 'lucide-image',
owner: 'Sam Rivera',
ownerImage: 'https://i.pravatar.cc/150?img=33',
daysAgo: 8,
size: 1.1,
sizeLabel: '1.1 MB',
},
{
id: 'ss-onboarding',
parent: 'screenshots',
type: 'file',
name: 'Onboarding.png',
icon: 'lucide-image',
owner: 'Evan You',
ownerImage: 'https://avatars.githubusercontent.com/u/499550?v=4',
daysAgo: 10,
size: 1.3,
sizeLabel: '1.3 MB',
},
// Inside "Team offsite 2026"
{
id: 'off-agenda',
parent: 'offsite',
type: 'file',
name: 'Agenda.pdf',
icon: 'lucide-file-text',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 21,
size: 0.3,
sizeLabel: '320 KB',
},
{
id: 'off-photo',
parent: 'offsite',
type: 'file',
name: 'Group photo.jpg',
icon: 'lucide-image',
owner: 'Ana Costa',
ownerImage: 'https://i.pravatar.cc/150?img=47',
daysAgo: 22,
size: 6.2,
sizeLabel: '6.2 MB',
},
{
id: 'off-budget',
parent: 'offsite',
type: 'file',
name: 'Budget.xlsx',
icon: 'lucide-file-spreadsheet',
owner: 'Priya Nair',
ownerImage: 'https://i.pravatar.cc/150?img=12',
daysAgo: 25,
size: 0.4,
sizeLabel: '380 KB',
},
]
const itemsById = new Map(allItems.map((item) => [item.id, item]))
// Navigation: the id of the open folder (null = root). On mobile you drill into
// a folder *in place* — the same list screen updates and a back button appears
// in the header, the way iOS Files and Drive work — rather than pushing a
// separate detail screen.
const currentFolderId = ref(null)
const currentFolder = computed(() =>
currentFolderId.value ? itemsById.get(currentFolderId.value) : null,
)
// Header title: the open folder's name once you drill in, otherwise the active
// sidebar section (Home / Recents / …).
const screenTitle = computed(() => currentFolder.value?.name || activeNav.value)
// Back target: the parent folder's name, or "Home" when one level deep at root.
const backLabel = computed(() => {
const parentId = currentFolder.value?.parent
return parentId ? itemsById.get(parentId).name : 'Home'
})
function childrenOf(folderId) {
return allItems.filter((item) => item.parent === folderId)
}
function openFolder(folderId) {
currentFolderId.value = folderId
searchQuery.value = ''
}
function goBack() {
openFolder(currentFolder.value?.parent ?? null)
}
function onRowClick(item) {
// Folders drill in; tapping a file opens its action sheet (the mobile stand-in
// for the desktop row's inline preview/menu).
if (item.type === 'folder') openFolder(item.id)
else openActions(item)
}
// Per-item overflow actions. On mobile these surface in a bottom sheet summoned
// by the trailing ellipsis (or by tapping a file row), not an inline dropdown.
const fileActions = [
{ label: 'Download', icon: 'lucide-download' },
{ label: 'Share', icon: 'lucide-user-plus' },
{ label: 'Rename', icon: 'lucide-pencil' },
{ label: 'Move to trash', icon: 'lucide-trash-2' },
]
const folderActions = [
{ label: 'Open', icon: 'lucide-folder-open' },
{ label: 'Share', icon: 'lucide-user-plus' },
{ label: 'Rename', icon: 'lucide-pencil' },
{ label: 'Move to trash', icon: 'lucide-trash-2' },
]
const newActions = [
{ label: 'New folder', icon: 'lucide-folder-plus' },
{ label: 'Upload', icon: 'lucide-upload' },
]
// The item whose action sheet is open (null = closed). A single sheet is reused
// for whichever row you tap.
const activeItem = ref(null)
const showActions = computed({
get: () => activeItem.value !== null,
set: (open) => {
if (!open) activeItem.value = null
},
})
function openActions(item) {
activeItem.value = item
}
const activeItemActions = computed(() =>
activeItem.value?.type === 'folder' ? folderActions : fileActions,
)
function runAction(action) {
const item = activeItem.value
showActions.value = false
// "Open" on a folder is the one action wired to do something — it drills in,
// same as tapping the row. The rest are decorative in this recipe.
if (item?.type === 'folder' && action.label === 'Open') openFolder(item.id)
}
// The nav sheet (desktop sidebar) — sections + storage meter.
const showNav = ref(false)
function selectNav(label) {
activeNav.value = label
openFolder(null)
showNav.value = false
}
// One filterable category per row. Folders are their own category; files map
// from their icon so the data stays the single source of truth.
const categoryByIcon = {
'lucide-file-text': 'document',
'lucide-file-spreadsheet': 'document',
'lucide-image': 'image',
'lucide-file-image': 'image',
'lucide-video': 'video',
'lucide-file-archive': 'archive',
}
function categoryOf(item) {
if (item.type === 'folder') return 'folder'
return categoryByIcon[item.icon] ?? 'document'
}
const typeOptions = [
{ label: 'All types', value: 'all' },
{ label: 'Folders', value: 'folder' },
{ label: 'Documents', value: 'document' },
{ label: 'Images', value: 'image' },
{ label: 'Videos', value: 'video' },
{ label: 'Archives', value: 'archive' },
]
const selectedType = ref('all')
const searchQuery = ref('')
const isFiltering = computed(
() => !!searchQuery.value.trim() || selectedType.value !== 'all',
)
function relativeLabel(daysAgo) {
if (daysAgo <= 0) return 'Today'
if (daysAgo === 1) return 'Yesterday'
if (daysAgo < 7) return `${daysAgo} days ago`
if (daysAgo < 14) return 'Last week'
if (daysAgo < 30) return `${Math.floor(daysAgo / 7)} weeks ago`
const months = Math.floor(daysAgo / 30)
return `${months} ${months === 1 ? 'month' : 'months'} ago`
}
// The size/count shown on a row's meta line: byte size for files, a derived
// child count for folders.
function metaSize(item) {
return item.type === 'folder'
? `${childrenOf(item.id).length} items`
: item.sizeLabel
}
// Newest first, with a name tiebreaker so ties don't shuffle on re-sort.
function compareRows(a, b) {
return a.daysAgo - b.daysAgo || a.name.localeCompare(b.name)
}
// A single flat, date-sorted list — the open folder's children after the
// search + type filters, ordered newest first (like iOS Files' Downloads).
const visibleItems = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
return childrenOf(currentFolderId.value)
.filter((item) => {
const matchesType =
selectedType.value === 'all' || categoryOf(item) === selectedType.value
const matchesQuery = !query || item.name.toLowerCase().includes(query)
return matchesType && matchesQuery
})
.sort(compareRows)
})
const navTab = ref('files')
function goHome() {
navTab.value = 'files'
selectNav('Home')
}
</script>
<template>
<MobileShell>
<PageHeaderMobile :title="screenTitle">
<template #left>
<!-- One level deep, the left slot is a back button; at the root it opens
the nav sheet (the desktop sidebar). -->
<Button
v-if="currentFolder"
variant="ghost"
icon="lucide-chevron-left"
:label="backLabel"
@click="goBack"
/>
<Button
v-else
variant="ghost"
icon="lucide-panel-left"
label="Menu"
@click="showNav = true"
/>
</template>
<template #right>
<Dropdown :options="newActions" align="end">
<Button variant="ghost" icon="lucide-plus" label="New" />
</Dropdown>
</template>
</PageHeaderMobile>
<!-- Search + type filter, pinned under the header as the list scrolls. -->
<div
class="sticky top-0 z-10 flex items-center gap-2 border-b border-outline-gray-1 bg-surface-base px-4 py-2"
>
<TextInput
v-model="searchQuery"
type="text"
placeholder="Search files"
class="flex-1"
>
<template #prefix>
<span
class="lucide-search size-4 text-ink-gray-5"
aria-hidden="true"
/>
</template>
</TextInput>
<Select v-model="selectedType" :options="typeOptions" />
</div>
<div class="pb-6 pt-1">
<!-- One flat, date-sorted list — no time-bucket group headers on mobile.
Folders and files interleave, newest first. -->
<List class="list-row-px-4">
<ListRow
v-for="item in visibleItems"
:key="item.id"
class="h-17"
@click="onRowClick(item)"
>
<ListCell>
<!-- Leading type glyph — the row's `icon` is a lucide class
(`lucide-folder` for folders, a file-type icon otherwise), so
folders and files share one element and every title lines up. -->
<div class="size-7 grid place-content-center">
<span
:class="item.icon"
class="size-5 shrink-0 text-ink-gray-5"
aria-hidden="true"
/>
</div>
</ListCell>
<ListCell>
<div class="min-w-0 flex-1">
<!-- Sized text in an inner span keeps its own line-height:
`leading-snug` + `truncate` on one element would shear off
descenders like the tail of "g". -->
<div class="truncate leading-snug text-ink-gray-8">
<span class="text-lg">{{ item.name }}</span>
</div>
<!-- Meta line, iOS "date · size" format: the desktop's Modified
column, then Size (or a derived child count for folders). -->
<div class="mt-0.5 truncate text-sm text-ink-gray-5">
{{ relativeLabel(item.daysAgo) }} · {{ metaSize(item) }}
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<!-- `@click.stop` so opening the action sheet doesn't also drill
into the folder / trigger the row tap. -->
<Button
variant="ghost"
icon="lucide-ellipsis"
:label="
item.type === 'folder' ? 'Folder actions' : 'File actions'
"
@click.stop="openActions(item)"
/>
</ListCell>
</ListRow>
</List>
<div
v-if="!visibleItems.length"
class="flex flex-col items-center gap-1 px-4 py-16 text-center"
>
<span
:class="isFiltering ? 'lucide-search-x' : 'lucide-folder-open'"
class="size-6 text-ink-gray-4"
aria-hidden="true"
/>
<p class="text-base font-medium text-ink-gray-7">
{{ isFiltering ? 'No files found' : 'This folder is empty' }}
</p>
<p class="text-p-sm text-ink-gray-5">
{{
isFiltering
? 'Try a different search or file type.'
: 'Upload a file or create a folder to get started.'
}}
</p>
</div>
</div>
<!-- Nav sheet — the desktop sidebar, surfaced on mobile as a sheet you pull
up from the header. Sections on top, the storage meter pinned below. -->
<BottomSheet v-model:open="showNav" title="Files">
<div class="px-2 pb-4">
<button
v-for="item in nav"
:key="item.label"
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition"
:class="
item.label === activeNav
? 'bg-surface-gray-3'
: 'hover:bg-surface-gray-2'
"
@click="selectNav(item.label)"
>
<span
:class="item.icon"
class="size-5 shrink-0 text-ink-gray-5"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-lg text-ink-gray-8">
{{ item.label }}
</span>
</button>
<div class="mt-4 border-t px-3 pt-4">
<Progress :value="42" size="sm" label="Storage" hint />
<div class="mt-1.5 text-xs text-ink-gray-5">4.2 GB of 10 GB used</div>
</div>
</div>
</BottomSheet>
<!-- Per-item action sheet — the desktop row's overflow dropdown, reused for
whichever row you tap. -->
<BottomSheet v-model:open="showActions" :title="activeItem?.name">
<div class="px-2 pb-4">
<button
v-for="action in activeItemActions"
:key="action.label"
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left transition hover:bg-surface-gray-2"
@click="runAction(action)"
>
<span
:class="action.icon"
class="size-5 shrink-0 text-ink-gray-5"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-lg text-ink-gray-8">
{{ action.label }}
</span>
</button>
</div>
</BottomSheet>
<template #nav>
<MobileNav>
<MobileNavItem
label="Files"
icon="lucide-folder"
:active="navTab === 'files'"
@click="goHome"
/>
<MobileNavItem
label="Shared"
icon="lucide-users"
:active="navTab === 'shared'"
@click="navTab = 'shared'"
/>
<MobileNavItem
label="Search"
icon="lucide-search"
:active="navTab === 'search'"
@click="navTab = 'search'"
/>
<MobileNavItem
label="You"
:active="navTab === 'you'"
@click="navTab = 'you'"
>
<template #default="{ active }">
<Avatar
image="https://i.pravatar.cc/150?img=5"
label="Rhea Kapoor"
size="md"
:class="active ? 'ring-2 ring-outline-gray-4' : ''"
/>
</template>
</MobileNavItem>
</MobileNav>
</template>
</MobileShell>
</template>vue
<script setup>
import { computed, reactive, ref } from 'vue'
import {
Avatar,
Badge,
Breadcrumbs,
Button,
Combobox,
DatePicker,
dayjs,
DesktopShell,
Divider,
Dropdown,
MultiSelect,
PageHeader,
ScrollArea,
Select,
Sidebar,
SidebarHeader,
SidebarItem,
SidebarLabel,
TabButtons,
Textarea,
Tooltip,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
const statuses = ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled']
const statusIcon = {
Backlog: 'lucide-circle-dashed',
Todo: 'lucide-circle',
'In Progress': 'lucide-circle-dot',
Done: 'lucide-circle-check',
Canceled: 'lucide-circle-x',
}
const priorities = ['High', 'Medium', 'Low']
// Linear-style signal icons: more bars = higher priority. The bar count already
// encodes severity; color reinforces it — hot for High, warm for Medium, muted
// for Low so low-priority rows recede.
const priorityIcon = {
High: 'lucide-signal-high',
Medium: 'lucide-signal-medium',
Low: 'lucide-signal-low',
}
const priorityColor = {
High: 'text-ink-red-7',
Medium: 'text-ink-amber-7',
Low: 'text-ink-gray-5',
}
const priorityRank = { High: 0, Medium: 1, Low: 2 }
// Linear-style labels. The theme only tints the badge — the label text
// carries the meaning, so the palette can stay small.
const labelTheme = {
Bug: 'red',
Feature: 'blue',
Improvement: 'green',
Design: 'violet',
Research: 'amber',
Backend: 'gray',
Frontend: 'gray',
Docs: 'gray',
Chore: 'gray',
}
// Labels render as neutral gray-outline badges so the column stays quiet; the
// only color is a small dot that keeps each label recognizable at a glance.
const themeDot = {
red: 'bg-surface-red-6',
blue: 'bg-surface-blue-6',
green: 'bg-surface-green-6',
amber: 'bg-surface-amber-6',
violet: 'bg-surface-violet-6',
gray: 'bg-surface-gray-6',
}
const labelDotClass = (label) => themeDot[labelTheme[label]]
const me = 'Rhea Kapoor'
const people = [
{ name: 'Rhea Kapoor', image: 'https://i.pravatar.cc/150?img=1' },
{
name: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
},
{ name: 'Priya Nair', image: 'https://i.pravatar.cc/150?img=5' },
{ name: 'Sam Rivera', image: 'https://i.pravatar.cc/150?img=12' },
{ name: 'Ana Costa', image: 'https://i.pravatar.cc/150?img=20' },
{ name: 'Maya Iyer', image: 'https://i.pravatar.cc/150?img=27' },
]
const imageOf = (name) => people.find((p) => p.name === name)?.image
const projects = [
{ name: 'Website Redesign', icon: 'lucide-globe' },
{ name: 'Mobile App', icon: 'lucide-smartphone' },
{ name: 'Design System', icon: 'lucide-shapes' },
{ name: 'Q3 Launch', icon: 'lucide-rocket' },
]
const tasks = reactive([
// Website Redesign
{
id: 245,
title: 'Fix layout shift on the pricing page',
project: 'Website Redesign',
status: 'In Progress',
priority: 'High',
labels: ['Bug', 'Frontend'],
assignees: ['Priya Nair'],
owner: 'Rhea Kapoor',
due: '2026-07-06',
description:
'The hero image loads without reserved space, so the whole page jumps once it decodes. Set explicit dimensions and measure CLS before and after.',
comments: [],
},
{
id: 231,
title: 'Fix validation errors in the checkout flow',
project: 'Website Redesign',
status: 'In Progress',
priority: 'High',
labels: ['Bug', 'Frontend'],
assignees: ['Rhea Kapoor', 'Evan You'],
owner: 'Evan You',
due: '2026-07-06',
description:
'The payment step accepts an empty billing address and then fails at the gateway with a generic error. Validate required fields inline, surface gateway errors next to the affected field, and keep the entered card details on retry.',
comments: [
{
author: 'Evan You',
time: '2h ago',
text: 'Reproduced it — the address form skips validation when you pay with a saved card. That path is probably the real bug.',
},
{
author: 'Rhea Kapoor',
time: '1h ago',
text: 'Agreed. Fixing the saved-card path first, then adding inline errors for the rest of the form.',
},
],
},
{
id: 228,
title: 'Migrate marketing pages to the new CMS',
project: 'Website Redesign',
status: 'In Progress',
priority: 'Medium',
labels: ['Feature', 'Backend'],
assignees: ['Priya Nair'],
owner: 'Rhea Kapoor',
due: '2026-07-08',
description:
'Move the pricing, about, and careers pages off the legacy templates. Content is already exported — wire up the new layouts and set redirects for the old URLs.',
comments: [],
},
{
id: 242,
title: 'Rebuild the top navigation as a sticky header',
project: 'Website Redesign',
status: 'Todo',
priority: 'Medium',
labels: ['Feature', 'Frontend'],
assignees: ['Sam Rivera'],
owner: 'Rhea Kapoor',
due: '2026-07-11',
description:
'The nav should stay pinned on scroll and collapse into a compact bar past the fold. Keep the mega-menu behavior on desktop and the drawer on mobile.',
comments: [],
},
{
id: 238,
title: 'Add dark mode to the marketing site',
project: 'Website Redesign',
status: 'Todo',
priority: 'High',
labels: ['Feature', 'Design'],
assignees: ['Evan You'],
owner: 'Ana Costa',
due: '2026-07-14',
description:
'Respect the OS preference by default and add a manual toggle in the footer. Audit the illustrations for both themes before shipping.',
comments: [],
},
{
id: 221,
title: 'Consolidate duplicate utility classes',
project: 'Website Redesign',
status: 'Backlog',
priority: 'Low',
labels: ['Chore', 'Frontend'],
assignees: ['Maya Iyer'],
owner: 'Sam Rivera',
due: '',
description:
'The stylesheet has grown three near-identical spacing helpers. Collapse them to the token scale and delete the dead rules.',
comments: [],
},
{
id: 216,
title: 'Set up analytics events for the signup funnel',
project: 'Website Redesign',
status: 'Backlog',
priority: 'Medium',
labels: ['Backend'],
assignees: ['Sam Rivera'],
owner: 'Rhea Kapoor',
due: '',
description:
'Instrument each step of the signup funnel so we can see where people drop off. Match the event names to the existing dashboard schema.',
comments: [],
},
{
id: 199,
title: 'Launch the redesigned homepage',
project: 'Website Redesign',
status: 'Done',
priority: 'High',
labels: ['Feature'],
assignees: ['Rhea Kapoor'],
owner: 'Rhea Kapoor',
due: '2026-06-28',
description:
'Final cutover from the old homepage. Redirects verified, Lighthouse scores green, and the old template archived.',
comments: [],
},
{
id: 203,
title: 'Prototype animated page transitions',
project: 'Website Redesign',
status: 'Canceled',
priority: 'Low',
labels: ['Research'],
assignees: ['Evan You'],
owner: 'Evan You',
due: '',
description:
'Dropped in favor of shipping the redesign sooner — revisit after launch.',
comments: [],
},
// Mobile App
{
id: 244,
title: 'Fix push notifications not arriving on iOS 17',
project: 'Mobile App',
status: 'In Progress',
priority: 'High',
labels: ['Bug', 'Backend'],
assignees: ['Sam Rivera'],
owner: 'Rhea Kapoor',
due: '2026-07-07',
description:
'A chunk of iOS 17 devices stopped receiving pushes after the last release. Suspect the APNs token refresh — add logging around registration and compare against Android.',
comments: [
{
author: 'Sam Rivera',
time: '5h ago',
text: 'Tokens look stale for users who upgraded in place. Testing a forced re-registration on launch.',
},
],
},
{
id: 241,
title: 'Add biometric login',
project: 'Mobile App',
status: 'In Progress',
priority: 'Medium',
labels: ['Feature'],
assignees: ['Evan You'],
owner: 'Priya Nair',
due: '2026-07-09',
description:
'Let people unlock the app with Face ID / fingerprint after the first password login. Fall back to the passcode when biometrics fail twice.',
comments: [],
},
{
id: 235,
title: 'Review the onboarding flow prototype',
project: 'Mobile App',
status: 'Todo',
priority: 'High',
labels: ['Design'],
assignees: ['Rhea Kapoor'],
owner: 'Ana Costa',
due: '2026-07-07',
description:
'Second iteration is up. Focus on the empty states and the progress indicator — both changed since the last review.',
comments: [
{
author: 'Ana Costa',
time: '1d ago',
text: 'Prototype link is in the project description. The step counter is the part I am least sure about.',
},
],
},
{
id: 237,
title: 'Build offline mode for the task list',
project: 'Mobile App',
status: 'Todo',
priority: 'High',
labels: ['Feature'],
assignees: ['Priya Nair'],
owner: 'Rhea Kapoor',
due: '2026-07-13',
description:
'Cache the task list locally and queue edits made while offline, then reconcile on reconnect. Show a clear indicator when changes are pending sync.',
comments: [],
},
{
id: 233,
title: 'Set up crash reporting for the beta build',
project: 'Mobile App',
status: 'Todo',
priority: 'Medium',
labels: ['Improvement'],
assignees: ['Sam Rivera'],
owner: 'Rhea Kapoor',
due: '2026-07-10',
description:
'Beta testers are reporting crashes we cannot reproduce. Add a crash reporter to the beta build and symbolicate stack traces in CI so reports arrive readable.',
comments: [],
},
{
id: 230,
title: 'Reduce app cold-start time',
project: 'Mobile App',
status: 'Backlog',
priority: 'Medium',
labels: ['Improvement'],
assignees: ['Sam Rivera'],
owner: 'Rhea Kapoor',
due: '',
description:
'Cold start is over two seconds on mid-range Android devices. Profile the launch path and defer anything that is not needed for the first screen.',
comments: [],
},
{
id: 224,
title: 'Localize the app into Spanish and German',
project: 'Mobile App',
status: 'Backlog',
priority: 'Low',
labels: ['Feature'],
assignees: ['Ana Costa'],
owner: 'Maya Iyer',
due: '',
description:
'Extract the remaining hardcoded strings, wire up the translation files, and check the layouts for text that overflows once translated.',
comments: [],
},
{
id: 210,
title: 'Ship the redesigned tab bar',
project: 'Mobile App',
status: 'Done',
priority: 'High',
labels: ['Design'],
assignees: ['Maya Iyer'],
owner: 'Rhea Kapoor',
due: '2026-06-30',
description:
'New five-tab layout with the create action promoted to the center. Rolled out to 100% after a clean week on the beta channel.',
comments: [],
},
// Design System
{
id: 236,
title: 'Migrate components to design tokens',
project: 'Design System',
status: 'In Progress',
priority: 'High',
labels: ['Improvement', 'Frontend'],
assignees: ['Maya Iyer'],
owner: 'Rhea Kapoor',
due: '2026-07-08',
description:
'Replace the remaining hardcoded colors and spacing with semantic tokens so theming works in one place. Start with the form controls.',
comments: [],
},
{
id: 229,
title: 'Document the Button component API',
project: 'Design System',
status: 'Todo',
priority: 'Medium',
labels: ['Docs'],
assignees: ['Priya Nair'],
owner: 'Rhea Kapoor',
due: '2026-07-12',
description:
'Write the props table, list every variant and size, and add copy-paste examples for the common cases. Link it from the component page.',
comments: [],
},
{
id: 222,
title: 'Add a DatePicker component',
project: 'Design System',
status: 'Todo',
priority: 'High',
labels: ['Feature', 'Frontend'],
assignees: ['Evan You'],
owner: 'Maya Iyer',
due: '2026-07-16',
description:
'A keyboard-accessible date picker with range support and a text input fallback. Match the existing popover and token conventions.',
comments: [],
},
{
id: 219,
title: 'Explore a two-tone illustration style',
project: 'Design System',
status: 'Backlog',
priority: 'Low',
labels: ['Design', 'Research'],
assignees: ['Maya Iyer'],
owner: 'Priya Nair',
due: '',
description:
'A lighter, more geometric direction that scales down cleanly to 16px marks. Timebox to a week of exploration.',
comments: [],
},
{
id: 214,
title: 'Audit spacing token usage across marketing pages',
project: 'Design System',
status: 'Backlog',
priority: 'Medium',
labels: ['Chore'],
assignees: [],
owner: 'Sam Rivera',
due: '',
description:
'Marketing pages have drifted from the spacing scale. List the offending pages and the one-off values they use.',
comments: [],
},
{
id: 212,
title: 'Audit color contrast for accessibility',
project: 'Design System',
status: 'Backlog',
priority: 'Low',
labels: ['Research', 'Design'],
assignees: ['Ana Costa'],
owner: 'Priya Nair',
due: '',
description:
'Check text and interactive colors against WCAG AA in both themes. Flag the tokens that fail and propose adjusted values.',
comments: [],
},
{
id: 208,
title: 'Ship empty-state illustrations',
project: 'Design System',
status: 'Done',
priority: 'High',
labels: ['Design'],
assignees: ['Rhea Kapoor'],
owner: 'Rhea Kapoor',
due: '2026-07-01',
description:
'Final set of six empty-state illustrations, exported for both themes and wired into the component library.',
comments: [
{
author: 'Priya Nair',
time: '3d ago',
text: 'These look great in the app. Nice work!',
},
],
},
{
id: 205,
title: 'Publish the icon library v2',
project: 'Design System',
status: 'Done',
priority: 'Medium',
labels: ['Design'],
assignees: ['Maya Iyer'],
owner: 'Rhea Kapoor',
due: '2026-06-27',
description:
'Redrawn on a consistent 24px grid with a lighter stroke. Published to the package and the Figma library in sync.',
comments: [],
},
// Q3 Launch
{
id: 243,
title: 'Finalize pricing and packaging',
project: 'Q3 Launch',
status: 'In Progress',
priority: 'High',
labels: ['Research'],
assignees: ['Rhea Kapoor'],
owner: 'Rhea Kapoor',
due: '2026-07-06',
description:
'Lock the three tiers and what goes in each. Pull the willingness-to-pay numbers from the last survey and get sign-off from sales.',
comments: [],
},
{
id: 240,
title: 'Prepare QA checklist for the release candidate',
project: 'Q3 Launch',
status: 'In Progress',
priority: 'High',
labels: ['Chore'],
assignees: ['Ana Costa'],
owner: 'Rhea Kapoor',
due: '2026-07-05',
description:
'Collect the regression scenarios from the last two releases into a single checklist, ordered by risk. Everything above the line must pass before we cut the release candidate.',
comments: [],
},
{
id: 239,
title: 'Prepare the press kit',
project: 'Q3 Launch',
status: 'Todo',
priority: 'High',
labels: ['Docs', 'Design'],
assignees: ['Ana Costa'],
owner: 'Rhea Kapoor',
due: '2026-07-10',
description:
'Assemble the fact sheet, founder bios, logo pack, and three product screenshots into a single downloadable kit for press.',
comments: [],
},
{
id: 234,
title: 'Set up the launch landing page',
project: 'Q3 Launch',
status: 'Todo',
priority: 'Medium',
labels: ['Feature', 'Frontend'],
assignees: ['Priya Nair'],
owner: 'Rhea Kapoor',
due: '2026-07-13',
description:
'A single page with the announcement, a demo video, and an email capture. Wire the form to the marketing list and add the launch-day banner.',
comments: [],
},
{
id: 226,
title: 'Draft the launch announcement email',
project: 'Q3 Launch',
status: 'Todo',
priority: 'Low',
labels: ['Docs'],
assignees: ['Rhea Kapoor'],
owner: 'Rhea Kapoor',
due: '2026-07-15',
description:
'First draft of the announcement for the existing-customer list: what changed, what it costs, and one clear call to action. Marketing reviews it on the 16th.',
comments: [],
},
{
id: 227,
title: 'Draft the launch-day runbook',
project: 'Q3 Launch',
status: 'Backlog',
priority: 'Medium',
labels: ['Docs', 'Chore'],
assignees: ['Sam Rivera'],
owner: 'Rhea Kapoor',
due: '',
description:
'Step-by-step timeline for launch day: who flips which switch, in what order, and the rollback plan if something goes sideways.',
comments: [],
},
{
id: 218,
title: 'Line up customer testimonials',
project: 'Q3 Launch',
status: 'Backlog',
priority: 'Low',
labels: ['Research'],
assignees: ['Ana Costa'],
owner: 'Ana Costa',
due: '',
description:
'Reach out to five beta customers for a short quote and a logo release. Two confirmed so far.',
comments: [],
},
{
id: 201,
title: 'Beta program wrap-up report',
project: 'Q3 Launch',
status: 'Done',
priority: 'High',
labels: ['Docs'],
assignees: ['Rhea Kapoor'],
owner: 'Rhea Kapoor',
due: '2026-06-25',
description:
'What we learned from the eight-week beta: top requests, the bugs we fixed, and the three things we deliberately deferred.',
comments: [],
},
])
// The sidebar switches between the "My tasks" view and one project's tasks.
// It opens on a project so the single-project layout (no project column) is
// what you see first.
const MY_TASKS = 'My tasks'
const activeView = ref('Website Redesign')
function openView(view) {
activeView.value = view
selectedTaskId.value = null
}
const openCount = (project) =>
tasks.filter(
(t) => t.project === project && !['Done', 'Canceled'].includes(t.status),
).length
// Filter bar state. `listTab` scopes to me; the rest are optional attribute
// filters that stack on top ('' means "no filter").
const listTab = ref('All')
const filters = reactive({
status: '',
priority: '',
assignee: '',
label: '',
})
const activeFilterCount = computed(
() => ['status', 'priority', 'assignee', 'label'].filter((k) => filters[k]).length,
)
function clearFilters() {
filters.status = filters.priority = filters.assignee = filters.label = ''
}
const labels = [...new Set(tasks.flatMap((t) => t.labels))].sort()
// First option doubles as the reset: its value is '' and its label reads like
// the field name, so the trigger shows "Priority" until you pick a value.
const statusFilterOptions = [
{ label: 'Status', value: '' },
...statuses.map((s) => ({ label: s, value: s })),
]
const priorityFilterOptions = [
{ label: 'Priority', value: '' },
...priorities.map((p) => ({ label: p, value: p })),
]
const assigneeFilterOptions = [
{ label: 'Assignee', value: '' },
...people.map((p) => ({ label: p.name, value: p.name })),
]
const labelFilterOptions = [
{ label: 'Label', value: '' },
...labels.map((l) => ({ label: l, value: l })),
]
const sortBy = ref('priority')
const sortLabels = { priority: 'Priority', due: 'Due date', title: 'Title' }
const sortLabel = computed(() => sortLabels[sortBy.value])
const sortDropdownOptions = Object.entries(sortLabels).map(([value, label]) => ({
label,
onClick: () => (sortBy.value = value),
}))
const visibleTasks = computed(() => {
let scoped =
activeView.value === MY_TASKS
? tasks.filter((t) => t.assignees.includes(me) || t.owner === me)
: tasks.filter((t) => t.project === activeView.value)
if (listTab.value === 'Assigned to me') {
scoped = scoped.filter((t) => t.assignees.includes(me))
} else if (listTab.value === 'Created by me') {
scoped = scoped.filter((t) => t.owner === me)
}
if (filters.status) scoped = scoped.filter((t) => t.status === filters.status)
if (filters.priority)
scoped = scoped.filter((t) => t.priority === filters.priority)
if (filters.assignee)
scoped = scoped.filter((t) => t.assignees.includes(filters.assignee))
if (filters.label)
scoped = scoped.filter((t) => t.labels.includes(filters.label))
return scoped
})
function sortTasks(list) {
const arr = [...list]
if (sortBy.value === 'priority') {
arr.sort((a, b) => priorityRank[a.priority] - priorityRank[b.priority])
} else if (sortBy.value === 'due') {
// Undated tasks sort last.
arr.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
} else if (sortBy.value === 'title') {
arr.sort((a, b) => a.title.localeCompare(b.title))
}
return arr
}
// Which attribute the list is grouped by. Each field defines its own section
// order and how a task maps to a section key; a task lands in one section
// (its primary assignee / first label) so counts stay honest.
const groupBy = ref('status')
const groupByLabels = {
status: 'Status',
priority: 'Priority',
assignee: 'Assignee',
project: 'Project',
label: 'Label',
}
const groupByLabel = computed(() => groupByLabels[groupBy.value])
const groupByDropdownOptions = Object.entries(groupByLabels).map(
([value, label]) => ({ label, onClick: () => (groupBy.value = value) }),
)
function groupOrder(field) {
if (field === 'priority') return priorities
if (field === 'project') return projects.map((p) => p.name)
if (field === 'assignee') return [...people.map((p) => p.name), 'No assignee']
if (field === 'label') return [...labels, 'No label']
return ['In Progress', 'Todo', 'Backlog', 'Done', 'Canceled']
}
function groupKeyOf(task, field) {
if (field === 'priority') return task.priority
if (field === 'project') return task.project
if (field === 'assignee') return task.assignees[0] || 'No assignee'
if (field === 'label') return task.labels[0] || 'No label'
return task.status
}
// Sections are open by default; only finished status columns start collapsed.
// `openState` records explicit user toggles and overrides that default.
const openState = reactive({})
function defaultOpen(key) {
return !(groupBy.value === 'status' && ['Done', 'Canceled'].includes(key))
}
function groupOpen(key) {
return key in openState ? openState[key] : defaultOpen(key)
}
function toggleGroup(key) {
openState[key] = !groupOpen(key)
}
const groupedTasks = computed(() => {
const field = groupBy.value
return groupOrder(field)
.map((key) => ({
key,
tasks: sortTasks(
visibleTasks.value.filter((t) => groupKeyOf(t, field) === key),
),
}))
.filter((group) => group.tasks.length)
})
// List ↔ detail are two "screens" of the same page; a real app would use
// two routes and pass the task id as a param.
const selectedTaskId = ref(null)
const selectedTask = computed(() =>
tasks.find((t) => t.id === selectedTaskId.value),
)
const statusSelectOptions = statuses.map((s) => ({ label: s, value: s }))
const prioritySelectOptions = priorities.map((p) => ({ label: p, value: p }))
const assigneeOptions = people.map((p) => ({
label: p.name,
value: p.name,
}))
// Combobox picks up `icon` for both the option rows and the button trigger.
const projectOptions = projects.map((p) => ({
label: p.name,
value: p.name,
icon: p.icon,
}))
// Tasks store due dates as YYYY-MM-DD (what DatePicker's v-model speaks);
// the list renders them in the short display form.
const formatDue = (due) => (due ? dayjs(due).format('MMM D') : '')
// What the tags column shows, kept within a badge budget so nothing clips
// mid-word: the "My tasks" view spends one slot on the project (the useful
// cross-project context) and one on a label; a project view shows two labels.
// Anything past the budget collapses into a "+N" chip.
function taskTags(task) {
const inMyTasks = activeView.value === MY_TASKS
const labelBudget = inMyTasks ? 1 : 2
const shown = task.labels.slice(0, labelBudget)
return {
project: inMyTasks ? task.project : null,
labels: shown,
extra: task.labels.length - shown.length,
}
}
// Linear-style single-row grid. Every trailing track is a *fixed* width so the
// title track (`minmax(0, 1fr)`) is the sole flexible one — it alone absorbs
// size differences (e.g. a row with two avatars) and every other column, id
// and tags included, lands at the same x on every row.
const taskColumns = [
'auto', // status icon
'2.5rem', // id
'minmax(0, 1fr)', // title
'14rem', // tags (labels, plus the project in "My tasks")
'6.5rem', // due date
'5.5rem', // priority
'3.5rem', // assignees (fits a 2-avatar stack, right-aligned)
]
function statusDropdownOptions(task) {
return statuses.map((status) => ({
label: status,
icon: statusIcon[status],
onClick: () => (task.status = status),
}))
}
const newComment = ref('')
function addComment() {
if (!newComment.value.trim()) return
selectedTask.value.comments.push({
author: me,
time: 'Just now',
text: newComment.value.trim(),
})
newComment.value = ''
}
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell>
<template #sidebar>
<Sidebar width="14rem" class="border-r">
<SidebarHeader
title="Halcyon Studio"
subtitle="Workspace"
logo="https://api.dicebear.com/10.x/disco/svg?seed=Halcyon"
:menu-items="[
{ label: 'Invite members', icon: 'lucide-user-plus' },
{ label: 'Workspace settings', icon: 'lucide-settings-2' },
{ label: 'Log out', icon: 'lucide-log-out' },
]"
/>
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<nav class="space-y-0.5">
<SidebarItem>
<template #prefix>
<span class="lucide-inbox size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">Inbox</span>
<template #suffix>
<span class="mr-1 text-xs text-ink-gray-5">4</span>
</template>
</SidebarItem>
<SidebarItem
:active="activeView === MY_TASKS"
@click="openView(MY_TASKS)"
>
<template #prefix>
<span class="lucide-list-todo size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">My tasks</span>
</SidebarItem>
<SidebarItem>
<template #prefix>
<span class="lucide-search size-4" aria-hidden="true" />
</template>
<span class="flex-1 truncate text-sm">Search</span>
</SidebarItem>
</nav>
<div class="mt-4 flex h-7 items-center justify-between">
<SidebarLabel>Projects</SidebarLabel>
<Button
variant="ghost"
size="sm"
icon="lucide-plus text-ink-gray-5"
label="New project"
/>
</div>
<nav class="mt-0.5 space-y-0.5">
<SidebarItem
v-for="project in projects"
:key="project.name"
:active="activeView === project.name"
@click="openView(project.name)"
>
<template #prefix>
<span
:class="project.icon"
class="size-4"
aria-hidden="true"
/>
</template>
<span class="flex-1 truncate text-sm">{{ project.name }}</span>
<template #suffix>
<Badge
v-if="openCount(project.name)"
variant="ghost"
:label="String(openCount(project.name))"
/>
</template>
</SidebarItem>
</nav>
</ScrollArea>
</Sidebar>
</template>
<!-- Screen 1: task list -->
<template v-if="!selectedTask">
<PageHeader>
<Breadcrumbs :items="[{ label: activeView }]" />
<div class="flex items-center gap-2">
<Button variant="solid" label="Add task" icon-left="lucide-plus" />
</div>
</PageHeader>
<div class="w-full px-3 pb-10 sm:px-6">
<!-- Filter bar: scope tabs, stackable attribute filters, and sort. -->
<div class="flex flex-wrap items-center gap-2 pt-5">
<TabButtons
v-model="listTab"
:options="[
{ label: 'All' },
{ label: 'Assigned to me' },
{ label: 'Created by me' },
]"
/>
<Divider orientation="vertical" class="mx-1" flex-item />
<Select
v-model="filters.status"
variant="ghost"
:options="statusFilterOptions"
>
<template #item-prefix="{ item }">
<span
v-if="item.value"
:class="statusIcon[item.value]"
class="size-4 text-ink-gray-6"
aria-hidden="true"
/>
</template>
</Select>
<Select
v-model="filters.priority"
variant="ghost"
:options="priorityFilterOptions"
>
<template #item-prefix="{ item }">
<span
v-if="item.value"
class="size-4"
:class="[priorityIcon[item.value], priorityColor[item.value]]"
aria-hidden="true"
/>
</template>
</Select>
<Select
v-model="filters.assignee"
variant="ghost"
:options="assigneeFilterOptions"
>
<template #item-prefix="{ item }">
<Avatar
v-if="item.value"
:image="imageOf(item.value)"
:label="item.label"
size="xs"
/>
</template>
</Select>
<Select
v-model="filters.label"
variant="ghost"
:options="labelFilterOptions"
>
<template #item-prefix="{ item }">
<span
v-if="item.value"
class="size-2 rounded-full"
:class="labelDotClass(item.value)"
aria-hidden="true"
/>
</template>
</Select>
<Button
v-if="activeFilterCount"
variant="ghost"
label="Clear"
icon-left="lucide-x"
@click="clearFilters"
/>
<div class="ml-auto flex items-center gap-2">
<span class="text-sm text-ink-gray-5">
{{ visibleTasks.length }} tasks
</span>
<Dropdown :options="groupByDropdownOptions" placement="right">
<Button variant="ghost" icon-left="lucide-layers">
Group: {{ groupByLabel }}
</Button>
</Dropdown>
<Dropdown :options="sortDropdownOptions" placement="right">
<Button variant="ghost" icon-left="lucide-arrow-up-down">
{{ sortLabel }}
</Button>
</Dropdown>
</div>
</div>
<div class="mt-4 space-y-4">
<div v-for="group in groupedTasks" :key="group.key">
<button
class="group flex w-full items-baseline rounded-sm bg-surface-sidebar px-2.5 py-2 text-base transition hover:bg-surface-gray-2"
@click="toggleGroup(group.key)"
>
<span class="font-medium text-ink-gray-8">
{{ group.key }}
</span>
<span class="ml-2 text-sm text-ink-gray-5">
{{ group.tasks.length }}
</span>
<span
class="ml-auto hidden text-sm text-ink-gray-5 group-hover:inline"
>
{{ groupOpen(group.key) ? 'Collapse' : 'Expand' }}
</span>
</button>
<List
v-if="groupOpen(group.key)"
class="mt-1"
:columns="taskColumns"
>
<ListRow
v-for="task in group.tasks"
:key="task.id"
class="h-10"
@click="selectedTaskId = task.id"
>
<ListCell>
<!-- Changing status shouldn't open the task: stop the
click before it reaches the row. -->
<span @click.stop>
<Tooltip text="Change status">
<Dropdown :options="statusDropdownOptions(task)">
<button
class="flex rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-outline-gray-3"
>
<span
:class="statusIcon[task.status]"
class="size-4 text-ink-gray-6"
:aria-label="task.status"
/>
</button>
</Dropdown>
</Tooltip>
</span>
</ListCell>
<ListCell>
<span class="text-sm tabular-nums text-ink-gray-4">
{{ task.id }}
</span>
</ListCell>
<ListCell>
<span class="truncate text-base-medium text-ink-gray-8">
{{ task.title }}
</span>
</ListCell>
<ListCell class="gap-1.5 overflow-hidden">
<!-- The project is implied inside a project view; label it
only in the cross-project "My tasks" list. -->
<Badge
v-if="taskTags(task).project"
variant="outline"
theme="gray"
class="shrink-0"
:label="taskTags(task).project"
/>
<Badge
v-for="label in taskTags(task).labels"
:key="label"
variant="outline"
theme="gray"
class="shrink-0"
:label="label"
>
<template #prefix>
<span
class="size-1.5 rounded-full"
:class="labelDotClass(label)"
aria-hidden="true"
/>
</template>
</Badge>
<span
v-if="taskTags(task).extra"
class="shrink-0 text-xs text-ink-gray-4"
>
+{{ taskTags(task).extra }}
</span>
</ListCell>
<ListCell>
<span
v-if="task.due"
class="flex items-center whitespace-nowrap text-sm text-ink-gray-5"
>
<span
class="lucide-calendar mr-1.5 size-3.5 shrink-0"
aria-hidden="true"
/>
{{ formatDue(task.due) }}
</span>
</ListCell>
<ListCell>
<span
class="flex items-center whitespace-nowrap text-sm text-ink-gray-5"
>
<span
class="mr-1 size-4 shrink-0"
:class="[priorityIcon[task.priority], priorityColor[task.priority]]"
aria-hidden="true"
/>
{{ task.priority }}
</span>
</ListCell>
<ListCell class="justify-end">
<Tooltip
v-if="task.assignees.length"
:text="task.assignees.join(', ')"
>
<div class="flex -space-x-1">
<Avatar
v-for="name in task.assignees"
:key="name"
:image="imageOf(name)"
:label="name"
size="sm"
/>
</div>
</Tooltip>
</ListCell>
</ListRow>
</List>
</div>
</div>
</div>
</template>
<!-- Screen 2: task detail -->
<template v-else>
<PageHeader>
<Breadcrumbs
:items="[
{ label: activeView, onClick: () => (selectedTaskId = null) },
{ label: selectedTask.title },
]"
/>
</PageHeader>
<div class="flex h-full flex-1">
<ScrollArea class="min-h-0 w-full flex-1">
<div class="p-6">
<h1 class="text-2xl-semibold text-ink-gray-8">
{{ selectedTask.title }}
</h1>
<p class="mt-3 text-p-base text-ink-gray-7">
{{ selectedTask.description }}
</p>
<div class="mt-10 border-t pt-6">
<h2 class="text-base-semibold text-ink-gray-8">
Comments
<span
v-if="selectedTask.comments.length"
class="ml-1 font-normal text-ink-gray-5"
>
{{ selectedTask.comments.length }}
</span>
</h2>
<div class="mt-5 space-y-6">
<div
v-for="(comment, i) in selectedTask.comments"
:key="i"
class="flex gap-3"
>
<Avatar
:image="imageOf(comment.author)"
:label="comment.author"
size="lg"
/>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<span class="text-base-medium text-ink-gray-8">
{{ comment.author }}
</span>
<span class="text-sm text-ink-gray-5">
{{ comment.time }}
</span>
</div>
<p class="mt-1 text-p-base text-ink-gray-7">
{{ comment.text }}
</p>
</div>
</div>
</div>
<div class="mt-6 flex gap-3">
<Avatar :image="imageOf(me)" :label="me" size="lg" />
<div class="flex-1">
<Textarea
v-model="newComment"
placeholder="Add a comment"
class="w-full"
/>
<div class="mt-2 flex justify-end">
<Button
variant="solid"
label="Comment"
:disabled="!newComment.trim()"
@click="addComment"
/>
</div>
</div>
</div>
</div>
</div>
</ScrollArea>
<!-- Meta panel: a two-column grid of label / control pairs. -->
<div class="hidden w-[20rem] shrink-0 border-l sm:block">
<div
class="grid grid-cols-[5rem_minmax(0,1fr)] items-center gap-y-6 p-6 text-base text-ink-gray-6"
>
<div>Status</div>
<Select
v-model="selectedTask.status"
:options="statusSelectOptions"
>
<template #item-prefix="{ item }">
<span
:class="statusIcon[item.value]"
class="size-4 text-ink-gray-6"
aria-hidden="true"
/>
</template>
</Select>
<div>Assignee</div>
<MultiSelect
v-model="selectedTask.assignees"
:options="assigneeOptions"
placeholder="Assign users"
>
<template #item-prefix="{ item }">
<Avatar
:image="imageOf(item.value)"
:label="item.label"
size="xs"
/>
</template>
</MultiSelect>
<div>Priority</div>
<Select
v-model="selectedTask.priority"
:options="prioritySelectOptions"
>
<template #item-prefix="{ item }">
<span
class="size-4"
:class="[priorityIcon[item.value], priorityColor[item.value]]"
aria-hidden="true"
/>
</template>
</Select>
<div>Due date</div>
<DatePicker
v-model="selectedTask.due"
placeholder="Set due date"
format="MMM D"
>
<template #prefix>
<span
class="lucide-calendar size-4 text-ink-gray-6"
aria-hidden="true"
/>
</template>
</DatePicker>
<div>Project</div>
<Combobox
v-model="selectedTask.project"
trigger="button"
:options="projectOptions"
placeholder="Select project"
/>
<div>Labels</div>
<div class="flex flex-wrap gap-1.5">
<Badge
v-for="label in selectedTask.labels"
:key="label"
variant="outline"
theme="gray"
:label="label"
>
<template #prefix>
<span
class="size-1.5 rounded-full"
:class="labelDotClass(label)"
aria-hidden="true"
/>
</template>
</Badge>
</div>
</div>
</div>
</div>
</template>
</DesktopShell>
</div>
</template>vue
<script setup>
import { computed, reactive, ref } from 'vue'
import {
Avatar,
Badge,
Button,
Dropdown,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
TabButtons,
Textarea,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
const tab = ref('tasks')
const statuses = ['Backlog', 'Todo', 'In Progress', 'Done', 'Canceled']
const statusIcon = {
Backlog: 'lucide-circle-dashed',
Todo: 'lucide-circle',
'In Progress': 'lucide-circle-dot',
Done: 'lucide-circle-check',
Canceled: 'lucide-circle-x',
}
// Priority signal icons, colored by severity (matches the desktop recipe):
// more bars = higher priority, hot for High down to muted for Low.
const priorityIcon = {
High: 'lucide-signal-high',
Medium: 'lucide-signal-medium',
Low: 'lucide-signal-low',
}
const priorityColor = {
High: 'text-ink-red-7',
Medium: 'text-ink-amber-7',
Low: 'text-ink-gray-5',
}
// Labels render as neutral gray-outline badges with a single colored dot, so
// the meta row stays quiet but each label is still recognizable.
const labelTheme = {
Bug: 'red',
Feature: 'blue',
Improvement: 'green',
Design: 'violet',
Research: 'amber',
Backend: 'gray',
Frontend: 'gray',
Docs: 'gray',
Chore: 'gray',
}
const themeDot = {
red: 'bg-surface-red-6',
blue: 'bg-surface-blue-6',
green: 'bg-surface-green-6',
amber: 'bg-surface-amber-6',
violet: 'bg-surface-violet-6',
gray: 'bg-surface-gray-6',
}
const labelDotClass = (label) => themeDot[labelTheme[label]]
const me = 'Rhea Kapoor'
const people = [
{ name: 'Rhea Kapoor', image: 'https://i.pravatar.cc/150?img=1' },
{
name: 'Evan You',
image: 'https://avatars.githubusercontent.com/u/499550?v=4',
},
{ name: 'Priya Nair', image: 'https://i.pravatar.cc/150?img=5' },
{ name: 'Sam Rivera', image: 'https://i.pravatar.cc/150?img=12' },
{ name: 'Ana Costa', image: 'https://i.pravatar.cc/150?img=20' },
{ name: 'Maya Iyer', image: 'https://i.pravatar.cc/150?img=27' },
]
const imageOf = (name) => people.find((p) => p.name === name)?.image
const projectIcon = {
'Website Redesign': 'lucide-globe',
'Mobile App': 'lucide-smartphone',
'Design System': 'lucide-shapes',
'Q3 Launch': 'lucide-rocket',
}
const tasks = reactive([
{
id: 245,
title: 'Fix layout shift on the pricing page',
project: 'Website Redesign',
status: 'In Progress',
priority: 'High',
labels: ['Bug', 'Frontend'],
assignee: 'Priya Nair',
due: 'Today',
description:
'The hero image loads without reserved space, so the whole page jumps once it decodes. Set explicit dimensions and measure CLS before and after.',
comments: [],
},
{
id: 231,
title: 'Fix validation errors in the checkout flow',
project: 'Website Redesign',
status: 'In Progress',
priority: 'High',
labels: ['Bug', 'Frontend'],
assignee: 'Rhea Kapoor',
due: 'Today',
description:
'The payment step accepts an empty billing address and then fails at the gateway with a generic error. Validate required fields inline, surface gateway errors next to the affected field, and keep the entered card details on retry.',
comments: [
{
author: 'Evan You',
time: '2h ago',
text: 'Reproduced it — the address form skips validation when you pay with a saved card. That path is probably the real bug.',
},
{
author: 'Rhea Kapoor',
time: '1h ago',
text: 'Agreed. Fixing the saved-card path first, then adding inline errors for the rest of the form.',
},
],
},
{
id: 244,
title: 'Fix push notifications not arriving on iOS 17',
project: 'Mobile App',
status: 'In Progress',
priority: 'High',
labels: ['Bug', 'Backend'],
assignee: 'Sam Rivera',
due: 'Tomorrow',
description:
'A chunk of iOS 17 devices stopped receiving pushes after the last release. Suspect the APNs token refresh — add logging around registration and compare against Android.',
comments: [
{
author: 'Sam Rivera',
time: '5h ago',
text: 'Tokens look stale for users who upgraded in place. Testing a forced re-registration on launch.',
},
],
},
{
id: 236,
title: 'Migrate components to design tokens',
project: 'Design System',
status: 'In Progress',
priority: 'Medium',
labels: ['Improvement', 'Frontend'],
assignee: 'Maya Iyer',
due: 'Jul 8',
description:
'Replace the remaining hardcoded colors and spacing with semantic tokens so theming works in one place. Start with the form controls.',
comments: [],
},
{
id: 235,
title: 'Review the onboarding flow prototype',
project: 'Mobile App',
status: 'Todo',
priority: 'High',
labels: ['Design'],
assignee: 'Rhea Kapoor',
due: 'Jul 7',
description:
'Second iteration is up. Focus on the empty states and the progress indicator — both changed since the last review.',
comments: [
{
author: 'Ana Costa',
time: '1d ago',
text: 'Prototype link is in the project description. The step counter is the part I am least sure about.',
},
],
},
{
id: 237,
title: 'Build offline mode for the task list',
project: 'Mobile App',
status: 'Todo',
priority: 'High',
labels: ['Feature'],
assignee: 'Priya Nair',
due: 'Jul 13',
description:
'Cache the task list locally and queue edits made while offline, then reconcile on reconnect. Show a clear indicator when changes are pending sync.',
comments: [],
},
{
id: 228,
title: 'Migrate marketing pages to the new CMS',
project: 'Website Redesign',
status: 'Todo',
priority: 'Medium',
labels: ['Feature', 'Backend'],
assignee: 'Priya Nair',
due: 'Jul 8',
description:
'Move the pricing, about, and careers pages off the legacy templates. Content is already exported — wire up the new layouts and set redirects for the old URLs.',
comments: [],
},
{
id: 239,
title: 'Prepare the press kit',
project: 'Q3 Launch',
status: 'Todo',
priority: 'High',
labels: ['Docs', 'Design'],
assignee: 'Ana Costa',
due: 'Jul 10',
description:
'Assemble the fact sheet, founder bios, logo pack, and three product screenshots into a single downloadable kit for press.',
comments: [],
},
{
id: 216,
title: 'Set up analytics events for the signup funnel',
project: 'Website Redesign',
status: 'Backlog',
priority: 'Medium',
labels: ['Backend'],
assignee: 'Sam Rivera',
due: '',
description:
'Instrument each step of the signup funnel so we can see where people drop off. Match the event names to the existing dashboard schema.',
comments: [],
},
{
id: 226,
title: 'Draft the launch announcement email',
project: 'Q3 Launch',
status: 'Backlog',
priority: 'Low',
labels: ['Docs'],
assignee: 'Rhea Kapoor',
due: 'Jul 15',
description:
'First draft of the announcement for the existing-customer list: what changed, what it costs, and one clear call to action. Marketing reviews it on the 16th.',
comments: [],
},
{
id: 208,
title: 'Ship empty-state illustrations',
project: 'Design System',
status: 'Done',
priority: 'High',
labels: ['Design'],
assignee: 'Rhea Kapoor',
due: 'Jul 1',
description:
'Final set of six empty-state illustrations, exported for both themes and wired into the component library.',
comments: [
{
author: 'Priya Nair',
time: '3d ago',
text: 'These look great in the app. Nice work!',
},
],
},
{
id: 201,
title: 'Beta program wrap-up report',
project: 'Q3 Launch',
status: 'Done',
priority: 'High',
labels: ['Docs'],
assignee: 'Rhea Kapoor',
due: 'Jun 25',
description:
'What we learned from the eight-week beta: top requests, the bugs we fixed, and the three things we deliberately deferred.',
comments: [],
},
])
const filterTab = ref('Open')
const visibleTasks = computed(() => {
if (filterTab.value === 'Open') {
return tasks.filter((t) => !['Done', 'Canceled'].includes(t.status))
}
return tasks
})
// List ↔ detail are two "screens" of the same page; a real app would use
// two routes and pass the task id as a param.
const selectedTaskId = ref(null)
const selectedTask = computed(() =>
tasks.find((t) => t.id === selectedTaskId.value),
)
function statusDropdownOptions(task) {
return statuses.map((status) => ({
label: status,
icon: statusIcon[status],
onClick: () => (task.status = status),
}))
}
const newComment = ref('')
function addComment() {
if (!newComment.value.trim()) return
selectedTask.value.comments.push({
author: me,
time: 'Just now',
text: newComment.value.trim(),
})
newComment.value = ''
}
</script>
<template>
<MobileShell>
<!-- Screen 1: task list -->
<template v-if="!selectedTask">
<PageHeaderMobile title="My tasks">
<template #right>
<Button variant="ghost" icon="lucide-plus" label="New task" />
</template>
</PageHeaderMobile>
<div class="pb-6 pt-3">
<div class="mb-3 px-4">
<TabButtons
v-model="filterTab"
class="w-full"
:options="[{ label: 'Open' }, { label: 'All' }]"
/>
</div>
<List class="list-gap-3 list-row-px-4">
<ListRow
v-for="task in visibleTasks"
:key="task.id"
class="py-2.5"
@click="selectedTaskId = task.id"
>
<ListCell>
<span
:class="statusIcon[task.status]"
class="size-4.5 text-ink-gray-6"
:aria-label="task.status"
/>
</ListCell>
<ListCell>
<div class="min-w-0 flex-1">
<!-- The sized text lives in an inner span so it keeps its own
line-height. Putting `leading-none` on the same element as
`truncate` (overflow-hidden) would shear off descenders
like the tail of "g". -->
<div
class="truncate leading-snug"
:class="
task.status === 'Done'
? 'text-ink-gray-5'
: 'text-ink-gray-8'
"
>
<span class="text-lg">{{ task.title }}</span>
</div>
<!-- Meta row mirrors the desktop columns: severity-colored
priority icon, a label tag, and the due date. -->
<div
class="mt-1.5 flex min-w-0 items-center gap-2 text-md text-ink-gray-5"
>
<span class="flex shrink-0 items-center">
<span
class="mr-1 size-4 shrink-0"
:class="[
priorityIcon[task.priority],
priorityColor[task.priority],
]"
aria-hidden="true"
/>
{{ task.priority }}
</span>
<Badge
v-if="task.labels.length"
variant="outline"
theme="gray"
class="shrink-0"
:label="task.labels[0]"
>
<template #prefix>
<span
class="size-1.5 rounded-full"
:class="labelDotClass(task.labels[0])"
aria-hidden="true"
/>
</template>
</Badge>
<span v-if="task.due" class="truncate whitespace-nowrap">
{{ task.due }}
</span>
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<Avatar
:image="imageOf(task.assignee)"
:label="task.assignee"
size="sm"
/>
</ListCell>
</ListRow>
</List>
</div>
</template>
<!-- Screen 2: task detail -->
<template v-else>
<PageHeaderMobile :title="`#${selectedTask.id}`">
<template #left>
<Button
variant="ghost"
icon="lucide-chevron-left"
label="Back to tasks"
@click="selectedTaskId = null"
/>
</template>
</PageHeaderMobile>
<div class="px-4 pb-6 pt-4">
<h1 class="text-xl-semibold text-ink-gray-8">
{{ selectedTask.title }}
</h1>
<p class="mt-2 text-p-base text-ink-gray-7">
{{ selectedTask.description }}
</p>
<!-- Meta chips: Gameplan's mobile task detail lays the same controls
out as a wrapping row instead of a side panel. -->
<div class="mt-6 flex flex-wrap items-center gap-2">
<Dropdown :options="statusDropdownOptions(selectedTask)">
<Button>
<template #prefix>
<span
:class="statusIcon[selectedTask.status]"
class="size-4 text-ink-gray-6"
aria-hidden="true"
/>
</template>
{{ selectedTask.status }}
</Button>
</Dropdown>
<Button>
<template #prefix>
<span
class="size-4"
:class="[
priorityIcon[selectedTask.priority],
priorityColor[selectedTask.priority],
]"
aria-hidden="true"
/>
</template>
{{ selectedTask.priority }}
</Button>
<Button v-if="selectedTask.due" icon-left="lucide-calendar">
{{ selectedTask.due }}
</Button>
<Button :icon-left="projectIcon[selectedTask.project]">
{{ selectedTask.project }}
</Button>
<Button>
<template #prefix>
<Avatar
:image="imageOf(selectedTask.assignee)"
:label="selectedTask.assignee"
size="xs"
/>
</template>
{{ selectedTask.assignee }}
</Button>
<Badge
v-for="label in selectedTask.labels"
:key="label"
variant="outline"
theme="gray"
size="lg"
:label="label"
>
<template #prefix>
<span
class="size-1.5 rounded-full"
:class="labelDotClass(label)"
aria-hidden="true"
/>
</template>
</Badge>
</div>
<div class="mt-8 border-t pt-5">
<h2 class="text-base-semibold text-ink-gray-8">
Comments
<span
v-if="selectedTask.comments.length"
class="ml-1 font-normal text-ink-gray-5"
>
{{ selectedTask.comments.length }}
</span>
</h2>
<div class="mt-5 space-y-5">
<div
v-for="(comment, i) in selectedTask.comments"
:key="i"
class="flex gap-3"
>
<Avatar
:image="imageOf(comment.author)"
:label="comment.author"
size="lg"
/>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<span class="text-base-medium text-ink-gray-8">
{{ comment.author }}
</span>
<span class="text-sm text-ink-gray-5">
{{ comment.time }}
</span>
</div>
<p class="mt-1 text-p-base text-ink-gray-7">
{{ comment.text }}
</p>
</div>
</div>
</div>
<div class="mt-6">
<Textarea
v-model="newComment"
placeholder="Add a comment"
class="w-full"
/>
<div class="mt-2 flex justify-end">
<Button
variant="solid"
label="Comment"
:disabled="!newComment.trim()"
@click="addComment"
/>
</div>
</div>
</div>
</div>
</template>
<template #nav>
<MobileNav>
<MobileNavItem
label="Inbox"
icon="lucide-inbox"
:active="tab === 'inbox'"
@click="tab = 'inbox'"
/>
<MobileNavItem
label="Tasks"
icon="lucide-list-todo"
:active="tab === 'tasks'"
@click="tab = 'tasks'"
/>
<MobileNavItem
label="Projects"
icon="lucide-folder-kanban"
:active="tab === 'projects'"
@click="tab = 'projects'"
/>
<MobileNavItem label="You" :active="tab === 'you'" @click="tab = 'you'">
<template #default="{ active }">
<Avatar
image="https://i.pravatar.cc/150?img=1"
label="Rhea Kapoor"
size="md"
:class="active ? 'ring-2 ring-outline-gray-4' : ''"
/>
</template>
</MobileNavItem>
</MobileNav>
</template>
</MobileShell>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
Button,
DateRangePicker,
DesktopShell,
PageHeader,
PageHeaderTitle,
ScrollArea,
Sidebar,
SidebarHeader,
SidebarItem,
SidebarLabel,
TabButtons,
} from 'frappe-ui'
import {
List,
ListCell,
ListHeader,
ListHeaderCell,
ListRow,
ListRows,
} from 'frappe-ui/list'
// Grouped navigation. Only the four `page` items render a real screen; the
// rest are shell dressing that fall through to a shared empty state, so the
// sidebar reads like a full accounting product without faking every module.
const navGroups = [
{
label: 'Overview',
items: [
{
key: 'cashflow',
label: 'Cashflow',
icon: 'lucide-activity',
page: true,
},
],
},
{
label: 'Sales',
items: [
{ key: 'invoices', label: 'Invoices', icon: 'lucide-file-text' },
{ key: 'payments', label: 'Payments', icon: 'lucide-credit-card' },
{ key: 'orders', label: 'Orders', icon: 'lucide-shopping-cart' },
{ key: 'customers', label: 'Customers', icon: 'lucide-users-round' },
],
},
{
label: 'Purchases',
items: [
{ key: 'bills', label: 'Bills', icon: 'lucide-receipt-text' },
{
key: 'expenses',
label: 'Expenses',
icon: 'lucide-receipt',
page: true,
},
{ key: 'suppliers', label: 'Suppliers', icon: 'lucide-truck' },
],
},
{
label: 'Accounting',
items: [
{ key: 'payroll', label: 'Payroll', icon: 'lucide-users', page: true },
{ key: 'journal', label: 'Journal', icon: 'lucide-book-open' },
{ key: 'taxes', label: 'Taxes', icon: 'lucide-percent' },
],
},
{
label: 'Reports',
items: [
{
key: 'reports',
label: 'Profit & Loss',
icon: 'lucide-chart-no-axes-column',
page: true,
},
{ key: 'balance', label: 'Balance Sheet', icon: 'lucide-scale' },
],
},
]
const navItems = navGroups.flatMap((group) => group.items)
const activePage = ref('cashflow')
const activeMeta = computed(
() => navItems.find((item) => item.key === activePage.value) ?? navItems[0],
)
const currency = (n) =>
`${n < 0 ? '-' : ''}$${Math.abs(n).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
/* -- Cashflow (dashboard) ------------------------------------------------- */
// `delta` is the month-over-month change in %; its sign drives the arrow
// direction. Kept neutral gray — "up" isn't universally good here (higher
// outgoings is worse), so colour would mislead.
const cashflowStats = [
{ label: 'Todays balance', value: 14081.09, delta: 4.2 },
{ label: 'Incoming in 30 days', value: 2011.44, delta: 12.5 },
{ label: 'Outgoing in 30 days', value: 12011.44, delta: 3.1 },
{ label: 'Projected balance', value: -1518.0, delta: -8.4 },
]
// Linked bank/card accounts shown in the dashboard's right rail.
const accounts = [
{ name: 'Business checking', number: '•••• 4021', balance: 18240.55 },
{ name: 'Savings', number: '•••• 8873', balance: 42500.0 },
{ name: 'Credit card', number: '•••• 1180', balance: -3241.09 },
]
const accountsTotal = accounts.reduce((sum, a) => sum + a.balance, 0)
// This month's spend by category, pre-sorted; `pct` drives the bar width.
const expenseBreakdown = [
{ category: 'Salaries', amount: 20100, pct: 62 },
{ category: 'Rent & utilities', amount: 3400, pct: 11 },
{ category: 'Marketing', amount: 3400, pct: 11 },
{ category: 'Software', amount: 1550, pct: 5 },
{ category: 'Everything else', amount: 3400, pct: 11 },
]
const flowTab = ref('outgoings')
const transactions = {
incomings: [
{ date: 'August 18', description: 'Invoice #1043 — Acme Co', amount: 4200 },
{ date: 'August 11', description: 'Invoice #1042 — Globex', amount: 1810 },
{ date: 'August 3', description: 'Refund — SaaS annual', amount: 240 },
],
outgoings: [
{ date: 'August 20', description: 'Lunch', amount: 104.99 },
{ date: 'August 12', description: 'Train ticket', amount: 5.23 },
{ date: 'August 8', description: 'Lunch with client', amount: 166.23 },
{ date: 'August 4', description: 'Printer', amount: 200.0 },
{ date: 'July 29', description: 'Coffee with client', amount: 6.0 },
],
}
const flowRows = computed(() => transactions[flowTab.value])
/* -- Expenses ------------------------------------------------------------- */
const expenses = [
{ date: 'August 20', description: 'Lunch', amount: 104.99 },
{ date: 'August 12', description: 'Train ticket', amount: 5.23 },
{ date: 'August 8', description: 'Lunch with client', amount: 166.23 },
{ date: 'August 4', description: 'Printer', amount: 200.0 },
{ date: 'July 29', description: 'Coffee with client', amount: 6.0 },
{ date: 'July 22', description: 'Travel', amount: 105.63 },
{ date: 'July 21', description: 'Hotel stay', amount: 350.0 },
{ date: 'July 12', description: 'Printer ink', amount: 15.0 },
{ date: 'July 10', description: 'Conference tickets', amount: 699.99 },
{ date: 'July 2', description: 'Train ticket', amount: 5.23 },
{ date: 'June 25', description: 'Bus travel', amount: 10.02 },
{ date: 'July 13', description: 'Accountant software', amount: 175.0 },
]
/* -- Payroll -------------------------------------------------------------- */
const payroll = [
{ id: 1, name: 'Stacey Bobb', total: 1900, tax: 300, ni: 314, net: 18540 },
{ id: 2, name: 'Derek Forbes', total: 1205, tax: 300, ni: 314, net: 19500 },
{ id: 3, name: 'Garth Leemow', total: 1900, tax: 200, ni: 314, net: 18540 },
{ id: 4, name: 'Ilyssa Bodah', total: 1200, tax: 400, ni: 314, net: 12000 },
{ id: 5, name: 'Bernard Timm', total: 3900, tax: 500, ni: 314, net: 28560 },
{ id: 6, name: 'Rabbi Ferouz', total: 1205, tax: 300, ni: 314, net: 12110 },
{ id: 7, name: 'Sam Ruprecht', total: 1900, tax: 150, ni: 314, net: 13880 },
{ id: 8, name: 'Daren Crabb', total: 5205, tax: 180, ni: 314, net: 110540 },
]
// The list's own selection model: `selectable` reveals the checkbox column and
// `v-model:selection` holds the chosen row `value`s (strings). A header button
// toggles the mode on and off.
const selectMode = ref(true)
const selection = ref(['2'])
function toggleSelectMode() {
selectMode.value = !selectMode.value
if (!selectMode.value) selection.value = []
}
/* -- Profit & Loss -------------------------------------------------------- */
// One financial year of monthly data (Apr 2024 – Mar 2025). Each month carries
// its first-of-month `date` so the DateRangePicker's window can be mapped back
// onto column indices. The report never renders all of it directly: the filter
// picks a window and the period toggle buckets it (see `buckets` below).
const months = [
{ label: 'Apr', year: 2024, date: '2024-04-01' },
{ label: 'May', year: 2024, date: '2024-05-01' },
{ label: 'Jun', year: 2024, date: '2024-06-01' },
{ label: 'Jul', year: 2024, date: '2024-07-01' },
{ label: 'Aug', year: 2024, date: '2024-08-01' },
{ label: 'Sep', year: 2024, date: '2024-09-01' },
{ label: 'Oct', year: 2024, date: '2024-10-01' },
{ label: 'Nov', year: 2024, date: '2024-11-01' },
{ label: 'Dec', year: 2024, date: '2024-12-01' },
{ label: 'Jan', year: 2025, date: '2025-01-01' },
{ label: 'Feb', year: 2025, date: '2025-02-01' },
{ label: 'Mar', year: 2025, date: '2025-03-01' },
]
// `section` rows are group headings; the rest are line items with one value per
// month, indexed to `months`. Enough lines that the report overflows the
// viewport and scrolls vertically as well as horizontally.
const pnlRows = [
{ type: 'section', label: 'Income' },
{
label: 'Turnover',
values: [
12500, 13200, 11800, 14100, 15300, 12900, 13800, 14600, 16200, 15100,
14200, 17300,
],
},
{
label: 'Product sales',
values: [
8200, 7600, 9100, 8800, 10200, 9400, 8900, 9700, 11300, 10600, 9800,
12100,
],
},
{
label: 'Service revenue',
values: [
4300, 4700, 4100, 5200, 4900, 5400, 5100, 5600, 6200, 5800, 5300, 6700,
],
},
{
label: 'Consulting',
values: [
3100, 2800, 3400, 3600, 3900, 3300, 3700, 4000, 4300, 3800, 3500, 4600,
],
},
{
label: 'Other income',
values: [320, 410, 280, 390, 450, 300, 370, 420, 510, 470, 360, 540],
},
{ type: 'section', label: 'Cost of sales' },
{
label: 'Materials',
values: [
4200, 3900, 4600, 4400, 5100, 4700, 4500, 4900, 5600, 5300, 4900, 6100,
],
},
{
label: 'Shipping & freight',
values: [820, 760, 910, 880, 1020, 940, 890, 970, 1130, 1060, 980, 1210],
},
{
label: 'Merchant fees',
values: [410, 430, 390, 470, 500, 440, 460, 490, 560, 520, 470, 610],
},
{ type: 'section', label: 'Expenses' },
{
label: 'Accountancy fees',
values: [750, 750, 750, 1450, 750, 750, 750, 1050, 750, 750, 750, 1250],
},
{
label: 'Software subscriptions',
values: [
1200, 1250, 1250, 1300, 1400, 1450, 1250, 1550, 1250, 1350, 1250, 1550,
],
},
{
label: 'Office equipment',
values: [2400, 840, 1050, 3050, 450, 800, 1050, 575, 1200, 650, 900, 575],
},
{
label: 'Rent & utilities',
values: [
3200, 3200, 3200, 3200, 3350, 3200, 3200, 3200, 3400, 3200, 3200, 3400,
],
},
{
label: 'Salaries',
values: [
18500, 18500, 18500, 18500, 19200, 19200, 19200, 19200, 19200, 20100,
20100, 20100,
],
},
{
label: 'Marketing',
values: [
1500, 2200, 900, 1800, 2600, 1200, 3100, 1400, 2700, 1900, 1600, 3400,
],
},
{
label: 'Travel',
values: [640, 980, 420, 1250, 760, 540, 1120, 890, 1340, 720, 610, 1480],
},
{
label: 'Insurance',
values: [520, 520, 520, 520, 540, 540, 540, 540, 560, 560, 560, 560],
},
{
label: 'Telecoms',
values: [180, 185, 182, 190, 195, 188, 192, 198, 205, 200, 196, 210],
},
{
label: 'Bank charges',
values: [90, 95, 88, 110, 105, 92, 98, 102, 120, 108, 96, 130],
},
{
label: 'Stationery',
values: [180, 220, 150, 310, 240, 190, 270, 160, 330, 210, 175, 290],
},
{
label: 'Meals & entertainment',
values: [420, 560, 380, 610, 720, 390, 540, 480, 690, 450, 510, 620],
},
]
// The picked window as [start, end] `YYYY-MM-DD`; defaults to the full year.
const fyStart = months[0].date
const fyEnd = months[months.length - 1].date
const dateRange = ref([fyStart, fyEnd])
const period = ref('monthly')
// Preset windows for the picker's #actions column.
const rangePresets = [
{ label: 'Financial year', range: [fyStart, fyEnd] },
{ label: 'Last 6 months', range: [months[6].date, fyEnd] },
{ label: 'Last quarter', range: [months[9].date, fyEnd] },
{ label: 'Last month', range: [fyEnd, fyEnd] },
]
// Map the picked date window onto column indices by comparing `YYYY-MM`
// prefixes — a partial selection falls back to the whole year.
const visibleIndices = computed(() => {
const [from, to] = dateRange.value
if (!from || !to) return months.map((_, i) => i)
const [lo, hi] = [from.slice(0, 7), to.slice(0, 7)].sort()
return months.flatMap((m, i) => {
const ym = m.date.slice(0, 7)
return ym >= lo && ym <= hi ? [i] : []
})
})
// A bucket is a labelled set of month indices — one per month (Monthly) or one
// per group of three (Quarterly). Both the columns and every cell derive from
// it, so one code path renders any range at either granularity.
const buckets = computed(() => {
const idxs = visibleIndices.value
if (period.value === 'monthly') {
return idxs.map((i) => ({ label: months[i].label, idx: [i] }))
}
const groups = []
for (let i = 0; i < idxs.length; i += 3) {
const idx = idxs.slice(i, i + 3)
const span = `${months[idx[0]].label}–${months[idx[idx.length - 1]].label}`
groups.push({ label: span, idx })
}
return groups
})
const cellValue = (row, bucket) =>
bucket.idx.reduce((sum, i) => sum + row.values[i], 0)
// First column is the line-item label; one flexible track per bucket.
const pnlColumns = computed(() => [
'minmax(9rem,1.4fr)',
...buckets.value.map(() => 'minmax(4.5rem,1fr)'),
])
</script>
<template>
<div class="h-screen w-full bg-surface-base text-ink-gray-9">
<DesktopShell>
<template #sidebar>
<Sidebar width="15rem" class="border-r">
<SidebarHeader
title="Everdusk Trading"
subtitle="everdusk.frappe.cloud"
logo="https://api.dicebear.com/10.x/disco/svg?seed=Everdusk"
:menu-items="[
{ label: 'Switch company', icon: 'lucide-arrow-left-right' },
{ label: 'Import data', icon: 'lucide-upload' },
{ label: 'Settings', icon: 'lucide-settings-2' },
]"
/>
<ScrollArea class="min-h-0 flex-1" viewport-class="px-2 pt-0.5 pb-10">
<div v-for="group in navGroups" :key="group.label" class="mb-3">
<div class="flex h-7 items-center">
<SidebarLabel>{{ group.label }}</SidebarLabel>
</div>
<nav class="mt-0.5 space-y-0.5">
<SidebarItem
v-for="item in group.items"
:key="item.key"
:active="item.key === activePage"
@click="activePage = item.key"
>
<template #prefix>
<span
:class="item.icon"
class="size-4"
aria-hidden="true"
/>
</template>
<span class="flex-1 truncate text-sm">{{ item.label }}</span>
<!-- Marks the items backed by a real screen (vs. shell-only
stubs). Gray per Gameplan's no-color rule. -->
<template v-if="item.page" #suffix>
<span
class="mr-2 size-1.5 rounded-full bg-surface-gray-5"
aria-hidden="true"
/>
</template>
</SidebarItem>
</nav>
</div>
</ScrollArea>
</Sidebar>
</template>
<PageHeader>
<PageHeaderTitle>{{ activeMeta.label }}</PageHeaderTitle>
<div class="flex items-center gap-2">
<template v-if="activePage === 'cashflow'">
<TabButtons
v-model="flowTab"
:options="[
{ label: 'Incomings', value: 'incomings' },
{ label: 'Outgoings', value: 'outgoings' },
]"
/>
<Button
variant="solid"
label="Add transaction"
icon-left="lucide-plus"
/>
</template>
<Button
v-else-if="activePage === 'expenses'"
variant="solid"
label="Add transaction"
icon-left="lucide-plus"
/>
<template v-else-if="activePage === 'payroll'">
<span
v-if="selectMode && selection.length"
class="text-sm text-ink-gray-6"
>
{{ selection.length }} selected
</span>
<Button
:label="selectMode ? 'Done' : 'Select'"
:icon-left="selectMode ? 'lucide-check' : 'lucide-list-checks'"
@click="toggleSelectMode"
/>
<Button
variant="solid"
label="Add employee"
icon-left="lucide-plus"
/>
</template>
<template v-else-if="activePage === 'reports'">
<Button
variant="ghost"
label="Compare"
icon-left="lucide-git-compare"
/>
<Button
variant="solid"
label="Export"
icon-left="lucide-download"
/>
</template>
</div>
</PageHeader>
<!-- Cashflow (dashboard) ---------------------------------------------->
<!-- Centered, single-column dashboard: everything stacks in one column
capped at max-w-4xl and centred, so the layout reads top-to-bottom
rather than spreading edge-to-edge. -->
<div v-if="activePage === 'cashflow'" class="p-4">
<div class="mx-auto max-w-4xl space-y-6">
<!-- KPI strip: one row, no per-item cards — just thin vertical
dividers. Each cell shows the figure and its trend. -->
<div class="grid grid-cols-4 divide-x divide-outline-gray-2">
<div
v-for="stat in cashflowStats"
:key="stat.label"
class="px-4 first:pl-0 last:pr-0"
>
<div class="text-xs text-ink-gray-5">{{ stat.label }}</div>
<div
class="mt-1 text-2xl font-semibold"
:class="stat.value < 0 ? 'text-ink-red-6' : 'text-ink-gray-9'"
>
{{ currency(stat.value) }}
</div>
<div class="mt-1 flex items-center gap-1 text-xs text-ink-gray-5">
<span
:class="
stat.delta >= 0 ? 'lucide-arrow-up' : 'lucide-arrow-down'
"
class="size-3"
aria-hidden="true"
/>
<span>{{ Math.abs(stat.delta) }}% vs last month</span>
</div>
</div>
</div>
<!-- Recent transactions: full width, spanning the whole column. -->
<section class="space-y-2">
<div class="flex h-7 items-center justify-between">
<h3 class="text-sm font-semibold text-ink-gray-8">
Recent transactions
</h3>
<Button variant="ghost" label="View all" />
</div>
<!-- list-row-px-0 drops the interactive row's default 0.75rem
inset so cells sit flush with the panel header and KPI strip
above — one shared left/right edge down the page. -->
<List
class="list-row-px-0"
:columns="['8rem', 'minmax(0,1fr)', '8rem']"
:row-height="44"
>
<ListRows :items="flowRows" v-slot="{ item }">
<ListRow @click="() => {}">
<ListCell>
<span class="text-sm text-ink-gray-6">{{ item.date }}</span>
</ListCell>
<ListCell>
<span class="truncate text-sm text-ink-gray-8">
{{ item.description }}
</span>
</ListCell>
<ListCell class="justify-end">
<span
class="text-sm"
:class="
flowTab === 'incomings'
? 'text-ink-green-6'
: 'text-ink-gray-8'
"
>
{{ flowTab === 'incomings' ? '+' : '-'
}}{{ currency(item.amount).replace('-', '') }}
</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</section>
<!-- Divider between the transactions row above and the two-up
widgets below. -->
<div class="border-t border-outline-gray-2" />
<!-- Bottom row: Cash accounts beside Spend this month. -->
<div class="grid grid-cols-2 gap-8">
<!-- Cash accounts -->
<section class="space-y-2">
<div class="flex h-7 items-center justify-between">
<h3 class="text-sm font-semibold text-ink-gray-8">
Cash accounts
</h3>
<span class="text-sm text-ink-gray-6">
{{ currency(accountsTotal) }}
</span>
</div>
<div class="divide-y divide-outline-gray-1">
<div
v-for="account in accounts"
:key="account.number"
class="flex items-center justify-between py-2"
>
<div class="min-w-0">
<div class="truncate text-sm text-ink-gray-8">
{{ account.name }}
</div>
<div class="text-p-xs text-ink-gray-5">
{{ account.number }}
</div>
</div>
<span
class="shrink-0 text-sm"
:class="
account.balance < 0 ? 'text-ink-red-6' : 'text-ink-gray-8'
"
>
{{ currency(account.balance) }}
</span>
</div>
</div>
</section>
<!-- Expense breakdown -->
<section class="space-y-3">
<div class="flex h-7 items-center">
<h3 class="text-sm font-semibold text-ink-gray-8">
Spend this month
</h3>
</div>
<div class="space-y-2.5">
<div
v-for="row in expenseBreakdown"
:key="row.category"
class="space-y-1"
>
<div class="flex items-center justify-between text-sm">
<span class="truncate text-ink-gray-7">
{{ row.category }}
</span>
<span class="text-ink-gray-6">
{{ currency(row.amount).replace('.00', '') }}
</span>
</div>
<div
class="h-1.5 overflow-hidden rounded-full bg-surface-gray-2"
>
<div
class="h-full rounded-full bg-surface-gray-6"
:style="{ width: `${row.pct}%` }"
/>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<!-- Expenses ---------------------------------------------------------->
<div v-else-if="activePage === 'expenses'" class="p-4">
<List
class="w-full list-row-px-3"
:columns="['10rem', 'minmax(0,1fr)', '9rem']"
:row-height="40"
>
<ListHeader>
<ListHeaderCell>Date</ListHeaderCell>
<ListHeaderCell>Description</ListHeaderCell>
<ListHeaderCell class="justify-end">Amount</ListHeaderCell>
</ListHeader>
<ListRows :items="expenses" v-slot="{ item }">
<ListRow @click="() => {}">
<ListCell>
<span class="text-base text-ink-gray-7">{{ item.date }}</span>
</ListCell>
<ListCell>
<span class="truncate text-base text-ink-gray-8">
{{ item.description }}
</span>
</ListCell>
<ListCell class="justify-end">
<span class="text-base text-ink-gray-8">
{{ currency(item.amount) }}
</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</div>
<!-- Payroll ----------------------------------------------------------->
<div v-else-if="activePage === 'payroll'" class="p-4">
<List
class="w-full list-row-px-3"
:columns="['minmax(0,1fr)', '9rem', '7rem', '7rem', '10rem']"
:row-height="40"
:selectable="selectMode"
v-model:selection="selection"
>
<ListHeader>
<ListHeaderCell>Name</ListHeaderCell>
<ListHeaderCell
>Total pay
<span class="text-ink-gray-5">(per annum)</span></ListHeaderCell
>
<ListHeaderCell
>Tax <span class="text-ink-gray-5">20%</span></ListHeaderCell
>
<ListHeaderCell>NI</ListHeaderCell>
<ListHeaderCell
>Net pay
<span class="text-ink-gray-5">(per annum)</span></ListHeaderCell
>
</ListHeader>
<ListRows :items="payroll" v-slot="{ item, value }">
<ListRow :value="value" @click="() => {}">
<ListCell>
<span class="truncate text-base text-ink-gray-8">{{
item.name
}}</span>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-7">{{
currency(item.total)
}}</span>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-7">{{
currency(item.tax)
}}</span>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-7">{{
currency(item.ni)
}}</span>
</ListCell>
<ListCell>
<span class="text-base text-ink-gray-8">{{
currency(item.net)
}}</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</div>
<!-- Profit & Loss ----------------------------------------------------->
<!-- `absolute inset-0` fills the shell's scroll region (its ScrollArea
root is `position: relative` with a definite height), giving this
page its own bounded height. That lets the filter bar and column
header stay put while only the rows scroll — vertical scrolling is
owned by the inner ScrollArea, not the shell. -->
<div
v-else-if="activePage === 'reports'"
class="absolute inset-0 flex flex-col p-4"
>
<!-- Filter bar: the date range picks the window, the period toggle sets
the granularity, and both flow into the table columns below. -->
<div class="mb-3 flex shrink-0 flex-wrap items-center gap-2">
<DateRangePicker
v-model="dateRange"
dual-pane
format="MMM YYYY"
:min="fyStart"
:max="fyEnd"
>
<template #prefix>
<span
class="lucide-calendar-range size-4 text-ink-gray-5"
aria-hidden="true"
/>
</template>
<template #actions="{ setRange, close }">
<Button
v-for="preset in rangePresets"
:key="preset.label"
variant="ghost"
size="sm"
class="w-full !justify-start"
:label="preset.label"
@click="
() => {
setRange(preset.range)
close()
}
"
/>
</template>
</DateRangePicker>
<TabButtons
v-model="period"
:options="[
{ label: 'Monthly', value: 'monthly' },
{ label: 'Quarterly', value: 'quarterly' },
]"
/>
<Button
variant="ghost"
label="Add filter"
icon-left="lucide-filter"
/>
</div>
<ScrollArea orientation="both" class="min-h-0 flex-1">
<List :columns="pnlColumns" :row-height="40">
<!-- Pinned to the scroll viewport's top; opaque bg so rows scroll
under it, above them via z-10. -->
<ListHeader class="sticky top-0 z-10 bg-surface-base">
<ListHeaderCell>Line item</ListHeaderCell>
<ListHeaderCell
v-for="bucket in buckets"
:key="bucket.label"
class="justify-end"
>
{{ bucket.label }}
</ListHeaderCell>
</ListHeader>
<ListRows :items="pnlRows" v-slot="{ item }">
<ListRow v-if="item.type === 'section'">
<ListCell>
<span class="text-sm font-semibold text-ink-gray-7">
{{ item.label }}
</span>
</ListCell>
</ListRow>
<ListRow v-else>
<ListCell>
<span class="truncate text-base text-ink-gray-8">{{
item.label
}}</span>
</ListCell>
<ListCell
v-for="bucket in buckets"
:key="bucket.label"
class="justify-end"
>
<span class="text-base text-ink-gray-7">
{{ currency(cellValue(item, bucket)).replace('.00', '') }}
</span>
</ListCell>
</ListRow>
</ListRows>
</List>
</ScrollArea>
</div>
<!-- Shell-only sections ---------------------------------------------->
<div
v-else
class="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center"
>
<div
class="flex size-11 items-center justify-center rounded-full bg-surface-gray-2"
>
<span
:class="activeMeta.icon"
class="size-5 text-ink-gray-5"
aria-hidden="true"
/>
</div>
<div class="mt-3 text-base font-medium text-ink-gray-8">
No {{ activeMeta.label.toLowerCase() }} yet
</div>
<p class="mt-1 max-w-xs text-p-sm text-ink-gray-5">
This section is part of the app-shell demo — the four highlighted
screens are fully built.
</p>
<Button
class="mt-4"
variant="subtle"
:label="`New ${activeMeta.label.toLowerCase()}`"
icon-left="lucide-plus"
/>
</div>
</DesktopShell>
</div>
</template>vue
<script setup>
import { computed, ref } from 'vue'
import {
AxisChart,
Avatar,
Button,
MobileNav,
MobileNavItem,
MobileShell,
PageHeaderMobile,
ScrollArea,
TabButtons,
} from 'frappe-ui'
import { List, ListCell, ListRow } from 'frappe-ui/list'
// Bottom-tab sections. The same four screens as the desktop recipe, re-laid
// for a single narrow column.
const sections = [
{ key: 'cashflow', label: 'Cashflow', icon: 'lucide-activity' },
{ key: 'expenses', label: 'Expenses', icon: 'lucide-receipt' },
{ key: 'payroll', label: 'Payroll', icon: 'lucide-users' },
{ key: 'reports', label: 'Reports', icon: 'lucide-chart-no-axes-column' },
]
const section = ref('cashflow')
const title = computed(
() => sections.find((s) => s.key === section.value)?.label ?? '',
)
const currency = (n) =>
`${n < 0 ? '-' : ''}$${Math.abs(n).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
/* -- Cashflow ------------------------------------------------------------- */
const cashflow = {
data: [
{ week: new Date('2026-03-16'), balance: 11800 },
{ week: new Date('2026-03-23'), balance: 2400 },
{ week: new Date('2026-03-30'), balance: 16400 },
{ week: new Date('2026-04-06'), balance: 9800 },
{ week: new Date('2026-04-13'), balance: 20100 },
{ week: new Date('2026-04-20'), balance: 14081 },
{ week: new Date('2026-04-27'), balance: 6200 },
{ week: new Date('2026-05-04'), balance: -3900 },
{ week: new Date('2026-05-11'), balance: -1518 },
],
title: 'Cashflow',
subtitle: 'Weekly balance',
xAxis: { key: 'week', type: 'time', timeGrain: 'week' },
yAxis: { prefix: '$' },
series: [{ name: 'balance', type: 'area' }],
}
const cashflowStats = [
{ label: 'Todays balance', value: 14081.09 },
{ label: 'Incoming 30d', value: 2011.44 },
{ label: 'Outgoing 30d', value: 12011.44 },
{ label: 'Projected', value: -1518.0 },
]
const flowTab = ref('outgoings')
const transactions = {
incomings: [
{ date: 'August 18', description: 'Invoice #1043 — Acme Co', amount: 4200 },
{ date: 'August 11', description: 'Invoice #1042 — Globex', amount: 1810 },
{ date: 'August 3', description: 'Refund — SaaS annual', amount: 240 },
],
outgoings: [
{ date: 'August 20', description: 'Lunch', amount: 104.99 },
{ date: 'August 12', description: 'Train ticket', amount: 5.23 },
{ date: 'August 8', description: 'Lunch with client', amount: 166.23 },
{ date: 'August 4', description: 'Printer', amount: 200.0 },
{ date: 'July 29', description: 'Coffee with client', amount: 6.0 },
],
}
const flowRows = computed(() => transactions[flowTab.value])
/* -- Expenses ------------------------------------------------------------- */
const expenses = [
{ date: 'August 20', description: 'Lunch', amount: 104.99 },
{ date: 'August 12', description: 'Train ticket', amount: 5.23 },
{ date: 'August 8', description: 'Lunch with client', amount: 166.23 },
{ date: 'August 4', description: 'Printer', amount: 200.0 },
{ date: 'July 29', description: 'Coffee with client', amount: 6.0 },
{ date: 'July 22', description: 'Travel', amount: 105.63 },
{ date: 'July 21', description: 'Hotel stay', amount: 350.0 },
{ date: 'July 12', description: 'Printer ink', amount: 15.0 },
{ date: 'July 10', description: 'Conference tickets', amount: 699.99 },
{ date: 'July 2', description: 'Train ticket', amount: 5.23 },
]
/* -- Payroll -------------------------------------------------------------- */
const payroll = [
{ id: 1, name: 'Stacey Bobb', tax: 300, ni: 314, net: 18540 },
{ id: 2, name: 'Derek Forbes', tax: 300, ni: 314, net: 19500 },
{ id: 3, name: 'Garth Leemow', tax: 200, ni: 314, net: 18540 },
{ id: 4, name: 'Ilyssa Bodah', tax: 400, ni: 314, net: 12000 },
{ id: 5, name: 'Bernard Timm', tax: 500, ni: 314, net: 28560 },
{ id: 6, name: 'Rabbi Ferouz', tax: 300, ni: 314, net: 12110 },
{ id: 7, name: 'Sam Ruprecht', tax: 150, ni: 314, net: 13880 },
{ id: 8, name: 'Daren Crabb', tax: 180, ni: 314, net: 110540 },
]
const selectMode = ref(false)
const selection = ref([])
function toggleSelectMode() {
selectMode.value = !selectMode.value
if (!selectMode.value) selection.value = []
}
/* -- Profit & Loss -------------------------------------------------------- */
const months = ['Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov']
const pnlRows = [
{ label: 'Turnover', values: [328, 219, 475, 450, 782, 105, 275, 739] },
{ label: 'Sales', values: [6513, 1033, 1892, 6410, 8017, 2109, 8006, 2900] },
{
label: 'Accountancy Fees',
values: [1570, 750, 575, 2450, 800, 1840, 1050, 1050],
},
{
label: 'Computer Software',
values: [1200, 1050, 1050, 1950, 2400, 2450, 1250, 550],
},
{
label: 'Office Equipment',
values: [2400, 1840, 1050, 3050, 2450, 800, 1050, 575],
},
{
label: 'Stationary',
values: [1950, 990, 1250, 1050, 1200, 1050, 1570, 550],
},
{
label: 'Accomodation',
values: [990, 550, 2450, 1570, 750, 1050, 1050, 1250],
},
{ label: 'Meals', values: [800, 1200, 1840, 1250, 3095, 575, 2400, 3050] },
]
const pnlTotals = computed(() =>
months.map((_, col) =>
pnlRows.reduce((sum, row) => sum + row.values[col], 0),
),
)
const compactMoney = (n) => (n >= 1000 ? `$${(n / 1000).toFixed(1)}k` : `$${n}`)
const pnlColumns = ['8rem', ...months.map(() => '4.5rem')]
</script>
<template>
<MobileShell>
<PageHeaderMobile :title="title">
<template #left>
<div
class="flex size-8 items-center justify-center rounded bg-surface-gray-7 text-ink-white"
>
<span class="lucide-landmark size-4" aria-hidden="true" />
</div>
</template>
<template #right>
<Button
v-if="section === 'payroll'"
variant="ghost"
:icon="selectMode ? 'lucide-check' : 'lucide-list-checks'"
:label="selectMode ? 'Done' : 'Select'"
@click="toggleSelectMode"
/>
<Button
v-else-if="section === 'reports'"
variant="ghost"
icon="lucide-git-compare"
label="Compare"
/>
<Button v-else variant="ghost" icon="lucide-plus" label="Add" />
</template>
</PageHeaderMobile>
<!-- Cashflow ------------------------------------------------------------>
<div v-if="section === 'cashflow'" class="space-y-4 px-4 pb-6 pt-3">
<div class="h-52 overflow-hidden rounded-lg border bg-surface-base p-3">
<AxisChart :config="cashflow" />
</div>
<div class="grid grid-cols-2 gap-3">
<div
v-for="stat in cashflowStats"
:key="stat.label"
class="rounded-lg border bg-surface-base p-3"
>
<div class="text-sm text-ink-gray-6">{{ stat.label }}</div>
<div
class="mt-1 text-xl font-semibold"
:class="stat.value < 0 ? 'text-ink-red-6' : 'text-ink-gray-9'"
>
{{ currency(stat.value) }}
</div>
</div>
</div>
<TabButtons
v-model="flowTab"
class="w-full"
:options="[
{ label: 'Incomings', value: 'incomings' },
{ label: 'Outgoings', value: 'outgoings' },
]"
/>
<List class="list-row-px-3" :columns="['minmax(0,1fr)', 'auto']">
<ListRow
v-for="item in flowRows"
:key="item.description"
class="h-14"
@click="() => {}"
>
<ListCell>
<div class="min-w-0">
<div class="truncate text-lg text-ink-gray-8">
{{ item.description }}
</div>
<div class="mt-0.5 text-md text-ink-gray-5">{{ item.date }}</div>
</div>
</ListCell>
<ListCell class="justify-end">
<span
class="text-lg font-medium tabular-nums"
:class="
flowTab === 'incomings' ? 'text-ink-green-6' : 'text-ink-gray-8'
"
>
{{ flowTab === 'incomings' ? '+' : '-'
}}{{ currency(item.amount).replace('-', '') }}
</span>
</ListCell>
</ListRow>
</List>
</div>
<!-- Expenses ------------------------------------------------------------>
<div v-else-if="section === 'expenses'" class="pb-6 pt-1">
<List class="list-row-px-4" :columns="['minmax(0,1fr)', 'auto']">
<ListRow
v-for="item in expenses"
:key="item.description + item.date"
class="h-14"
@click="() => {}"
>
<ListCell>
<div class="min-w-0">
<div class="truncate text-lg text-ink-gray-8">
{{ item.description }}
</div>
<div class="mt-0.5 text-md text-ink-gray-5">{{ item.date }}</div>
</div>
</ListCell>
<ListCell class="justify-end">
<span class="text-lg font-medium tabular-nums text-ink-gray-8">
{{ currency(item.amount) }}
</span>
</ListCell>
</ListRow>
</List>
</div>
<!-- Payroll ------------------------------------------------------------->
<div v-else-if="section === 'payroll'" class="pb-6 pt-1">
<List
class="list-row-px-4"
:columns="['minmax(0,1fr)', 'auto']"
:selectable="selectMode"
v-model:selection="selection"
>
<ListRow
v-for="item in payroll"
:key="item.id"
:value="String(item.id)"
class="h-16"
@click="() => {}"
>
<ListCell>
<Avatar :label="item.name" size="lg" />
<div class="ml-3 min-w-0">
<div class="truncate text-lg text-ink-gray-8">
{{ item.name }}
</div>
<div class="mt-0.5 text-md text-ink-gray-5">
Tax {{ currency(item.tax) }} · NI {{ currency(item.ni) }}
</div>
</div>
</ListCell>
<ListCell class="justify-end">
<div class="text-right">
<div class="text-lg font-medium tabular-nums text-ink-gray-8">
{{ currency(item.net) }}
</div>
<div class="mt-0.5 text-md text-ink-gray-5">per annum</div>
</div>
</ListCell>
</ListRow>
</List>
</div>
<!-- Reports (Profit & Loss) --------------------------------------------->
<div v-else class="px-4 pb-6 pt-3">
<ScrollArea
orientation="horizontal"
class="rounded-lg border bg-surface-base"
viewport-class="p-3"
>
<List class="w-max" :columns="pnlColumns" :row-height="40">
<template v-for="row in pnlRows" :key="row.label">
<ListRow>
<ListCell>
<span class="truncate text-base text-ink-gray-8">{{
row.label
}}</span>
</ListCell>
<ListCell v-for="(value, i) in row.values" :key="i">
<span class="text-base tabular-nums text-ink-gray-7">
{{ compactMoney(value) }}
</span>
</ListCell>
</ListRow>
</template>
<ListRow class="border-t">
<ListCell />
<ListCell v-for="(total, i) in pnlTotals" :key="i">
<span
class="text-base font-semibold tabular-nums text-ink-gray-9"
>
{{ compactMoney(total) }}
</span>
</ListCell>
</ListRow>
</List>
</ScrollArea>
</div>
<template #nav>
<MobileNav>
<MobileNavItem
v-for="s in sections"
:key="s.key"
:label="s.label"
:icon="s.icon"
:active="section === s.key"
@click="section = s.key"
/>
</MobileNav>
</template>
</MobileShell>
</template>