spa hardening, offline logging, rate limit fixes

server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
This commit is contained in:
2026-08-28 14:59:21 +02:00
parent 23d052278a
commit bf148902ab
395 changed files with 13972 additions and 10635 deletions

View File

@@ -6,24 +6,30 @@
/* ── Aero color overrides ─────────────────────────────────────── */
:root {
/*
* One palette, under both selectors. shadcn ships a `.dark` block of its own;
* naming it here means it never wins, and there is only one place a colour is
* written down. This theme does not have a second mode to switch to.
*/
:root,
.dark {
--background: transparent;
--foreground: oklch(0.985 0 0);
--card: rgba(255, 255, 255, 0.08);
--card: rgba(18, 22, 34, 0.55);
--card-foreground: oklch(0.985 0 0);
--popover: rgba(20, 20, 30, 0.85);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.852 0.199 91.936);
--primary-foreground: #fff;
--secondary: rgba(255, 255, 255, 0.08);
--secondary: rgba(255, 255, 255, 0.12);
--secondary-foreground: oklch(0.985 0 0);
--muted: rgba(255, 255, 255, 0.06);
--muted-foreground: rgba(255, 255, 255, 0.6);
--muted: rgba(255, 255, 255, 0.1);
--muted-foreground: rgba(255, 255, 255, 0.85);
--accent: rgba(255, 255, 255, 0.1);
--accent-foreground: oklch(0.985 0 0);
--destructive: rgba(200, 60, 60, 0.65);
--border: rgba(255, 255, 255, 0.15);
--input: rgba(255, 255, 255, 0.1);
--destructive: rgba(226, 88, 88, 0.85);
--border: rgba(255, 255, 255, 0.22);
--input: rgba(255, 255, 255, 0.14);
--ring: oklch(0.852 0.199 91.936 / 0.4);
--chart-1: oklch(0.852 0.199 91.936);
--chart-2: oklch(0.89 0.13 91.936);
@@ -43,46 +49,24 @@
--aero-primary-mid: oklch(0.89 0.13 91.936);
--aero-primary-light: oklch(0.93 0.07 91.936);
--aero-primary-glow: oklch(0.852 0.199 91.936 / 0.3);
--aero-glass-bg: rgba(255, 255, 255, 0.12);
--aero-glass-border: rgba(255, 255, 255, 0.2);
/*
* Dark-tinted glass. White text over white glass measured ~1.4:1 against the
* bright parts of the photo behind it; a dark ground puts it above 7:1
* without touching the background or losing the blur.
*/
--aero-glass-bg: rgba(18, 22, 34, 0.55);
--aero-glass-border: rgba(255, 255, 255, 0.22);
--aero-glass-shadow: 0 8px 32px oklch(0.852 0.199 91.936 / 0.1);
--aero-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.4);
--aero-blur: 12px;
}
/* Force dark — override any .dark block from shadcn defaults */
.dark {
--background: transparent;
--foreground: oklch(0.985 0 0);
--card: rgba(255, 255, 255, 0.08);
--card-foreground: oklch(0.985 0 0);
--popover: rgba(20, 20, 30, 0.85);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.852 0.199 91.936);
--primary-foreground: #fff;
--secondary: rgba(255, 255, 255, 0.08);
--secondary-foreground: oklch(0.985 0 0);
--muted: rgba(255, 255, 255, 0.06);
--muted-foreground: rgba(255, 255, 255, 0.6);
--accent: rgba(255, 255, 255, 0.1);
--accent-foreground: oklch(0.985 0 0);
--destructive: rgba(200, 60, 60, 0.65);
--border: rgba(255, 255, 255, 0.15);
--input: rgba(255, 255, 255, 0.1);
--ring: oklch(0.852 0.199 91.936 / 0.4);
--chart-1: oklch(0.852 0.199 91.936);
--chart-2: oklch(0.89 0.13 91.936);
--chart-3: oklch(0.93 0.07 91.936);
--chart-4: rgba(255, 255, 255, 0.4);
--chart-5: rgba(255, 255, 255, 0.2);
--sidebar: rgba(0, 0, 0, 0.4);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.852 0.199 91.936);
--sidebar-primary-foreground: #fff;
--sidebar-accent: rgba(255, 255, 255, 0.1);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: rgba(255, 255, 255, 0.1);
--sidebar-ring: oklch(0.852 0.199 91.936 / 0.4);
/*
* The reading column gets a little more veil than the rest of the page, so
* headings and captions that sit outside a card stay legible over the
* brightest parts of the photo. On a wide screen the margins keep the
* lighter scrim, and the background stays as bright as it was.
*/
--aero-column-veil: rgba(0, 0, 0, 0.35);
}
/* ── Background treatment ─────────────────────────────────────── */
@@ -95,7 +79,7 @@ body::before {
content: "";
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.4);
z-index: 0;
pointer-events: none;
}
@@ -107,69 +91,8 @@ body > #root {
/* ── Utility classes ──────────────────────────────────────────── */
@utility glass {
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
background: var(--aero-glass-bg);
border: 1px solid var(--aero-glass-border);
}
@utility glass-card {
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
background: var(--aero-glass-bg);
border: 1px solid var(--aero-glass-border);
box-shadow: var(--aero-glass-shadow), var(--aero-glass-inset);
position: relative;
overflow: hidden;
}
@utility glass-heavy {
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.1);
}
@utility aero-glow {
text-shadow:
0 0 8px var(--aero-primary-glow),
0 0 2px var(--aero-primary);
}
@utility aero-pill {
border-radius: 999px;
border: 1px solid var(--aero-glass-border);
background: transparent;
}
/* ── Glass card gradient overlay ──────────────────────────────── */
.glass-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), transparent);
border-radius: inherit;
pointer-events: none;
z-index: 1;
}
/* ── Hover lift (pointer devices only) ────────────────────────── */
@media (hover: hover) {
.aero-lift {
transition: transform 0.2s, box-shadow 0.2s;
}
.aero-lift:hover {
transform: translateY(-2px);
box-shadow:
0 12px 40px var(--aero-primary-glow),
var(--aero-glass-inset);
}
@utility app-column {
background: var(--aero-column-veil);
}
/* ── shadcn data-slot overrides ───────────────────────────────── */
@@ -199,21 +122,8 @@ body > #root {
--tw-ring-shadow: none;
}
[data-slot="sheet-overlay"] {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
[data-slot="sheet-content"] {
background: rgba(20, 20, 30, 0.85);
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
border-color: rgba(255, 255, 255, 0.15);
}
[data-slot="input"] {
background: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.12);
border-color: var(--aero-glass-border);
}
@@ -246,7 +156,11 @@ body > #root {
left: 0;
right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.25), transparent);
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.25),
transparent
);
border-radius: inherit;
pointer-events: none;
}
@@ -262,7 +176,7 @@ body > #root {
/* Destructive button — glassmorphic red */
[data-slot="button"][data-variant="destructive"] {
background: rgba(200, 60, 60, 0.65);
background: rgba(190, 55, 55, 0.8);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid rgba(220, 80, 80, 0.3);
@@ -286,72 +200,3 @@ body > #root {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* Sonner toast */
[data-sonner-toaster] [data-sonner-toast] {
background: rgba(20, 20, 30, 0.85);
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
border-color: rgba(255, 255, 255, 0.15);
color: oklch(0.985 0 0);
}
/* Chart label readability */
[data-slot="chart"] .recharts-cartesian-axis-tick text {
fill: rgba(255, 255, 255, 0.85) !important;
}
/* Tooltip — default shadcn uses bg-foreground which is white in this theme */
[data-slot="tooltip-content"] {
background: var(--popover);
color: var(--popover-foreground);
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
border: 1px solid rgba(255, 255, 255, 0.15);
}
[data-slot="tooltip-content"] .lucide,
[data-slot="tooltip-content"] [data-slot="tooltip-arrow"] {
color: var(--popover-foreground);
}
/* Context menu */
[data-slot="context-menu-content"] {
background: var(--popover);
backdrop-filter: blur(var(--aero-blur));
-webkit-backdrop-filter: blur(var(--aero-blur));
border-color: rgba(255, 255, 255, 0.15);
}
/* Star glow for filled amber stars */
.aero-star-filled {
filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary));
}
/* ── Keyframe animations ────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
@keyframes aero-page-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.aero-page-in {
animation: aero-page-in 0.25s ease-out both;
}
@keyframes aero-star-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.aero-star-pop {
animation: aero-star-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
.aero-poster-hover {
transition: transform 0.2s ease-out;
}
.aero-poster-hover:hover {
transform: scale(1.04);
}
}

View File

@@ -1,9 +1,11 @@
import axios, { AxiosError } from "axios"
import type { InternalAxiosRequestConfig } from "axios"
import type { z } from "zod/v4"
import {
ActivityResponse,
ApiErrorResponse,
AuthTokenResponse,
ErrorResponse,
RefreshedResponse,
SignedInResponse,
BulkActionResponse,
CalendarDayResponse,
EntryResponse,
@@ -20,13 +22,23 @@ import {
CycleViewResponse,
PreferencesResponse,
ApiTokenResponse,
EntryPageResponse,
MintedApiTokenResponse,
ServerInfoResponse,
CorrelationRowResponse,
DimensionPayload,
} from "./schema"
import { serverUrl } from "./server-url"
import {
accessToken,
forgetSession,
refreshToken as storedRefreshToken,
rememberSession,
} from "./session"
import type {
ChangePasswordRequest,
CreateActivityRequest,
CreateEntriesRequest,
CreateEntryRequest,
DateSpanParams,
MetricPayload,
@@ -35,6 +47,7 @@ import type {
DateRangeParams,
ListEntriesParams,
LoginRequest,
MintApiTokenRequest,
RegisterRequest,
RenameActivityRequest,
ReplaceActivityRequest,
@@ -45,21 +58,30 @@ import type {
} from "./schema"
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? "",
baseURL: serverUrl(),
})
api.interceptors.request.use((config) => {
const token = localStorage.getItem("accessToken")
const token = accessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
/**
* A request that has already been retried once after a refresh. Without this
* mark, a token that refreshes cleanly but still fails authorisation would
* refresh and retry forever.
*/
type RetriableConfig = InternalAxiosRequestConfig & {
retriedAfterRefresh?: true
}
let refreshPromise: Promise<boolean> | null = null
async function tryRefreshToken(): Promise<boolean> {
const refreshToken = localStorage.getItem("refreshToken")
const refreshToken = storedRefreshToken()
if (!refreshToken) return false
try {
@@ -67,18 +89,25 @@ async function tryRefreshToken(): Promise<boolean> {
`${api.defaults.baseURL}/api/v1/auth/refresh`,
{ refreshToken }
)
const parsed = AuthTokenResponse.parse(data)
localStorage.setItem("accessToken", parsed.accessToken)
localStorage.setItem("refreshToken", parsed.refreshToken)
rememberSession(RefreshedResponse.parse(data))
return true
} catch {
return false
}
}
function abandonSession(): void {
forgetSession()
window.location.href = "/login"
}
api.interceptors.response.use(undefined, async (error: AxiosError) => {
const original = error.config
const original = error.config as RetriableConfig | undefined
if (error.response?.status !== 401 || !original) throw error
if (original.retriedAfterRefresh) {
abandonSession()
throw error
}
if (!refreshPromise) {
refreshPromise = tryRefreshToken().finally(() => {
@@ -88,12 +117,11 @@ api.interceptors.response.use(undefined, async (error: AxiosError) => {
const refreshed = await refreshPromise
if (!refreshed) {
localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken")
window.location.href = "/login"
return Promise.reject(error)
abandonSession()
throw error
}
original.retriedAfterRefresh = true
return api(original)
})
@@ -110,7 +138,7 @@ class ApiError extends Error {
function handleError(err: unknown): never {
if (err instanceof AxiosError && err.response) {
const parsed = ApiErrorResponse.safeParse(err.response.data)
const parsed = ErrorResponse.safeParse(err.response.data)
throw new ApiError(
err.response.status,
parsed.success ? parsed.data.error.code : "UNKNOWN",
@@ -168,13 +196,13 @@ async function uploadForm<T>(
export const auth = {
login: (body: LoginRequest) =>
request("post", "/api/v1/auth/login", AuthTokenResponse, body),
request("post", "/api/v1/auth/login", SignedInResponse, body),
register: (body: RegisterRequest) =>
request("post", "/api/v1/users/register", UserResponse, body),
refresh: (refreshToken: string) =>
request("post", "/api/v1/auth/refresh", AuthTokenResponse, {
request("post", "/api/v1/auth/refresh", RefreshedResponse, {
refreshToken,
}),
@@ -222,11 +250,15 @@ export const cycle = {
// ── API tokens ──────────────────────────────────────────────────
export const server = {
info: () => request("get", "/api/v1/server", ServerInfoResponse),
}
export const tokens = {
list: () => request("get", "/api/v1/tokens", ApiTokenResponse.array()),
mint: (name: string) =>
request("post", "/api/v1/tokens", MintedApiTokenResponse, { name }),
mint: (body: MintApiTokenRequest) =>
request("post", "/api/v1/tokens", MintedApiTokenResponse, body),
revoke: (id: string) => requestVoid("delete", `/api/v1/tokens/${id}`),
}
@@ -267,7 +299,10 @@ export const metrics = {
export const entries = {
list: (params?: ListEntriesParams) =>
request("get", "/api/v1/entries", EntryResponse.array(), undefined, params),
request("get", "/api/v1/entries", EntryPageResponse, undefined, params),
createMany: (body: CreateEntriesRequest) =>
request("post", "/api/v1/entries/bulk", EntryResponse.array(), body),
get: (id: string) => request("get", `/api/v1/entries/${id}`, EntryResponse),
@@ -313,20 +348,6 @@ export const entries = {
undefined,
params
),
filterByMood: (mood: number) =>
request(
"get",
`/api/v1/entries/filter/mood/${mood}`,
EntryResponse.array()
),
filterByActivity: (activityId: string) =>
request(
"get",
`/api/v1/entries/filter/activity/${activityId}`,
EntryResponse.array()
),
}
// ── Activities ──────────────────────────────────────────────────

View File

@@ -81,18 +81,20 @@ export const CalendarDayResponse = z.object({
})
export type CalendarDayResponse = z.infer<typeof CalendarDayResponse>
export const MoodFrequency = z.object({
export const MoodFrequencyResponse = z.object({
mood: Mood,
moodLabel: z.string(),
count: z.number(),
})
export type MoodFrequency = z.infer<typeof MoodFrequency>
export type MoodFrequencyResponse = z.infer<typeof MoodFrequencyResponse>
export const MoodStatsResponse = z.object({
average: z.number().nullable(),
frequency: z.array(MoodFrequency),
frequency: z.array(MoodFrequencyResponse),
currentStreak: z.number(),
totalEntries: z.number(),
firstLoggedOn: z.string().nullable(),
lastLoggedOn: z.string().nullable(),
})
export type MoodStatsResponse = z.infer<typeof MoodStatsResponse>
@@ -143,21 +145,25 @@ export const MediaIdResponse = z.object({
})
export type MediaIdResponse = z.infer<typeof MediaIdResponse>
export const AuthTokenResponse = z.object({
export const RefreshedResponse = z.object({
accessToken: z.string(),
refreshToken: z.string(),
expiresAt: datetime,
user: UserResponse.optional(),
})
export type AuthTokenResponse = z.infer<typeof AuthTokenResponse>
export type RefreshedResponse = z.infer<typeof RefreshedResponse>
export const ApiErrorResponse = z.object({
export const SignedInResponse = RefreshedResponse.extend({
user: UserResponse,
})
export type SignedInResponse = z.infer<typeof SignedInResponse>
export const ErrorResponse = z.object({
error: z.object({
code: z.string(),
message: z.string(),
}),
})
export type ApiErrorResponse = z.infer<typeof ApiErrorResponse>
export type ErrorResponse = z.infer<typeof ErrorResponse>
export const VapidKeyResponse = z.object({
publicKey: z.string(),
@@ -249,6 +255,9 @@ export type ChangePasswordRequest = z.infer<typeof ChangePasswordRequest>
export const ListEntriesParams = z.object({
from: z.string().optional(),
to: z.string().optional(),
mood: z.number().int().optional(),
activity: z.string().optional(),
updatedSince: z.string().optional(),
limit: z.number().int().optional(),
offset: z.number().int().optional(),
})
@@ -337,10 +346,18 @@ export const CorrelationRowResponse = z.object({
})
export type CorrelationRowResponse = z.infer<typeof CorrelationRowResponse>
export const TokenScope = z.enum([
"readJournal",
"writeJournal",
"writeMetrics",
"readProfile",
])
export type TokenScope = z.infer<typeof TokenScope>
export const ApiTokenResponse = z.object({
id: uuid,
name: z.string(),
scope: z.string(),
scopes: z.array(TokenScope),
createdAt: datetime,
lastUsedAt: datetime.nullable(),
})
@@ -354,6 +371,7 @@ export type MintedApiTokenResponse = z.infer<typeof MintedApiTokenResponse>
export const MintApiTokenRequest = z.object({
name: z.string(),
scopes: z.array(TokenScope),
})
export type MintApiTokenRequest = z.infer<typeof MintApiTokenRequest>
@@ -395,6 +413,7 @@ export const RestoreOutcomeResponse = z.object({
activities: z.int(),
reminders: z.int(),
media: z.int(),
skipped: z.int(),
unreadable: z.array(z.string()),
})
export type RestoreOutcomeResponse = z.infer<typeof RestoreOutcomeResponse>
@@ -405,3 +424,51 @@ export const SubsonicCredential = z.object({
password: z.string(),
})
export type SubsonicCredential = z.infer<typeof SubsonicCredential>
export const CreateEntriesRequest = z.object({
entries: z.array(CreateEntryRequest),
})
export type CreateEntriesRequest = z.infer<typeof CreateEntriesRequest>
export const EntryPageResponse = z.object({
items: z.array(EntryResponse),
total: z.int(),
limit: z.int(),
offset: z.int(),
hasMore: z.boolean(),
})
export type EntryPageResponse = z.infer<typeof EntryPageResponse>
export const ServerLimitsResponse = z.object({
maxBodySize: z.int(),
maxContentLength: z.int(),
maxPhotos: z.int(),
maxVoiceMemos: z.int(),
maxActivitiesPerEntry: z.int(),
maxEntriesPerPage: z.int(),
maxImportDays: z.int(),
})
export type ServerLimitsResponse = z.infer<typeof ServerLimitsResponse>
export const MetricKindResponse = z.object({
name: z.string(),
unit: z.string(),
minimum: z.int(),
maximum: z.int(),
})
export type MetricKindResponse = z.infer<typeof MetricKindResponse>
export const ServerInfoResponse = z.object({
version: z.string(),
registrationOpen: z.boolean(),
pushEnabled: z.boolean(),
weatherEnabled: z.boolean(),
nowPlayingProvider: z.string().nullable(),
accessTokenTtlSeconds: z.int(),
limits: ServerLimitsResponse,
tokenScopes: z.array(z.object({ name: TokenScope, describes: z.string() })),
metricKinds: z.array(MetricKindResponse),
moods: z.array(z.object({ value: z.int(), label: z.string() })),
weatherConditions: z.array(z.string()),
})
export type ServerInfoResponse = z.infer<typeof ServerInfoResponse>

56
spa/src/api/server-url.ts Normal file
View File

@@ -0,0 +1,56 @@
const CHOSEN_SERVER = "serverUrl"
const BUILT_IN = import.meta.env.VITE_API_URL ?? ""
export function serverUrl(): string {
try {
return localStorage.getItem(CHOSEN_SERVER) ?? BUILT_IN
} catch {
return BUILT_IN
}
}
export function isServerChosen(): boolean {
try {
return localStorage.getItem(CHOSEN_SERVER) !== null
} catch {
return false
}
}
export function tidyServerUrl(typed: string): string {
return typed.trim().replace(/\/+$/, "")
}
export function looksLikeAServer(typed: string): boolean {
const tidied = tidyServerUrl(typed)
if (!tidied) return false
try {
const parsed = new URL(tidied)
return parsed.protocol === "http:" || parsed.protocol === "https:"
} catch {
return false
}
}
export async function reachable(typed: string): Promise<boolean> {
try {
const response = await fetch(`${tidyServerUrl(typed)}/api/v1/server`)
return response.ok
} catch {
return false
}
}
export function rememberServer(typed: string) {
localStorage.setItem(CHOSEN_SERVER, tidyServerUrl(typed))
window.location.reload()
}
export function forgetServer() {
localStorage.removeItem(CHOSEN_SERVER)
window.location.reload()
}

45
spa/src/api/session.ts Normal file
View File

@@ -0,0 +1,45 @@
const ACCESS_TOKEN_KEY = "accessToken"
const REFRESH_TOKEN_KEY = "refreshToken"
export interface SessionTokens {
accessToken: string
refreshToken: string
}
function read(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
export function accessToken(): string | null {
return read(ACCESS_TOKEN_KEY)
}
export function refreshToken(): string | null {
return read(REFRESH_TOKEN_KEY)
}
export function hasSession(): boolean {
return accessToken() !== null
}
export function rememberSession(tokens: SessionTokens): void {
try {
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
} catch {
// A browser refusing storage leaves the session in memory only.
}
}
export function forgetSession(): void {
try {
localStorage.removeItem(ACCESS_TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
} catch {
// Nothing to forget if storage is unavailable.
}
}

View File

@@ -1,5 +1,5 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { groupByCategory } from "@/lib/activity-groups"
import type { ActivityResponse } from "@/api/schema"
interface ActivityPickerProps {
@@ -13,52 +13,39 @@ export function ActivityPicker({
selected,
onToggle,
}: ActivityPickerProps) {
const grouped = groupByCategory(activities)
return (
<div className="flex flex-col gap-3">
{grouped.map(({ category, items }) => (
<div key={category}>
<p className="mb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
{groupByCategory(activities).map(({ category, items }) => (
<fieldset key={category}>
<legend className="mb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
{category}
</p>
</legend>
<div className="flex flex-wrap gap-2">
{items.map((activity) => (
<Badge
key={activity.id}
variant={selected.includes(activity.id) ? "default" : "outline"}
className={cn(
"cursor-pointer transition-colors",
selected.includes(activity.id) && "bg-primary"
)}
onClick={() => onToggle(activity.id)}
>
{activity.name}
</Badge>
))}
{items.map((activity) => {
const isSelected = selected.includes(activity.id)
return (
<button
key={activity.id}
type="button"
role="checkbox"
aria-checked={isSelected}
onClick={() => onToggle(activity.id)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground hover:text-foreground"
)}
>
{activity.name}
</button>
)
})}
</div>
</div>
</fieldset>
))}
</div>
)
}
function groupByCategory(
activities: ActivityResponse[]
): Array<{ category: string; items: ActivityResponse[] }> {
const map = new Map<string, ActivityResponse[]>()
for (const a of activities) {
const cat = a.category ?? "Other"
if (!map.has(cat)) map.set(cat, [])
map.get(cat)!.push(a)
}
return Array.from(map.entries())
.sort(([a], [b]) => {
if (a === "Other") return 1
if (b === "Other") return -1
return a.localeCompare(b)
})
.map(([category, items]) => ({ category, items }))
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
interface AuthCardProps {
title: string
subtitle?: string
children: ReactNode
}
export function AuthCard({ title, subtitle, children }: AuthCardProps) {
return (
<main className="flex min-h-svh items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<h1 className="text-2xl font-bold">{title}</h1>
{subtitle && (
<p className="text-sm text-muted-foreground">{subtitle}</p>
)}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</main>
)
}

View File

@@ -0,0 +1,11 @@
import { messageFor } from "@/lib/api-message"
export function FormError({ error }: { error: unknown }) {
if (!error) return null
return (
<p role="alert" className="text-sm text-destructive">
{messageFor(error)}
</p>
)
}

View File

@@ -0,0 +1,60 @@
import { cn } from "@/lib/utils"
/**
* The server accepts any password, so this is guidance rather than a rule it
* will enforce — but a journal is worth a password that is not "abc".
*/
const SHORTEST_SENSIBLE = 10
interface Requirement {
describes: string
met: (password: string) => boolean
}
const REQUIREMENTS: Requirement[] = [
{
describes: `at least ${SHORTEST_SENSIBLE} characters`,
met: (password) => password.length >= SHORTEST_SENSIBLE,
},
{
describes: "a letter and a digit, or a long passphrase",
met: (password) =>
(/\p{L}/u.test(password) && /\d/.test(password)) || password.length >= 20,
},
]
export function isStrongEnough(password: string): boolean {
return REQUIREMENTS.every((requirement) => requirement.met(password))
}
export function PasswordStrength({ password }: { password: string }) {
if (!password) {
return (
<p id="password-strength" className="text-xs text-muted-foreground">
Use {SHORTEST_SENSIBLE} characters or more.
</p>
)
}
return (
<ul id="password-strength" className="flex list-none flex-col gap-0.5 p-0">
{REQUIREMENTS.map((requirement) => {
const met = requirement.met(password)
return (
<li
key={requirement.describes}
className={cn(
"text-xs",
met ? "text-muted-foreground" : "text-destructive"
)}
>
<span aria-hidden="true">{met ? "✓" : "•"}</span>{" "}
{requirement.describes}
<span className="sr-only">{met ? " — met" : " — not yet met"}</span>
</li>
)
})}
</ul>
)
}

View File

@@ -0,0 +1,64 @@
import { Link } from "@tanstack/react-router"
import { ChevronRight } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { METRIC_FIELDS } from "@/components/metric/metric-fields"
import { byKind, useDailyMetrics, useTracksMetrics } from "@/hooks/use-metrics"
import { today } from "@/lib/day"
const MOST_SHOWN = 3
/** Today's readings, for accounts that record them. */
export function MetricsCard() {
const day = today()
const tracks = useTracksMetrics()
const { data } = useDailyMetrics(day, day)
const recorded = byKind(data)
if (!tracks) return null
const shown = METRIC_FIELDS.filter((field) => recorded.has(field.kind)).slice(
0,
MOST_SHOWN
)
return (
<Link to="/metrics" aria-label="Daily metrics">
<Card>
<CardContent className="flex items-center justify-between gap-3 py-4">
{shown.length ? (
<div className="flex flex-1 items-center justify-around">
{shown.map((field) => {
const Icon = field.icon
return (
<div
key={field.kind}
className="flex flex-col items-center gap-1"
>
<Icon
className="h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<span className="text-sm font-semibold tabular-nums">
{recorded.get(field.kind)?.value.toLocaleString()}
</span>
<span className="text-[10px] text-muted-foreground">
{field.label}
</span>
</div>
)
})}
</div>
) : (
<span className="text-sm text-muted-foreground">
No readings today
</span>
)}
<ChevronRight
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</CardContent>
</Card>
</Link>
)
}

View File

@@ -0,0 +1,84 @@
import { Link } from "@tanstack/react-router"
import { Bell, ListChecks } from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { useActivities } from "@/hooks/use-activities"
import { useReminders } from "@/hooks/use-reminders"
import { usePushNotifications } from "@/hooks/use-push"
/** Past this many entries the account is established, and nudging is nagging. */
const STILL_NEW_BELOW = 5
interface SetupStep {
to: "/activities" | "/reminders"
icon: LucideIcon
label: string
describes: string
}
/**
* The two things that are worth setting up early and are otherwise only
* reachable through Settings. Disappears on its own once they are done, or
* once the account is no longer new.
*/
export function SetupCard({ totalEntries }: { totalEntries: number }) {
const { data: activities } = useActivities()
const { data: reminders } = useReminders()
const push = usePushNotifications()
if (totalEntries >= STILL_NEW_BELOW) return null
const steps: SetupStep[] = []
if (activities?.length === 0) {
steps.push({
to: "/activities",
icon: ListChecks,
label: "Add your activities",
describes: "What you want to tag entries with",
})
}
if (push.isSupported && push.hasVapidKey && reminders?.length === 0) {
steps.push({
to: "/reminders",
icon: Bell,
label: "Set a reminder",
describes: "A nudge at the time you want to check in",
})
}
if (steps.length === 0) return null
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Finish setting up</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1 p-0 pb-2">
{steps.map((step) => {
const Icon = step.icon
return (
<Link
key={step.to}
to={step.to}
className="flex items-center gap-3 px-6 py-3 transition-colors hover:bg-muted/50"
>
<Icon
className="h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="flex flex-col">
<span className="text-sm">{step.label}</span>
<span className="text-xs text-muted-foreground">
{step.describes}
</span>
</span>
</Link>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,39 @@
import { Link } from "@tanstack/react-router"
import { isToday } from "date-fns"
import { buttonLook } from "@/lib/button-style"
import { Card, CardContent } from "@/components/ui/card"
import { MoodBadge } from "@/components/mood/mood-badge"
import type { EntryResponse } from "@/api/schema"
/**
* How today has gone, or an invitation to say. The latest entry is only
* "today's mood" if it was logged today — showing a mood from last week under
* that heading was telling the reader something untrue.
*/
export function TodayCard({ latest }: { latest?: EntryResponse }) {
const loggedToday = latest && isToday(new Date(latest.loggedAt))
if (!loggedToday) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-6">
<p className="text-sm text-muted-foreground">
Nothing logged today yet
</p>
<Link to="/add" className={buttonLook()}>
How are you feeling?
</Link>
</CardContent>
</Card>
)
}
return (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-6">
<p className="text-sm text-muted-foreground">Today</p>
<MoodBadge value={latest.mood} showLabel className="text-2xl" />
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,11 @@
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
export default function Devtools() {
return (
<>
<ReactQueryDevtools buttonPosition="bottom-left" />
<TanStackRouterDevtools position="bottom-right" />
</>
)
}

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { groupNamesById } from "@/lib/activity-groups"
import type { ActivityResponse } from "@/api/schema"
interface EntryActivitiesProps {
activityIds: string[]
catalogue: ActivityResponse[]
}
export function EntryActivities({
activityIds,
catalogue,
}: EntryActivitiesProps) {
if (!activityIds.length) return null
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activities</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{groupNamesById(activityIds, catalogue).map(({ category, items }) => (
<div key={category}>
<p className="mb-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
{category}
</p>
<div className="flex flex-wrap gap-2">
{items.map((activity) => (
<Badge key={activity.id} variant="outline" className="text-xs">
{activity.name}
</Badge>
))}
</div>
</div>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,43 @@
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PhotoLightbox } from "@/components/media/photo-lightbox"
import { media } from "@/api/client"
export function EntryPhotos({ photoIds }: { photoIds: string[] }) {
const [openAt, setOpenAt] = useState<number | null>(null)
const urls = photoIds.map(media.photoUrl)
if (!photoIds.length) return null
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Photos</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2">
{urls.map((url, index) => (
<button
key={photoIds[index]}
type="button"
aria-label={`Open photo ${index + 1} of ${urls.length}`}
onClick={() => setOpenAt(index)}
className="aspect-square overflow-hidden rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<img
src={url}
alt={`Photo ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
))}
</CardContent>
<PhotoLightbox
urls={urls}
openAt={openAt}
onClose={() => setOpenAt(null)}
/>
</Card>
)
}

View File

@@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button"
/**
* Says plainly that what is on screen was picked up from an abandoned attempt,
* rather than letting it look like a fresh form that mysteriously has content.
*/
export function DraftRestoredNotice({ onDiscard }: { onDiscard: () => void }) {
return (
<div
role="status"
className="flex items-center justify-between gap-3 rounded-lg border border-primary/40 bg-primary/10 px-3 py-2"
>
<span className="text-xs">We kept what you started earlier.</span>
<Button type="button" variant="ghost" size="sm" onClick={onDiscard}>
Start over
</Button>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { PhotoPicker } from "@/components/media/photo-picker"
import { VoiceMemoPicker } from "@/components/media/voice-memo-picker"
import { SongField } from "./fields/song-field"
import { LocationField } from "./fields/location-field"
import type { DraftControls } from "@/hooks/use-entry-draft"
const NOTE_ROWS = 3
/**
* Everything beyond the mood, the moment and the activities. Folded away by
* default so the common case — a face and a tap — is not buried under it.
*/
export function EntryDetails({ draft, set }: DraftControls) {
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
<Label htmlFor="entry-note">Note</Label>
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
id="entry-note"
placeholder="What's on your mind?"
value={draft.content}
onChange={(event) => set("content", event.target.value)}
rows={NOTE_ROWS}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Song</CardTitle>
</CardHeader>
<CardContent>
<SongField
value={draft.song}
onChange={(song) => set("song", song)}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Location</CardTitle>
</CardHeader>
<CardContent>
<LocationField
value={draft.location}
onChange={(location) => set("location", location)}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Photos</CardTitle>
</CardHeader>
<CardContent>
<PhotoPicker
items={draft.photos}
onChange={(photos) => set("photos", photos)}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Voice memos</CardTitle>
</CardHeader>
<CardContent>
<VoiceMemoPicker
items={draft.voiceMemos}
onChange={(voiceMemos) => set("voiceMemos", voiceMemos)}
/>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { MOODS } from "@/lib/mood"
import { cn } from "@/lib/utils"
import { useActivities } from "@/hooks/use-activities"
import type { EntryFilters } from "@/hooks/use-entries"
interface EntryFiltersBarProps {
filters: EntryFilters
onChange: (filters: EntryFilters) => void
}
export function isNarrowed(filters: EntryFilters): boolean {
return Object.values(filters).some((value) => value !== undefined)
}
export function EntryFiltersBar({ filters, onChange }: EntryFiltersBarProps) {
const { data: activities } = useActivities()
const inUse = activities?.filter((activity) => !activity.archived) ?? []
const toggleMood = (mood: number) =>
onChange({ ...filters, mood: filters.mood === mood ? undefined : mood })
const chooseActivity = (activity: string) =>
onChange({
...filters,
activity: filters.activity === activity ? undefined : activity,
})
return (
<div className="flex flex-col gap-3">
<fieldset className="flex flex-col gap-1.5">
<legend className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Mood
</legend>
<div className="flex gap-1.5">
{MOODS.map((mood) => (
<button
key={mood.value}
type="button"
aria-pressed={filters.mood === mood.value}
aria-label={mood.label}
onClick={() => toggleMood(mood.value)}
className={cn(
"flex-1 rounded-lg border py-1.5 text-xl transition-all",
filters.mood === mood.value
? "border-primary bg-primary/15"
: "border-transparent opacity-60 hover:opacity-100"
)}
>
<span aria-hidden="true">{mood.emoji}</span>
</button>
))}
</div>
</fieldset>
{inUse.length > 0 && (
<fieldset className="flex flex-col gap-1.5">
<legend className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Activity
</legend>
<div className="flex flex-wrap gap-1.5">
{inUse.map((activity) => (
<button
key={activity.id}
type="button"
aria-pressed={filters.activity === activity.id}
onClick={() => chooseActivity(activity.id)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs transition-colors",
filters.activity === activity.id
? "border-primary bg-primary/15 text-foreground"
: "border-border text-muted-foreground hover:text-foreground"
)}
>
{activity.name}
</button>
))}
</div>
</fieldset>
)}
{isNarrowed(filters) && (
<Button
variant="ghost"
size="sm"
className="self-start"
onClick={() => onChange({})}
>
<X className="mr-1 h-3 w-3" aria-hidden="true" />
Clear filters
</Button>
)}
</div>
)
}

View File

@@ -1,307 +1,149 @@
import { useState } from "react"
import { Link } from "@tanstack/react-router"
import { ChevronDown, ChevronUp, WifiOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { MoodPicker } from "@/components/mood/mood-picker"
import { LoggedAtPicker } from "@/components/entry/logged-at-picker"
import { ActivityPicker } from "@/components/activity/activity-picker"
import { PhotoPicker } from "@/components/media/photo-picker"
import { VoiceMemoPicker } from "@/components/media/voice-memo-picker"
import { EntryDetails } from "./entry-details"
import { DraftRestoredNotice } from "./draft-restored-notice"
import { useActivities } from "@/hooks/use-activities"
import { useNowPlaying } from "@/hooks/use-providers"
import { media as mediaApi } from "@/api/client"
import { toast } from "@/components/ui/toast"
import type { MoodValue } from "@/lib/mood"
import type { MediaItem } from "@/lib/media"
import { getExistingIds, getPendingFiles } from "@/lib/media"
import { buildDimensions } from "@/lib/dimensions"
import { useOnline } from "@/hooks/use-online"
import { hasExtras } from "@/lib/entry-draft"
import { fromLoggedAt, toLoggedAt } from "@/lib/logged-at"
import type { DimensionPayload } from "@/api/schema"
export interface EntryFormData {
mood: number
loggedAt: string
dimensions?: DimensionPayload[]
}
import type { DraftControls } from "@/hooks/use-entry-draft"
interface EntryFormProps {
controls: DraftControls
title: string
submitLabel: string
isPending: boolean
onSubmit: (data: EntryFormData) => void
initial?: {
mood?: number
loggedAt?: string
content?: string
activities?: string[]
photos?: string[]
voiceMemos?: string[]
location?: { latitude: number; longitude: number } | null
song?: { title: string; artist: string } | null
}
isSaving: boolean
onSubmit: () => void
}
export function EntryForm({
controls,
title,
submitLabel,
isPending,
isSaving,
onSubmit,
initial,
}: EntryFormProps) {
const [mood, setMood] = useState<MoodValue | undefined>(
initial?.mood as MoodValue | undefined
)
const [loggedAt, setLoggedAt] = useState<Date>(
initial?.loggedAt ? fromLoggedAt(initial.loggedAt) : new Date()
)
const [content, setContent] = useState(initial?.content ?? "")
const [selectedActivities, setSelectedActivities] = useState<string[]>(
initial?.activities ?? []
)
const [photoItems, setPhotoItems] = useState<MediaItem[]>(
(initial?.photos ?? []).map((id) => ({ type: "existing" as const, id }))
)
const [voiceMemoItems, setVoiceMemoItems] = useState<MediaItem[]>(
(initial?.voiceMemos ?? []).map((id) => ({ type: "existing" as const, id }))
)
const [songTitle, setSongTitle] = useState(initial?.song?.title ?? "")
const [songArtist, setSongArtist] = useState(initial?.song?.artist ?? "")
const [location, setLocation] = useState<{
latitude: number
longitude: number
} | null>(initial?.location ?? null)
const [locating, setLocating] = useState(false)
const nowPlaying = useNowPlaying()
const fillFromNowPlaying = () => {
nowPlaying.mutate(undefined, {
onSuccess: (playing) => {
if (playing?.kind !== "song") {
toast.add({ title: "Nothing is playing right now", type: "info" })
return
}
setSongTitle(playing.title)
setSongArtist(playing.artist)
},
onError: () =>
toast.add({
title: "Could not reach your music server",
type: "error",
}),
})
}
const [uploading, setUploading] = useState(false)
const captureLocation = () => {
if (!("geolocation" in navigator)) {
toast.add({
title: "Location is unavailable on this device",
type: "error",
})
return
}
setLocating(true)
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
setLocating(false)
},
() => {
toast.add({ title: "Could not read your location", type: "error" })
setLocating(false)
}
)
}
const { draft, isLoading, wasRestored, set, toggleActivity, discard } =
controls
const [detailsOpen, setDetailsOpen] = useState(() => hasExtras(draft))
const { data: activities } = useActivities()
const online = useOnline()
const toggleActivity = (id: string) => {
setSelectedActivities((prev) =>
prev.includes(id) ? prev.filter((a) => a !== id) : [...prev, id]
const showingDetails = detailsOpen || hasExtras(draft)
const inUse = activities?.filter((activity) => !activity.archived) ?? []
if (isLoading) {
return (
<div className="flex flex-col gap-6">
<Skeleton className="h-8 w-48 self-center" />
<Skeleton className="h-24" />
<Skeleton className="h-32" />
</div>
)
}
const handleSubmit = async () => {
if (!mood) return
setUploading(true)
try {
const photoIds = [...getExistingIds(photoItems)]
for (const file of getPendingFiles(photoItems)) {
const result = await mediaApi.uploadPhoto(file)
photoIds.push(result.id)
}
const voiceMemoIds = [...getExistingIds(voiceMemoItems)]
for (const file of getPendingFiles(voiceMemoItems)) {
const result = await mediaApi.uploadVoiceMemo(file)
voiceMemoIds.push(result.id)
}
onSubmit({
mood,
loggedAt: toLoggedAt(loggedAt),
dimensions: buildDimensions({
content,
activityIds: selectedActivities,
photoIds,
voiceMemoIds,
location,
song: { title: songTitle, artist: songArtist },
}),
})
} catch {
toast.add({ title: "Failed to upload media", type: "error" })
} finally {
setUploading(false)
}
}
const busy = isPending || uploading
return (
<div className="flex flex-col gap-6">
<form
className="flex flex-col gap-6"
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<h1 className="text-center text-xl font-bold">{title}</h1>
<MoodPicker value={mood} onChange={setMood} size="lg" />
{wasRestored && <DraftRestoredNotice onDiscard={discard} />}
<MoodPicker
value={draft.mood ?? undefined}
onChange={(mood) => set("mood", mood)}
size="lg"
/>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Logged</CardTitle>
</CardHeader>
<CardContent>
<LoggedAtPicker value={loggedAt} onChange={setLoggedAt} />
<LoggedAtPicker
value={fromLoggedAt(draft.loggedAt)}
onChange={(moment) => set("loggedAt", toLoggedAt(moment))}
/>
</CardContent>
</Card>
{activities?.length ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activities</CardTitle>
</CardHeader>
<CardContent>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Activities</CardTitle>
</CardHeader>
<CardContent>
{inUse.length > 0 ? (
<ActivityPicker
activities={activities}
selected={selectedActivities}
activities={inUse}
selected={draft.activityIds}
onToggle={toggleActivity}
/>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Note</CardTitle>
</CardHeader>
<CardContent>
<Textarea
placeholder="What's on your mind?"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Song</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Input
placeholder="Title"
value={songTitle}
onChange={(e) => setSongTitle(e.target.value)}
/>
<Input
placeholder="Artist"
value={songArtist}
onChange={(e) => setSongArtist(e.target.value)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={fillFromNowPlaying}
disabled={nowPlaying.isPending}
>
{nowPlaying.isPending ? "Checking…" : "What's playing?"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Location</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={captureLocation}
disabled={locating}
>
{locating
? "Locating…"
: location
? "Update location"
: "Add location"}
</Button>
{location && (
<span className="text-xs text-muted-foreground">
{location.latitude.toFixed(4)}, {location.longitude.toFixed(4)}
</span>
)}
{location && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setLocation(null)}
>
Remove
</Button>
) : (
<p className="text-sm text-muted-foreground">
No activities yet.{" "}
<Link to="/activities" className="text-primary underline">
Add some
</Link>{" "}
to tag what you were doing.
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Photos</CardTitle>
</CardHeader>
<CardContent>
<PhotoPicker items={photoItems} onChange={setPhotoItems} />
</CardContent>
</Card>
{showingDetails ? (
<EntryDetails {...controls} />
) : (
<Button
type="button"
variant="outline"
aria-expanded={false}
onClick={() => setDetailsOpen(true)}
>
<ChevronDown className="mr-2 h-4 w-4" aria-hidden="true" />
Add a note, photo, or more
</Button>
)}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Voice memos</CardTitle>
</CardHeader>
<CardContent>
<VoiceMemoPicker
items={voiceMemoItems}
onChange={setVoiceMemoItems}
/>
</CardContent>
</Card>
{showingDetails && !hasExtras(draft) && (
<Button
type="button"
variant="ghost"
size="sm"
aria-expanded={true}
onClick={() => setDetailsOpen(false)}
>
<ChevronUp className="mr-2 h-4 w-4" aria-hidden="true" />
Hide extras
</Button>
)}
{!online && (
<p className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<WifiOff className="h-3 w-3" aria-hidden="true" />
Offline this will send when you are back
</p>
)}
<Button
type="submit"
size="lg"
onClick={handleSubmit}
disabled={!mood || busy}
disabled={draft.mood === null || isSaving}
className="w-full"
>
{uploading
? "Uploading media..."
: isPending
? "Saving..."
: submitLabel}
{isSaving ? "Saving…" : submitLabel}
</Button>
</div>
</form>
)
}

View File

@@ -1,55 +1,50 @@
import { format, isToday, isYesterday } from "date-fns"
import { describeDay } from "@/lib/day-label"
import { formatDate, parseDate } from "@/lib/day"
import type { EntryResponse } from "@/api/schema"
import { EntryCard } from "./entry-card"
interface EntryListProps {
entries: EntryResponse[]
limit?: number
}
export function EntryList({ entries, limit }: EntryListProps) {
const sliced = limit ? entries.slice(0, limit) : entries
const grouped = groupByDay(sliced)
export function EntryList({ entries }: EntryListProps) {
return (
<div className="flex flex-col gap-4">
{grouped.map(({ label, entries: onThatDay }) => (
<div key={label} className="flex flex-col gap-2">
{groupByDay(entries).map(({ day, entries: onThatDay }) => (
<section key={day} className="flex flex-col gap-2">
<h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{label}
{describeDay(parseDate(day))}
</h3>
{onThatDay.map((entry) => (
<EntryCard key={entry.id} entry={entry} />
))}
</div>
</section>
))}
</div>
)
}
function groupByDay(
interface DayGroup {
day: string
entries: EntryResponse[]
): Array<{ label: string; entries: EntryResponse[] }> {
}
function groupByDay(entries: EntryResponse[]): DayGroup[] {
const groups = new Map<string, EntryResponse[]>()
for (const entry of entries) {
const date = new Date(entry.loggedAt)
const key = format(date, "yyyy-MM-dd")
const day = formatDate(new Date(entry.loggedAt))
const existing = groups.get(day)
if (!groups.has(key)) {
groups.set(key, [])
if (existing) {
existing.push(entry)
} else {
groups.set(day, [entry])
}
groups.get(key)!.push(entry)
}
return Array.from(groups.entries()).map(([key, onThatDay]) => ({
label: formatDayLabel(new Date(key)),
return Array.from(groups.entries()).map(([day, onThatDay]) => ({
day,
entries: onThatDay,
}))
}
function formatDayLabel(date: Date): string {
if (isToday(date)) return "Today"
if (isYesterday(date)) return "Yesterday"
return format(date, "EEEE, MMMM d, yyyy")
}

View File

@@ -0,0 +1,27 @@
import { Link } from "@tanstack/react-router"
import { buttonLook } from "@/lib/button-style"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty"
export function EntryMissing() {
return (
<Empty className="py-12">
<EmptyHeader>
<EmptyTitle>Entry not found</EmptyTitle>
<EmptyDescription>
It may have been deleted, or the link may be wrong.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Link to="/diary" className={buttonLook({ variant: "outline" })}>
Back to the diary
</Link>
</EmptyContent>
</Empty>
)
}

View File

@@ -0,0 +1,71 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
import type { Coordinates } from "@/lib/entry-draft"
interface LocationFieldProps {
value: Coordinates | null
onChange: (location: Coordinates | null) => void
}
const COORDINATE_PRECISION = 4
export function LocationField({ value, onChange }: LocationFieldProps) {
const [locating, setLocating] = useState(false)
const capture = () => {
if (!("geolocation" in navigator)) {
toast.add({
title: "Location is unavailable on this device",
type: "error",
})
return
}
setLocating(true)
navigator.geolocation.getCurrentPosition(
(position) => {
onChange({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
setLocating(false)
},
() => {
toast.add({ title: "Could not read your location", type: "error" })
setLocating(false)
}
)
}
return (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={capture}
disabled={locating}
>
{locating ? "Locating…" : value ? "Update location" : "Add location"}
</Button>
{value && (
<>
<span className="text-xs text-muted-foreground tabular-nums">
{value.latitude.toFixed(COORDINATE_PRECISION)},{" "}
{value.longitude.toFixed(COORDINATE_PRECISION)}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onChange(null)}
>
Remove
</Button>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
import { useNowPlaying } from "@/hooks/use-providers"
import type { SongNote } from "@/lib/entry-draft"
interface SongFieldProps {
value: SongNote
onChange: (song: SongNote) => void
}
export function SongField({ value, onChange }: SongFieldProps) {
const nowPlaying = useNowPlaying()
const fillFromNowPlaying = () => {
nowPlaying.mutate(undefined, {
onSuccess: (playing) => {
if (playing?.kind !== "song") {
toast.add({ title: "Nothing is playing right now", type: "info" })
return
}
onChange({ title: playing.title, artist: playing.artist })
},
onError: () =>
toast.add({
title: "Could not reach your music server",
type: "error",
}),
})
}
return (
<div className="flex flex-col gap-2">
<Label htmlFor="song-title" className="sr-only">
Song title
</Label>
<Input
id="song-title"
placeholder="Title"
value={value.title}
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
<Label htmlFor="song-artist" className="sr-only">
Artist
</Label>
<Input
id="song-artist"
placeholder="Artist"
value={value.artist}
onChange={(event) => onChange({ ...value, artist: event.target.value })}
/>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={fillFromNowPlaying}
disabled={nowPlaying.isPending}
>
{nowPlaying.isPending ? "Checking…" : "What's playing?"}
</Button>
</div>
)
}

View File

@@ -0,0 +1,47 @@
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { EntryForm } from "./entry-form"
import { useNewEntryForm } from "@/hooks/use-new-entry-form"
interface LogSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Logging a mood from wherever you are. The same form the page shows, in front
* of what you were already looking at, because the common case is one tap.
*/
export function LogSheet({ open, onOpenChange }: LogSheetProps) {
const { controls, submit, isSaving } = useNewEntryForm(() =>
onOpenChange(false)
)
return (
<Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>
<DrawerContent className="mx-auto max-h-[85svh] w-full max-w-md">
<DrawerHeader className="sr-only">
<DrawerTitle>Log a mood</DrawerTitle>
<DrawerDescription>
Pick how you feel, and add as much or as little else as you want.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pt-2 pb-6 pb-safe">
<EntryForm
controls={controls}
title="How are you feeling?"
submitLabel="Save entry"
isSaving={isSaving}
onSubmit={submit}
/>
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,103 @@
import { format } from "date-fns"
import { AlertTriangle, Clock } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { MoodBadge } from "@/components/mood/mood-badge"
import {
useDiscardQueued,
useQueuedEntries,
useRetryQueued,
} from "@/hooks/use-outbox"
import { isStuck } from "@/lib/outbox-sync"
import { contentPreview } from "@/lib/entry-preview"
import type { QueuedEntry } from "@/lib/store/outbox"
/**
* Entries written on this device that the server has not taken yet. They are
* shown with the journal so that logging offline still looks like it worked.
*/
export function PendingEntries() {
const { data: queued = [] } = useQueuedEntries()
if (!queued.length) return null
return (
<section className="flex flex-col gap-2">
<h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Not sent yet
</h2>
{queued.map((entry) => (
<PendingEntryCard key={entry.id} queued={entry} />
))}
</section>
)
}
function PendingEntryCard({ queued }: { queued: QueuedEntry }) {
const retry = useRetryQueued()
const discard = useDiscardQueued()
const stuck = isStuck(queued)
const note = contentPreview(queued.draft.content)
return (
<Card className="border-dashed">
<CardContent className="flex items-start gap-3 p-4">
{queued.draft.mood !== null && <MoodBadge value={queued.draft.mood} />}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{stuck ? (
<AlertTriangle
className="h-3 w-3 text-destructive"
aria-hidden="true"
/>
) : (
<Clock className="h-3 w-3" aria-hidden="true" />
)}
{stuck ? "The server would not take this" : "Waiting to send"}
</span>
<span className="text-xs text-muted-foreground">
{format(new Date(queued.draft.loggedAt), "MMM d, h:mm a")}
</span>
</div>
{note && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{note}
</p>
)}
{stuck && (
<>
{queued.lastError && (
<p className="mt-1 text-xs text-destructive">
{queued.lastError}
</p>
)}
<div className="mt-2 flex gap-2">
<Button
size="sm"
variant="outline"
disabled={retry.isPending}
onClick={() => retry.mutate(queued)}
>
{retry.isPending ? "Retrying…" : "Try again"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive"
disabled={discard.isPending}
onClick={() => discard.mutate(queued.id)}
>
Discard
</Button>
</div>
</>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -7,8 +7,8 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
return (
<div className="mx-auto min-h-svh max-w-md bg-background">
<main className="pt-safe px-4 pb-20">{children}</main>
<div className="mx-auto min-h-svh max-w-md app-column">
<main className="px-4 pt-safe pb-nav">{children}</main>
<BottomNav />
</div>
)

View File

@@ -1,61 +1,116 @@
import { Suspense, lazy, useState } from "react"
import { Link, useRouterState } from "@tanstack/react-router"
import { Home, Calendar, BarChart3, Settings, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* The form behind the button is the heaviest thing in the app — a date picker,
* a drawer and every media control. None of it is needed until someone taps.
*/
const LogSheet = lazy(() =>
import("@/components/entry/log-sheet").then((module) => ({
default: module.LogSheet,
}))
)
type NavItem = {
to: string
icon: typeof Home
label: string
primary?: boolean
/**
* The routes this tab stands for. A tab stays lit while you are anywhere
* beneath it — reading an entry belongs to Home, managing activities to
* Settings — so no screen in the app is left with nothing selected.
*/
owns: string[]
}
const NAV_ITEMS: NavItem[] = [
{ to: "/", icon: Home, label: "Home" },
{ to: "/calendar", icon: Calendar, label: "Calendar" },
{ to: "/add", icon: Plus, label: "Add", primary: true },
{ to: "/stats", icon: BarChart3, label: "Stats" },
{ to: "/settings", icon: Settings, label: "Settings" },
{
to: "/",
icon: Home,
label: "Home",
owns: ["/", "/diary", "/entry", "/edit"],
},
{ to: "/calendar", icon: Calendar, label: "Calendar", owns: ["/calendar"] },
{
to: "/stats",
icon: BarChart3,
label: "Stats",
owns: ["/stats", "/metrics"],
},
{
to: "/settings",
icon: Settings,
label: "Settings",
owns: ["/settings", "/activities", "/reminders", "/import", "/export"],
},
]
function owns(item: NavItem, pathname: string): boolean {
return item.owns.some(
(route) =>
pathname === route || (route !== "/" && pathname.startsWith(`${route}/`))
)
}
export function BottomNav() {
const { location } = useRouterState()
const [logging, setLogging] = useState(false)
const [before, after] = [NAV_ITEMS.slice(0, 2), NAV_ITEMS.slice(2)]
return (
<nav className="safe-area-pb fixed right-0 bottom-0 left-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex max-w-md items-center justify-around px-2 py-1">
{NAV_ITEMS.map((item) => {
const isActive = location.pathname === item.to
const Icon = item.icon
<>
{logging && (
<Suspense fallback={null}>
<LogSheet open={logging} onOpenChange={setLogging} />
</Suspense>
)}
<nav
aria-label="Primary"
className="fixed right-0 bottom-0 left-0 z-50 border-t bg-background/95 pb-safe backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div className="mx-auto flex max-w-md items-center justify-around px-2 py-1">
{before.map((item) => (
<NavTab key={item.to} item={item} pathname={location.pathname} />
))}
if (item.primary) {
return (
<Link
key={item.to}
to={item.to}
className="-mt-5 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg"
>
<Icon className="h-6 w-6" />
</Link>
)
}
<button
type="button"
aria-label="Log a mood"
aria-haspopup="dialog"
onClick={() => setLogging(true)}
className="-mt-5 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg"
>
<Plus className="h-6 w-6" aria-hidden="true" />
</button>
return (
<Link
key={item.to}
to={item.to}
className={cn(
"flex flex-col items-center gap-0.5 px-3 py-2 text-xs transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-5 w-5" />
<span>{item.label}</span>
</Link>
)
})}
</div>
</nav>
{after.map((item) => (
<NavTab key={item.to} item={item} pathname={location.pathname} />
))}
</div>
</nav>
</>
)
}
function NavTab({ item, pathname }: { item: NavItem; pathname: string }) {
const isActive = owns(item, pathname)
const Icon = item.icon
return (
<Link
to={item.to}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex flex-col items-center gap-0.5 px-3 py-2 text-xs transition-colors",
isActive
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-5 w-5" aria-hidden="true" />
<span>{item.label}</span>
</Link>
)
}

View File

@@ -0,0 +1,67 @@
import { useEffect } from "react"
import { useRouter } from "@tanstack/react-router"
import { RotateCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from "@/components/ui/empty"
import {
forgetMissingChunkReload,
isMissingChunk,
reloadForMissingChunk,
} from "@/lib/service-worker"
/**
* What a screen shows when it fails, instead of taking the whole app down with
* it. A chunk that went missing under a deploy heals itself with one reload;
* anything else is offered a retry.
*/
export function RouteError({ error }: { error: unknown }) {
const router = useRouter()
const staleBuild = isMissingChunk(error)
useEffect(() => {
if (staleBuild) reloadForMissingChunk()
else forgetMissingChunkReload()
}, [staleBuild])
if (staleBuild) {
return (
<Empty className="py-16">
<EmptyHeader>
<EmptyTitle>Updating</EmptyTitle>
<EmptyDescription>
A newer version is available. Picking it up now.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" onClick={() => window.location.reload()}>
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
Reload
</Button>
</EmptyContent>
</Empty>
)
}
return (
<Empty className="py-16">
<EmptyHeader>
<EmptyTitle>This screen did not load</EmptyTitle>
<EmptyDescription>
{error instanceof Error ? error.message : "Something went wrong."}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" onClick={() => router.invalidate()}>
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
Try again
</Button>
</EmptyContent>
</Empty>
)
}

View File

@@ -1,14 +1,20 @@
import { useRef, useState, useEffect, useCallback } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { Pause, Play, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider"
import { formatDuration } from "@/lib/duration"
interface AudioPlayerProps {
src: string
label?: string
onRemove?: () => void
}
export function AudioPlayer({ src, onRemove }: AudioPlayerProps) {
export function AudioPlayer({
src,
label = "Voice memo",
onRemove,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [duration, setDuration] = useState(0)
@@ -21,35 +27,42 @@ export function AudioPlayer({ src, onRemove }: AudioPlayerProps) {
const onLoaded = () => setDuration(audio.duration)
const onTime = () => setCurrentTime(audio.currentTime)
const onEnded = () => setPlaying(false)
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("timeupdate", onTime)
audio.addEventListener("ended", onEnded)
audio.addEventListener("play", onPlay)
audio.addEventListener("pause", onPause)
return () => {
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("timeupdate", onTime)
audio.removeEventListener("ended", onEnded)
audio.removeEventListener("play", onPlay)
audio.removeEventListener("pause", onPause)
}
}, [])
const toggle = useCallback(() => {
const audio = audioRef.current
if (!audio) return
if (playing) {
audio.pause()
if (audio.paused) {
audio.play().catch(() => setPlaying(false))
} else {
audio.play()
audio.pause()
}
setPlaying(!playing)
}, [playing])
}, [])
const seek = useCallback((value: number | readonly number[]) => {
const audio = audioRef.current
if (!audio) return
const v = Array.isArray(value) ? value[0] : value
audio.currentTime = v
setCurrentTime(v)
const moment = Array.isArray(value) ? value[0] : (value as number)
audio.currentTime = moment
setCurrentTime(moment)
}, [])
return (
@@ -60,10 +73,15 @@ export function AudioPlayer({ src, onRemove }: AudioPlayerProps) {
type="button"
variant="ghost"
size="icon"
aria-label={`${playing ? "Pause" : "Play"} ${label}`}
className="h-8 w-8 shrink-0"
onClick={toggle}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{playing ? (
<Pause className="h-4 w-4" aria-hidden="true" />
) : (
<Play className="h-4 w-4" aria-hidden="true" />
)}
</Button>
<Slider
@@ -71,11 +89,12 @@ export function AudioPlayer({ src, onRemove }: AudioPlayerProps) {
max={duration || 1}
step={0.1}
onValueChange={seek}
aria-label={`Seek within ${label}`}
className="flex-1"
/>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{fmt(currentTime)}/{fmt(duration)}
{formatDuration(currentTime)}/{formatDuration(duration)}
</span>
{onRemove && (
@@ -83,19 +102,13 @@ export function AudioPlayer({ src, onRemove }: AudioPlayerProps) {
type="button"
variant="ghost"
size="icon"
aria-label={`Remove ${label}`}
className="h-6 w-6 shrink-0 text-destructive"
onClick={onRemove}
>
<X className="h-3 w-3" />
<X className="h-3 w-3" aria-hidden="true" />
</Button>
)}
</div>
)
}
function fmt(seconds: number): string {
if (!isFinite(seconds)) return "0:00"
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, "0")}`
}

View File

@@ -2,19 +2,25 @@ import { X } from "lucide-react"
interface MediaThumbnailProps {
src: string
alt: string
onRemove: () => void
}
export function MediaThumbnail({ src, onRemove }: MediaThumbnailProps) {
export function MediaThumbnail({ src, alt, onRemove }: MediaThumbnailProps) {
return (
<div className="relative h-20 w-20">
<img src={src} alt="" className="h-full w-full rounded-lg object-cover" />
<img
src={src}
alt={alt}
className="h-full w-full rounded-lg object-cover"
/>
<button
type="button"
aria-label={`Remove ${alt}`}
onClick={onRemove}
className="text-destructive-foreground absolute -top-1 -right-1 rounded-full bg-destructive p-0.5"
>
<X className="h-3 w-3" />
<X className="h-3 w-3" aria-hidden="true" />
</button>
</div>
)

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
interface PhotoLightboxProps {
urls: string[]
/** Which photo to show, or null when the lightbox is closed. */
openAt: number | null
onClose: () => void
}
export function PhotoLightbox({ urls, openAt, onClose }: PhotoLightboxProps) {
const [index, setIndex] = useState(openAt ?? 0)
useEffect(() => {
if (openAt !== null) setIndex(openAt)
}, [openAt])
const show = (next: number) =>
setIndex((next + urls.length) % Math.max(urls.length, 1))
useEffect(() => {
if (openAt === null) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") show(index - 1)
if (event.key === "ArrowRight") show(index + 1)
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
})
const many = urls.length > 1
return (
<Dialog open={openAt !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader className="sr-only">
<DialogTitle>
Photo {index + 1} of {urls.length}
</DialogTitle>
<DialogDescription>A photo attached to this entry.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
{many && (
<Button
variant="ghost"
size="icon"
aria-label="Previous photo"
onClick={() => show(index - 1)}
>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
</Button>
)}
<img
src={urls[index]}
alt={`Photo ${index + 1} of ${urls.length}`}
className="max-h-[75svh] w-full rounded-lg object-contain"
/>
{many && (
<Button
variant="ghost"
size="icon"
aria-label="Next photo"
onClick={() => show(index + 1)}
>
<ChevronRight className="h-5 w-5" aria-hidden="true" />
</Button>
)}
</div>
{many && (
<p className="text-center text-xs text-muted-foreground">
{index + 1} of {urls.length}
</p>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -3,8 +3,9 @@ import { ImagePlus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { MediaThumbnail } from "./media-thumbnail"
import { media } from "@/api/client"
import { usePreviewUrls } from "@/hooks/use-preview-urls"
import { keyOf, newPendingItem } from "@/lib/media"
import type { MediaItem } from "@/lib/media"
import { createPendingItem } from "@/lib/media"
interface PhotoPickerProps {
items: MediaItem[]
@@ -13,40 +14,43 @@ interface PhotoPickerProps {
export function PhotoPicker({ items, onChange }: PhotoPickerProps) {
const inputRef = useRef<HTMLInputElement>(null)
const previews = usePreviewUrls(items)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
onChange([...items, createPendingItem(file)])
e.target.value = ""
const handleFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const chosen = Array.from(event.target.files ?? [])
if (chosen.length) onChange([...items, ...chosen.map(newPendingItem)])
event.target.value = ""
}
const remove = (index: number) => {
onChange(items.filter((_, i) => i !== index))
const removeAt = (index: number) => {
onChange(items.filter((_, position) => position !== index))
}
return (
<div className="flex flex-col gap-3">
{items.length > 0 && (
<div className="flex flex-wrap gap-2">
{items.map((item, i) => (
<MediaThumbnail
key={i}
src={
item.type === "existing"
? media.photoUrl(item.id)
: item.preview
}
onRemove={() => remove(i)}
/>
<ul className="flex list-none flex-wrap gap-2 p-0">
{items.map((item, index) => (
<li key={keyOf(item)}>
<MediaThumbnail
src={
item.kind === "existing"
? media.photoUrl(item.id)
: (previews.get(item.localId) ?? "")
}
alt={`Photo ${index + 1}`}
onRemove={() => removeAt(index)}
/>
</li>
))}
</div>
</ul>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
onChange={handleFile}
className="hidden"
/>
@@ -57,7 +61,7 @@ export function PhotoPicker({ items, onChange }: PhotoPickerProps) {
size="sm"
onClick={() => inputRef.current?.click()}
>
<ImagePlus className="mr-2 h-4 w-4" />
<ImagePlus className="mr-2 h-4 w-4" aria-hidden="true" />
Add photo
</Button>
</div>

View File

@@ -3,9 +3,11 @@ import { FileAudio, Mic, Square } from "lucide-react"
import { Button } from "@/components/ui/button"
import { AudioPlayer } from "./audio-player"
import { media } from "@/api/client"
import { useVoiceRecorder, formatDuration } from "@/hooks/use-voice-recorder"
import { usePreviewUrls } from "@/hooks/use-preview-urls"
import { useVoiceRecorder } from "@/hooks/use-voice-recorder"
import { formatDuration } from "@/lib/duration"
import { keyOf, newPendingItem } from "@/lib/media"
import type { MediaItem } from "@/lib/media"
import { createPendingItem } from "@/lib/media"
interface VoiceMemoPickerProps {
items: MediaItem[]
@@ -14,40 +16,42 @@ interface VoiceMemoPickerProps {
export function VoiceMemoPicker({ items, onChange }: VoiceMemoPickerProps) {
const fileRef = useRef<HTMLInputElement>(null)
const previews = usePreviewUrls(items)
const addFile = (file: File) => {
onChange([...items, createPendingItem(file)])
const add = (file: File) => onChange([...items, newPendingItem(file)])
const { recording, duration, start, stop } = useVoiceRecorder(add)
const handleFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) add(file)
event.target.value = ""
}
const { recording, duration, start, stop } = useVoiceRecorder(addFile)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
addFile(file)
e.target.value = ""
}
const remove = (index: number) => {
onChange(items.filter((_, i) => i !== index))
const removeAt = (index: number) => {
onChange(items.filter((_, position) => position !== index))
}
return (
<div className="flex flex-col gap-3">
{items.map((item, i) => (
{items.map((item, index) => (
<AudioPlayer
key={i}
key={keyOf(item)}
src={
item.type === "existing"
item.kind === "existing"
? media.voiceMemoUrl(item.id)
: item.preview
: (previews.get(item.localId) ?? "")
}
onRemove={() => remove(i)}
label={`Voice memo ${index + 1}`}
onRemove={() => removeAt(index)}
/>
))}
{recording && (
<div className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3">
<div
role="status"
className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3"
>
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-destructive" />
<span className="text-sm font-medium">Recording</span>
<span className="ml-auto font-mono text-sm">
@@ -73,7 +77,7 @@ export function VoiceMemoPicker({ items, onChange }: VoiceMemoPickerProps) {
className="flex-1"
onClick={stop}
>
<Square className="mr-2 h-4 w-4" />
<Square className="mr-2 h-4 w-4" aria-hidden="true" />
Stop
</Button>
) : (
@@ -84,7 +88,7 @@ export function VoiceMemoPicker({ items, onChange }: VoiceMemoPickerProps) {
className="flex-1"
onClick={start}
>
<Mic className="mr-2 h-4 w-4" />
<Mic className="mr-2 h-4 w-4" aria-hidden="true" />
Record
</Button>
)}
@@ -96,7 +100,7 @@ export function VoiceMemoPicker({ items, onChange }: VoiceMemoPickerProps) {
onClick={() => fileRef.current?.click()}
disabled={recording}
>
<FileAudio className="mr-2 h-4 w-4" />
<FileAudio className="mr-2 h-4 w-4" aria-hidden="true" />
Upload
</Button>
</div>

View File

@@ -0,0 +1,52 @@
import { addDays, isToday, subDays } from "date-fns"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { formatDate, parseDate, today } from "@/lib/day"
import { describeDay } from "@/lib/day-label"
interface DayStepperProps {
value: string
onChange: (date: string) => void
}
/** Yesterday is one tap away, which is when most readings actually get typed. */
export function DayStepper({ value, onChange }: DayStepperProps) {
const day = parseDate(value)
const atToday = isToday(day)
return (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
aria-label="Previous day"
onClick={() => onChange(formatDate(subDays(day, 1)))}
>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
</Button>
<div className="flex flex-1 flex-col items-center gap-1">
<span className="text-sm font-medium">{describeDay(day)}</span>
<Input
type="date"
aria-label="Day"
className="h-8 text-center text-xs"
value={value}
max={today()}
onChange={(event) => onChange(event.target.value)}
/>
</div>
<Button
variant="ghost"
size="icon"
aria-label="Next day"
disabled={atToday}
onClick={() => onChange(formatDate(addDays(day, 1)))}
>
<ChevronRight className="h-5 w-5" aria-hidden="true" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { METRIC_FIELDS } from "./metric-fields"
import type { MetricField } from "./metric-fields"
import { MetricRow } from "./metric-row"
import { byKind, useSetDailyMetrics } from "@/hooks/use-metrics"
import { messageFor } from "@/lib/api-message"
import type { DailyMetricResponse, MetricPayload } from "@/api/schema"
type Drafts = Record<string, string>
function storedText(
stored: Map<string, DailyMetricResponse>,
field: MetricField
): string {
const reading = stored.get(field.kind)
return reading ? String(reading.value) : ""
}
function saveLabel(changes: number, cleared: number): string {
if (changes === 0) return "Nothing to save"
const plural = changes === 1 ? "" : "s"
if (cleared === 0) return `Save ${changes} change${plural}`
if (cleared === changes) {
return `Clear ${cleared} reading${cleared === 1 ? "" : "s"}`
}
return `Save ${changes} change${plural}, ${cleared} cleared`
}
function isOutOfRange(field: MetricField, draft: string): boolean {
if (draft === "") return false
const count = Number(draft)
return !Number.isInteger(count) || count < field.min || count > field.max
}
interface MetricsEditorProps {
date: string
stored: DailyMetricResponse[]
}
/**
* One day's readings. Mounted fresh per day — the drafts are seeded from what
* is stored, so there is no second copy of the day's state to keep in step.
*/
export function MetricsEditor({ date, stored }: MetricsEditorProps) {
const recorded = byKind(stored)
const [drafts, setDrafts] = useState<Drafts>(() =>
Object.fromEntries(
METRIC_FIELDS.map((field) => [field.kind, storedText(recorded, field)])
)
)
const save = useSetDailyMetrics()
const edited = METRIC_FIELDS.filter(
(field) => (drafts[field.kind] ?? "") !== storedText(recorded, field)
)
const invalid = edited.filter((field) =>
isOutOfRange(field, drafts[field.kind] ?? "")
)
const changes: MetricPayload[] = edited
.filter((field) => !invalid.includes(field))
.map((field) => ({
kind: field.kind,
value: drafts[field.kind] === "" ? null : Number(drafts[field.kind]),
}))
const cleared = changes.filter((change) => change.value === null).length
return (
<div className="flex flex-col gap-4">
<Card>
<CardContent className="flex flex-col gap-4 py-4">
{METRIC_FIELDS.map((field) => (
<MetricRow
key={field.kind}
field={field}
stored={recorded.get(field.kind)}
draft={drafts[field.kind] ?? ""}
onChange={(draft) =>
setDrafts((current) => ({ ...current, [field.kind]: draft }))
}
/>
))}
</CardContent>
</Card>
{invalid.length > 0 && (
<p role="alert" className="text-xs text-destructive">
{invalid.map((field) => field.label).join(", ")}:{" "}
{invalid.length === 1 ? "value is" : "values are"} outside the allowed
range.
</p>
)}
<Button
disabled={changes.length === 0 || save.isPending}
onClick={() => save.mutate({ date, values: changes })}
>
{save.isPending ? "Saving…" : saveLabel(changes.length, cleared)}
</Button>
{save.isError && (
<p role="alert" className="text-xs text-destructive">
{messageFor(save.error)}
</p>
)}
</div>
)
}

View File

@@ -4,23 +4,24 @@ import { cn } from "@/lib/utils"
interface MoodDotProps {
value: number
size?: "sm" | "md"
/**
* Whether the dot needs to name itself. False where the surrounding control
* already says which mood this is, so it is not read out twice.
*/
labelled?: boolean
}
const BG_COLORS: Record<number, string> = {
1: "bg-red-500",
2: "bg-orange-500",
3: "bg-yellow-500",
4: "bg-lime-500",
5: "bg-green-500",
}
const SIZES = { sm: "h-2 w-2", md: "h-3 w-3" } as const
export function MoodDot({ value, size = "sm" }: MoodDotProps) {
const sizes = { sm: "h-2 w-2", md: "h-3 w-3" }
export function MoodDot({ value, size = "sm", labelled = true }: MoodDotProps) {
const mood = getMood(value)
return (
<span
className={cn("inline-block rounded-full", sizes[size], BG_COLORS[value])}
title={getMood(value).label}
role={labelled ? "img" : undefined}
aria-label={labelled ? mood.label : undefined}
aria-hidden={labelled ? undefined : true}
className={cn("inline-block rounded-full", SIZES[size], mood.background)}
/>
)
}

View File

@@ -1,3 +1,4 @@
import { useRef } from "react"
import { MOODS } from "@/lib/mood"
import type { MoodValue } from "@/lib/mood"
import { cn } from "@/lib/utils"
@@ -8,32 +9,79 @@ interface MoodPickerProps {
size?: "sm" | "md" | "lg"
}
const SIZES = {
sm: "text-2xl p-2",
md: "text-4xl p-3",
lg: "text-5xl p-4",
} as const
/**
* A radio group, because that is what choosing one of five is. Arrow keys move
* between the faces and a single tab stop enters the group, so the picker is
* one control rather than five loose buttons.
*/
export function MoodPicker({ value, onChange, size = "md" }: MoodPickerProps) {
const sizes = {
sm: "text-2xl p-2",
md: "text-4xl p-3",
lg: "text-5xl p-4",
const groupRef = useRef<HTMLDivElement>(null)
const focusAt = (index: number) => {
const options =
groupRef.current?.querySelectorAll<HTMLButtonElement>("[role='radio']")
options?.[(index + MOODS.length) % MOODS.length]?.focus()
}
const handleKey = (event: React.KeyboardEvent, index: number) => {
const step =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0
if (step === 0) return
event.preventDefault()
const next = (index + step + MOODS.length) % MOODS.length
onChange(MOODS[next].value)
focusAt(next)
}
const focusedIndex = value
? MOODS.findIndex((mood) => mood.value === value)
: 0
return (
<div className="flex items-center justify-center gap-2">
{MOODS.map((mood) => (
<button
key={mood.value}
type="button"
onClick={() => onChange(mood.value)}
className={cn(
"rounded-xl transition-all duration-200",
sizes[size],
value === mood.value
? "scale-110 ring-2 ring-primary"
: "opacity-50 hover:scale-105 hover:opacity-100"
)}
aria-label={mood.label}
>
{mood.emoji}
</button>
))}
<div
ref={groupRef}
role="radiogroup"
aria-label="How are you feeling?"
className="flex items-center justify-center gap-2"
>
{MOODS.map((mood, index) => {
const isSelected = value === mood.value
return (
<button
key={mood.value}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={mood.label}
tabIndex={index === focusedIndex ? 0 : -1}
onClick={() => onChange(mood.value)}
onKeyDown={(event) => handleKey(event, index)}
className={cn(
"rounded-xl transition-all duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
SIZES[size],
isSelected
? "scale-110 ring-2 ring-primary"
: "opacity-60 hover:scale-105 hover:opacity-100"
)}
>
<span aria-hidden="true">{mood.emoji}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { CloudUpload, WifiOff } from "lucide-react"
import { useOnline } from "@/hooks/use-online"
import { useQueuedEntries } from "@/hooks/use-outbox"
import { isStuck } from "@/lib/outbox-sync"
/**
* Says where unsent work stands. Silent when there is nothing to say — a
* permanent banner would be noise on every screen.
*/
export function ConnectionBanner() {
const online = useOnline()
const { data: queued = [] } = useQueuedEntries()
const stuck = queued.filter(isStuck).length
const waiting = queued.length - stuck
if (online && queued.length === 0) return null
return (
<div
role="status"
className="flex items-center gap-2 rounded-lg border border-border bg-muted/60 px-3 py-2 text-xs"
>
{online ? (
<CloudUpload className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
) : (
<WifiOff className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
)}
<span>{summarise({ online, waiting, stuck })}</span>
</div>
)
}
function plural(count: number): string {
return count === 1 ? "entry" : "entries"
}
function summarise({
online,
waiting,
stuck,
}: {
online: boolean
waiting: number
stuck: number
}): string {
if (!online) {
return waiting + stuck > 0
? `Offline. ${waiting + stuck} ${plural(waiting + stuck)} waiting to send.`
: "Offline. Anything you log is kept here and sent when you are back."
}
if (stuck > 0 && waiting > 0) {
return `Sending ${waiting} ${plural(waiting)}. ${stuck} could not be sent.`
}
if (stuck > 0) {
return `${stuck} ${plural(stuck)} could not be sent — review below.`
}
return `Sending ${waiting} ${plural(waiting)}`
}

View File

@@ -1,101 +1,175 @@
import { useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { Trash2 } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { CollapsibleCard } from "@/components/settings/collapsible-card"
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/toast"
import {
useApiTokens,
useMintApiToken,
useRevokeApiToken,
} from "@/hooks/use-api-tokens"
import type { ApiTokenResponse } from "@/api/schema"
import type { ApiTokenResponse, TokenScope } from "@/api/schema"
const NAME_RULE = /^[a-z0-9-]+$/
const GRANTS: Array<{
scope: TokenScope
title: string
describes: string
}> = [
{
scope: "readJournal",
title: "Read the journal",
describes:
"Entries, activities, the calendar, statistics, correlations, cycle records and daily metrics.",
},
{
scope: "writeJournal",
title: "Write to the journal",
describes:
"Create, change and delete entries, activities, cycle records, reminders and media.",
},
{
scope: "writeMetrics",
title: "Write daily metrics",
describes:
"For an importer. Its writes are attributed to the token's own name.",
},
{
scope: "readProfile",
title: "Read the profile",
describes: "The account's name, timezone, preferences and connections.",
},
]
export function ApiTokensCard() {
const { data: existing } = useApiTokens()
const mint = useMintApiToken()
const revoke = useRevokeApiToken()
const [name, setName] = useState("")
const [granted, setGranted] = useState<TokenScope[]>(["writeMetrics"])
const [justMinted, setJustMinted] = useState<string | null>(null)
const normalised = name.trim().toLowerCase()
const canMint = normalised.length > 0 && NAME_RULE.test(normalised)
const namedWell = normalised.length > 0 && NAME_RULE.test(normalised)
const canMint = namedWell && granted.length > 0
const toggle = (scope: TokenScope, wanted: boolean) =>
setGranted((held) =>
wanted ? [...held, scope] : held.filter((one) => one !== scope)
)
const submit = () => {
mint.mutate(normalised, {
onSuccess: (minted) => {
setJustMinted(minted.secret)
setName("")
},
onError: (error) =>
toast.add({
title: error instanceof Error ? error.message : "Could not mint",
type: "error",
}),
})
mint.mutate(
{ name: normalised, scopes: granted },
{
onSuccess: (minted) => {
setJustMinted(minted.secret)
setName("")
setGranted(["writeMetrics"])
},
onError: (error) =>
toast.add({
title: error instanceof Error ? error.message : "Could not mint",
type: "error",
}),
}
)
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Importer tokens</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
For automations that send daily metrics a Shortcut, Tasker, a cron
script. A token can only write metrics: it cannot read your entries or
touch your account. Tokens do not expire, so revoke any you stop
using.
</p>
<CollapsibleCard
title="Access tokens"
description="For automations and other clients — a widget, a Shortcut, a cron script."
summary={
existing?.length ? (
<Badge variant="secondary">{existing.length}</Badge>
) : undefined
}
>
<FieldGroup>
{justMinted && (
<div className="flex flex-col gap-1.5 rounded-md border border-dashed p-3">
<Label className="text-xs">
Copy this now it is not shown again
</Label>
<code className="text-xs break-all">{justMinted}</code>
<Button
variant="outline"
size="sm"
onClick={() => setJustMinted(null)}
>
Done
</Button>
</div>
<Alert>
<AlertTitle>Copy this now it is not shown again</AlertTitle>
<AlertDescription>
<code className="break-all">{justMinted}</code>
<Button
variant="outline"
size="sm"
onClick={() => setJustMinted(null)}
>
Done
</Button>
</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-1.5">
<Label className="text-xs">
Name becomes the source recorded on its data
</Label>
<div className="flex gap-2">
<Input
placeholder="iphone-shortcuts"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<Button
size="sm"
disabled={!canMint || mint.isPending}
onClick={submit}
>
{mint.isPending ? "Minting…" : "Mint"}
</Button>
</div>
{normalised.length > 0 && !canMint && (
<span className="text-xs text-destructive">
Lowercase letters, digits and hyphens only.
</span>
<Field data-invalid={normalised.length > 0 && !namedWell}>
<FieldLabel htmlFor="token-name">Name</FieldLabel>
<Input
id="token-name"
placeholder="plasma-widget"
value={name}
aria-invalid={normalised.length > 0 && !namedWell}
onChange={(event) => setName(event.target.value)}
/>
<FieldDescription>
Becomes the source recorded on any metrics it writes. Lowercase
letters, digits and hyphens.
</FieldDescription>
</Field>
<FieldSet>
<FieldLegend variant="label">What it may do</FieldLegend>
<FieldGroup className="gap-3">
{GRANTS.map((grant) => (
<Field key={grant.scope} orientation="horizontal">
<Checkbox
id={`scope-${grant.scope}`}
checked={granted.includes(grant.scope)}
onCheckedChange={(wanted) =>
toggle(grant.scope, wanted === true)
}
/>
<FieldContent>
<FieldLabel htmlFor={`scope-${grant.scope}`}>
{grant.title}
</FieldLabel>
<FieldDescription>{grant.describes}</FieldDescription>
</FieldContent>
</Field>
))}
</FieldGroup>
</FieldSet>
<Field orientation="horizontal">
<Button disabled={!canMint || mint.isPending} onClick={submit}>
{mint.isPending ? "Minting…" : "Mint token"}
</Button>
{granted.length === 0 && (
<FieldDescription>
Tick at least one thing for it to do.
</FieldDescription>
)}
</div>
</Field>
{existing?.length ? (
<div className="flex flex-col gap-2">
<FieldGroup className="gap-3">
{existing.map((token) => (
<TokenRow
key={token.id}
@@ -103,12 +177,12 @@ export function ApiTokensCard() {
onRevoke={() => revoke.mutate(token.id)}
/>
))}
</div>
</FieldGroup>
) : (
<span className="text-xs text-muted-foreground">No tokens yet.</span>
<FieldDescription>No tokens yet.</FieldDescription>
)}
</CardContent>
</Card>
</FieldGroup>
</CollapsibleCard>
)
}
@@ -124,14 +198,33 @@ function TokenRow({
: "never used"
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium">{token.name}</span>
<span className="text-[10px] text-muted-foreground">{lastUsed}</span>
</div>
<Field orientation="horizontal">
<FieldContent>
<FieldTitleRow name={token.name} scopes={token.scopes} />
<FieldDescription>{lastUsed}</FieldDescription>
</FieldContent>
<Button variant="ghost" size="icon" onClick={onRevoke}>
<Trash2 className="h-4 w-4 text-destructive" />
<Trash2 className="text-destructive" />
</Button>
</Field>
)
}
function FieldTitleRow({
name,
scopes,
}: {
name: string
scopes: TokenScope[]
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-xs font-medium">{name}</span>
{scopes.map((scope) => (
<Badge key={scope} variant="secondary">
{scope}
</Badge>
))}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import type { ReactNode } from "react"
import { ChevronDown } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
export function CollapsibleCard({
title,
description,
summary,
defaultOpen = false,
children,
}: {
title: string
description?: string
summary?: ReactNode
defaultOpen?: boolean
children: ReactNode
}) {
return (
<Card>
<Collapsible defaultOpen={defaultOpen}>
<CardHeader>
<CollapsibleTrigger className="group flex w-full cursor-pointer items-center justify-between gap-3 text-left">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="text-sm">{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</div>
<div className="flex shrink-0 items-center gap-2">
{summary}
<ChevronDown className="size-4 text-muted-foreground transition-transform duration-200 group-data-[panel-open]:rotate-180" />
</div>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 data-[ending-style]:h-0 data-[starting-style]:h-0">
<CardContent>{children}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)
}

View File

@@ -0,0 +1,24 @@
import { Badge } from "@/components/ui/badge"
import { CollapsibleCard } from "@/components/settings/collapsible-card"
import { ServerPicker } from "@/components/settings/server-picker"
import { isServerChosen, serverUrl } from "@/api/server-url"
import { useServerInfo } from "@/hooks/use-server"
export function ServerCard() {
const { data: info } = useServerInfo()
const elsewhere = isServerChosen()
return (
<CollapsibleCard
title="Server"
description="This app talks to whichever k-mood server you point it at."
summary={
<Badge variant={elsewhere ? "secondary" : "outline"}>
{elsewhere ? serverUrl() : "this site"}
</Badge>
}
>
<ServerPicker version={info?.version} />
</CollapsibleCard>
)
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Spinner } from "@/components/ui/spinner"
import {
forgetServer,
isServerChosen,
looksLikeAServer,
reachable,
rememberServer,
serverUrl,
} from "@/api/server-url"
type Checking = "idle" | "asking" | "unreachable"
export function ServerPicker({ version }: { version?: string }) {
const current = serverUrl()
const chosen = isServerChosen()
const [typed, setTyped] = useState(current)
const [checking, setChecking] = useState<Checking>("idle")
const tidy = typed.trim()
const usable = looksLikeAServer(tidy) && tidy !== current
const connect = async () => {
setChecking("asking")
if (await reachable(tidy)) {
rememberServer(tidy)
return
}
setChecking("unreachable")
}
return (
<FieldGroup>
<Field orientation="horizontal">
<FieldLabel>Connected to</FieldLabel>
<Badge variant="secondary">{current || "this site"}</Badge>
{version && <Badge variant="outline">v{version}</Badge>}
</Field>
<Field>
<FieldLabel htmlFor="server-url">Address</FieldLabel>
<Input
id="server-url"
placeholder="https://k-mood.example.com"
value={typed}
aria-invalid={tidy.length > 0 && !looksLikeAServer(tidy)}
onChange={(event) => {
setTyped(event.target.value)
setChecking("idle")
}}
/>
<FieldDescription>
The server must allow this site as a CORS origin, which whoever runs
it sets in its configuration.
</FieldDescription>
</Field>
{checking === "unreachable" && (
<Alert variant="destructive">
<AlertTitle>No k-mood server answered</AlertTitle>
<AlertDescription>
Nothing responded at that address, or it refused this site as an
origin. The address was not saved.
</AlertDescription>
</Alert>
)}
<Field orientation="horizontal">
<Button disabled={!usable || checking === "asking"} onClick={connect}>
{checking === "asking" && <Spinner data-icon="inline-start" />}
Connect
</Button>
{chosen && (
<Button variant="outline" onClick={forgetServer}>
Use this site again
</Button>
)}
</Field>
</FieldGroup>
)
}

View File

@@ -1,8 +1,15 @@
import { useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
import { CollapsibleCard } from "@/components/settings/collapsible-card"
import {
useConnectProvider,
useDisconnectProvider,
@@ -37,58 +44,74 @@ export function SubsonicCard() {
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Music</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{connected ? (
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
<CollapsibleCard
title="Music"
description="Connect your own Subsonic server to fill in what you were listening to."
summary={
<Badge variant={connected ? "secondary" : "outline"}>
{connected ? "connected" : "not connected"}
</Badge>
}
>
{connected ? (
<FieldGroup>
<Field orientation="horizontal">
<FieldDescription>
Subsonic is connected. Your password is stored encrypted and is
never shown again.
</p>
</FieldDescription>
<Button
variant="outline"
size="sm"
onClick={() => disconnect.mutate()}
disabled={disconnect.isPending}
>
Disconnect
</Button>
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
Connect your own Subsonic server to fill in what you were
listening to.
</p>
</Field>
</FieldGroup>
) : (
<FieldGroup>
<Field>
<FieldLabel htmlFor="subsonic-url">Address</FieldLabel>
<Input
id="subsonic-url"
placeholder="https://music.example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="subsonic-username">Username</FieldLabel>
<Input
placeholder="Username"
id="subsonic-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
/>
</Field>
<Field>
<FieldLabel htmlFor="subsonic-password">Password</FieldLabel>
<Input
id="subsonic-password"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="off"
/>
<FieldDescription>
Stored encrypted, and never shown again once saved.
</FieldDescription>
</Field>
<Field orientation="horizontal">
<Button
size="sm"
onClick={submit}
disabled={connect.isPending || !url || !username || !password}
>
{connect.isPending ? "Connecting…" : "Connect"}
</Button>
</>
)}
</CardContent>
</Card>
</Field>
</FieldGroup>
)}
</CollapsibleCard>
)
}

View File

@@ -0,0 +1,50 @@
import { useState } from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { MoodHeatmap } from "./mood-heatmap"
import { useYearCalendar } from "@/hooks/use-year-calendar"
import { useDateBounds } from "@/hooks/use-date-bounds"
export function HeatmapSection() {
const thisYear = new Date().getFullYear()
const [year, setYear] = useState(thisYear)
const { data: days, isLoading } = useYearCalendar(year)
const { data: bounds } = useDateBounds()
const earliestYear = bounds?.oldest.getFullYear() ?? thisYear
return (
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
aria-label={`Show ${year - 1}`}
onClick={() => setYear((current) => current - 1)}
disabled={year <= earliestYear}
>
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
</Button>
<span className="text-sm font-medium" aria-live="polite">
{year}
</span>
<Button
variant="ghost"
size="icon"
aria-label={`Show ${year + 1}`}
onClick={() => setYear((current) => current + 1)}
disabled={year >= thisYear}
>
<ChevronRight className="h-5 w-5" aria-hidden="true" />
</Button>
</div>
{isLoading ? (
<Skeleton className="h-32" />
) : days ? (
<MoodHeatmap year={year} days={days} />
) : null}
</section>
)
}

View File

@@ -1,27 +1,21 @@
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getMood } from "@/lib/mood"
import type { MoodFrequency } from "@/api/schema"
import type { MoodFrequencyResponse } from "@/api/schema"
interface MoodDistributionProps {
frequency: MoodFrequency[]
}
const COLORS: Record<number, string> = {
1: "#ef4444",
2: "#f97316",
3: "#eab308",
4: "#84cc16",
5: "#22c55e",
frequency: MoodFrequencyResponse[]
}
export function MoodDistribution({ frequency }: MoodDistributionProps) {
if (!frequency.length) return null
const data = frequency.map((f) => ({
name: getMood(f.mood).emoji + " " + f.moodLabel,
value: f.count,
mood: f.mood,
const data = frequency.map((entry) => ({
label: entry.moodLabel,
emoji: getMood(entry.mood).emoji,
swatch: getMood(entry.mood).swatch,
value: entry.count,
mood: entry.mood,
}))
const total = data.reduce((sum, d) => sum + d.value, 0)
@@ -33,37 +27,41 @@ export function MoodDistribution({ frequency }: MoodDistributionProps) {
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<ResponsiveContainer width={120} height={120}>
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={30}
outerRadius={55}
strokeWidth={2}
>
{data.map((d) => (
<Cell key={d.mood} fill={COLORS[d.mood]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col gap-1.5">
{data.map((d) => (
<div key={d.mood} className="flex items-center gap-2 text-sm">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: COLORS[d.mood] }}
/>
<span className="text-foreground">{d.name}</span>
<span className="text-foreground/70">
{d.value} ({Math.round((d.value / total) * 100)}%)
</span>
</div>
))}
<div aria-hidden="true">
<ResponsiveContainer width={120} height={120}>
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={30}
outerRadius={55}
strokeWidth={2}
>
{data.map((slice) => (
<Cell key={slice.mood} fill={slice.swatch} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
<ul className="flex list-none flex-col gap-1.5 p-0">
{data.map((slice) => (
<li key={slice.mood} className="flex items-center gap-2 text-sm">
<span
aria-hidden="true"
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: slice.swatch }}
/>
<span aria-hidden="true">{slice.emoji}</span>
<span className="text-foreground">{slice.label}</span>
<span className="text-foreground/70">
{slice.value} ({Math.round((slice.value / total) * 100)}%)
</span>
</li>
))}
</ul>
</div>
</CardContent>
</Card>

View File

@@ -9,7 +9,9 @@ import {
} from "date-fns"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { cn } from "@/lib/utils"
import { getMood } from "@/lib/mood"
import { getMood, nearestMood } from "@/lib/mood"
import { rowOfWeek, weekStartsOn } from "@/lib/week"
import { formatDate } from "@/lib/day"
import type { CalendarDayResponse } from "@/api/schema"
interface MoodHeatmapProps {
@@ -17,14 +19,6 @@ interface MoodHeatmapProps {
days: CalendarDayResponse[]
}
const MOOD_BG: Record<number, string> = {
1: "bg-red-500",
2: "bg-orange-400",
3: "bg-yellow-400",
4: "bg-lime-400",
5: "bg-green-500",
}
const MONTH_LABELS = [
"Jan",
"Feb",
@@ -52,7 +46,7 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
}
const allDays = eachDayOfInterval({ start: yearStart, end: yearEnd })
const weekStart = startOfWeek(yearStart)
const weekStart = startOfWeek(yearStart, { weekStartsOn: weekStartsOn() })
const totalWeeks = differenceInWeeks(yearEnd, weekStart) + 1
const grid: (Date | null)[][] = Array.from({ length: 7 }, () =>
@@ -61,7 +55,7 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
for (const day of allDays) {
const weekIndex = differenceInWeeks(day, weekStart)
const dayIndex = getDay(day)
const dayIndex = rowOfWeek(getDay(day))
grid[dayIndex][weekIndex] = day
}
@@ -71,9 +65,10 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
<CardTitle className="text-sm">Mood heatmap {year}</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<div className="flex min-w-[640px] gap-0.5">
<p className="sr-only">{summarise(year, moodMap)}</p>
<div aria-hidden="true" className="flex min-w-[640px] gap-0.5">
<div className="flex flex-col gap-0.5 pr-1 text-[9px] text-muted-foreground">
{["", "Mon", "", "Wed", "", "Fri", ""].map((label, i) => (
{rowLabels().map((label, i) => (
<div key={i} className="h-[11px] leading-[11px]">
{label}
</div>
@@ -105,7 +100,7 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
return <div key={weekIdx} className="h-[11px] w-[11px]" />
}
const dateStr = format(day, "yyyy-MM-dd")
const dateStr = formatDate(day)
const mood = moodMap.get(dateStr)
return (
@@ -113,7 +108,7 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
key={weekIdx}
className={cn(
"h-[11px] w-[11px] rounded-[2px]",
mood ? MOOD_BG[mood] : "bg-muted/30"
mood ? getMood(mood).background : "bg-muted/30"
)}
title={
mood
@@ -134,7 +129,10 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
{[1, 2, 3, 4, 5].map((m) => (
<div
key={m}
className={cn("h-[11px] w-[11px] rounded-[2px]", MOOD_BG[m])}
className={cn(
"h-[11px] w-[11px] rounded-[2px]",
getMood(m).background
)}
title={getMood(m).label}
/>
))}
@@ -144,3 +142,29 @@ export function MoodHeatmap({ year, days }: MoodHeatmapProps) {
</Card>
)
}
/**
* The grid is 365 coloured squares — as tab stops it would be unusable, and
* read out one by one it would be meaningless. This says the same thing in a
* sentence; the calendar is where a screen reader goes for a given day.
*/
function summarise(year: number, moods: Map<string, number>): string {
const logged = moods.size
if (logged === 0) return `Mood heatmap for ${year}. Nothing logged.`
const total = Array.from(moods.values()).reduce((sum, mood) => sum + mood, 0)
const average = nearestMood(total / logged)
return `Mood heatmap for ${year}. ${logged} ${
logged === 1 ? "day" : "days"
} logged, mostly ${average.label.toLowerCase()}.`
}
/** Every other row is named, starting from whichever day the week starts on. */
function rowLabels(): string[] {
const short = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
const start = weekStartsOn()
return short.map((_, row) => (row % 2 === 1 ? short[(row + start) % 7] : ""))
}

View File

@@ -0,0 +1,43 @@
import { StatCard } from "./stat-card"
import { nearestMood } from "@/lib/mood"
import type { MoodStatsResponse } from "@/api/schema"
const NO_VALUE = "—"
/**
* Average, streak and total, shown the same way wherever they appear — the
* dashboard and the statistics page were drifting into two different readings
* of the same three numbers.
*/
export function MoodSummary({ stats }: { stats: MoodStatsResponse }) {
const mood = stats.average === null ? null : nearestMood(stats.average)
return (
<div className="grid grid-cols-3 gap-3">
<StatCard
label="Average"
value={
mood ? (
<span className="inline-flex items-baseline gap-1">
<span aria-hidden="true">{mood.emoji}</span>
<span>{stats.average?.toFixed(1)}</span>
</span>
) : (
NO_VALUE
)
}
valueLabel={
mood
? `Average mood ${stats.average?.toFixed(1)} out of 5, ${mood.label}`
: "No average yet"
}
/>
<StatCard
label="Streak"
value={`${stats.currentStreak}d`}
valueLabel={`${stats.currentStreak} day streak`}
/>
<StatCard label="Entries" value={stats.totalEntries} />
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { format, startOfWeek, startOfMonth } from "date-fns"
import { useMemo } from "react"
import {
Area,
AreaChart,
@@ -9,24 +9,35 @@ import {
YAxis,
} from "recharts"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getMoodEmoji } from "@/lib/mood"
import { BEST_MOOD, MIDDLE_MOOD, WORST_MOOD, getMoodEmoji } from "@/lib/mood"
import { aggregateTrend, bucketFor } from "@/lib/trend-buckets"
import type { TrendBar } from "@/lib/trend-buckets"
import type { TrendPoint } from "@/hooks/use-trend"
import type { Period } from "@/lib/period"
const GRADIENT_ID = "moodTrendGradient"
const MAX_POINTS_WITH_DOTS = 60
const AXIS_PADDING = 0.5
interface MoodTrendChartProps {
entries: Array<{ loggedAt: string; mood: number }>
period?: "week" | "month" | "year" | "all"
points: TrendPoint[]
period: Period
title?: string
}
export function MoodTrendChart({
entries,
period = "month",
points,
period,
title = "Mood over time",
}: MoodTrendChartProps) {
const data = aggregate(entries, period)
const bars = useMemo(
() => aggregateTrend(points, bucketFor(period, points.length)),
[points, period]
)
if (data.length === 0) return null
if (bars.length === 0) return null
const showDots = data.length <= 60
const showDots = bars.length <= MAX_POINTS_WITH_DOTS
return (
<Card>
@@ -36,79 +47,71 @@ export function MoodTrendChart({
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<AreaChart
data={data}
data={bars}
margin={{ top: 5, right: 5, bottom: 0, left: -10 }}
>
<defs>
<linearGradient id="moodGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
<stop offset="50%" stopColor="#eab308" stopOpacity={0.2} />
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
<linearGradient id={GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={BEST_MOOD.swatch}
stopOpacity={0.4}
/>
<stop
offset="50%"
stopColor={MIDDLE_MOOD.swatch}
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor={WORST_MOOD.swatch}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "white" }}
tick={{ fontSize: 11, fill: "var(--foreground)" }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={50}
/>
<YAxis
domain={[0.5, 5.5]}
domain={[
WORST_MOOD.value - AXIS_PADDING,
BEST_MOOD.value + AXIS_PADDING,
]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 14 }}
tickFormatter={(v) => getMoodEmoji(v)}
tickFormatter={getMoodEmoji}
tickLine={false}
axisLine={false}
width={32}
/>
<Tooltip
content={(props) => {
const { active, payload } = props as {
active?: boolean
payload?: ReadonlyArray<{ payload: DataPoint }>
}
if (!active || !payload?.length) return null
const d = payload[0].payload
const mood = Math.round(d.avg)
return (
<div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
<p className="font-medium">{d.tooltip}</p>
<p>
{getMoodEmoji(mood)} {d.avg.toFixed(1)} avg
<span className="ml-1 text-muted-foreground">
({d.count} {d.count === 1 ? "entry" : "entries"})
</span>
</p>
</div>
)
}}
/>
<Tooltip content={<TrendTooltip />} />
<Area
type="natural"
dataKey="avg"
stroke="#22c55e"
fill="url(#moodGradient)"
dataKey="average"
stroke={BEST_MOOD.swatch}
fill={`url(#${GRADIENT_ID})`}
strokeWidth={2}
dot={
showDots
? {
r: 2.5,
fill: "hsl(var(--background))",
stroke: "#22c55e",
fill: "var(--popover)",
stroke: BEST_MOOD.swatch,
strokeWidth: 1.5,
}
: false
}
activeDot={{ r: 4, fill: "#22c55e" }}
activeDot={{ r: 4, fill: BEST_MOOD.swatch }}
/>
</AreaChart>
</ResponsiveContainer>
@@ -117,67 +120,25 @@ export function MoodTrendChart({
)
}
interface DataPoint {
label: string
tooltip: string
avg: number
count: number
}
function aggregate(
entries: Array<{ loggedAt: string; mood: number }>,
period: string
): DataPoint[] {
if (period === "year" || period === "all") {
return entries.length > 365
? aggregateBy(entries, "month")
: aggregateBy(entries, "week")
}
return aggregateBy(entries, "day")
}
function aggregateBy(
entries: Array<{ loggedAt: string; mood: number }>,
bucket: "day" | "week" | "month"
): DataPoint[] {
const groups = new Map<
string,
{ moods: number[]; label: string; tooltip: string }
>()
for (const e of entries) {
const d = new Date(e.loggedAt)
let key: string
let label: string
let tooltip: string
if (bucket === "month") {
key = format(startOfMonth(d), "yyyy-MM")
label = format(d, "MMM yyyy")
tooltip = format(d, "MMMM yyyy")
} else if (bucket === "week") {
const week = startOfWeek(d)
key = format(week, "yyyy-MM-dd")
label = format(week, "MMM d")
tooltip = `Week of ${format(week, "MMM d, yyyy")}`
} else {
key = format(d, "yyyy-MM-dd")
label = format(d, "MMM d")
tooltip = format(d, "EEE, MMM d")
}
if (!groups.has(key)) {
groups.set(key, { moods: [], label, tooltip })
}
groups.get(key)!.moods.push(e.mood)
function TrendTooltip(props: unknown) {
const { active, payload } = props as {
active?: boolean
payload?: ReadonlyArray<{ payload: TrendBar }>
}
return Array.from(groups.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([_, { moods, label, tooltip }]) => ({
label,
tooltip,
avg: moods.reduce((s, m) => s + m, 0) / moods.length,
count: moods.length,
}))
if (!active || !payload?.length) return null
const bar = payload[0].payload
return (
<div className="rounded-lg border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-md">
<p className="font-medium">{bar.tooltip}</p>
<p>
{getMoodEmoji(Math.round(bar.average))} {bar.average.toFixed(1)} avg
<span className="ml-1 text-muted-foreground">
({bar.entryCount} {bar.entryCount === 1 ? "entry" : "entries"})
</span>
</p>
</div>
)
}

View File

@@ -1,18 +1,26 @@
import { Card, CardContent } from "@/components/ui/card"
import type { ReactNode } from "react"
import { Card, CardContent } from "@/components/ui/card"
interface StatCardProps {
label: string
value: string | number
value: ReactNode
/** Read instead of `value` by assistive technology, when the value is a glyph. */
valueLabel?: string
icon?: ReactNode
}
export function StatCard({ label, value, icon }: StatCardProps) {
export function StatCard({ label, value, valueLabel, icon }: StatCardProps) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-1 py-4">
{icon && <span className="text-muted-foreground">{icon}</span>}
<span className="text-2xl font-bold">{value}</span>
<span
className="text-2xl font-bold"
aria-label={valueLabel}
role={valueLabel ? "img" : undefined}
>
{value}
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</CardContent>
</Card>

View File

@@ -1,78 +0,0 @@
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
/>
<ChevronUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
/>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,22 +0,0 @@
import { cn } from "@/lib/utils"
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<"div"> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
"--ratio": ratio,
} as React.CSSProperties
}
className={cn("relative aspect-(--ratio)", className)}
{...props}
/>
)
}
export { AspectRatio }

View File

@@ -1,208 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn("absolute inset-0 z-10 outline-none", className),
},
props
),
render,
state: {
slot: "attachment-trigger",
},
})
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}

View File

@@ -1,107 +0,0 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -1,122 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -1,129 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
),
},
props
),
render,
state: {
slot: "bubble-content",
},
})
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }

View File

@@ -1,88 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -1,239 +0,0 @@
import * as React from "react"
import useEmblaCarousel from "embla-carousel-react"
import type { UseEmblaCarouselType } from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}

View File

@@ -1,369 +0,0 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload]
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -1,298 +0,0 @@
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -1,193 +0,0 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -1,272 +0,0 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -1,4 +0,0 @@
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider"

View File

@@ -1,270 +0,0 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -1,51 +0,0 @@
"use client"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -1,159 +0,0 @@
"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -1,84 +0,0 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "lucide-react"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50",
containerClassName
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -1,202 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View File

@@ -1,26 +0,0 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }

View File

@@ -1,72 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
}
)
function Marker({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(markerVariants({ variant, className })),
},
props
),
render,
state: {
slot: "marker",
variant,
},
})
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export { Marker, MarkerIcon, MarkerContent, markerVariants }

View File

@@ -1,284 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
className
)}
{...props}
/>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn(
"min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -1,128 +0,0 @@
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ArrowDownIcon } from "lucide-react"
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className
)}
{...props}
/>
)
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
className
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className
)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}

View File

@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}

View File

@@ -1,65 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }

View File

@@ -1,171 +0,0 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
)
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Icon>
)
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
}

View File

@@ -1,133 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

@@ -1,83 +0,0 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -1,328 +0,0 @@
"use client"
import * as React from "react"
import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import type { Button } from "@/components/ui/button"
import { CheckIcon } from "lucide-react"
function Questionnaire({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Root>) {
return (
<QuestionnairePrimitive.Root
data-slot="questionnaire"
className={cn("flex w-full min-w-0 flex-col gap-4", className)}
{...props}
/>
)
}
function QuestionnaireProgress({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
return (
<QuestionnairePrimitive.Progress
data-slot="questionnaire-progress"
className={cn(
"min-h-[1lh] w-fit min-w-[14ch] text-xs font-medium text-muted-foreground tabular-nums",
className
)}
{...props}
/>
)
}
function QuestionnaireItem({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
return (
<QuestionnairePrimitive.Item
data-slot="questionnaire-item"
className={cn(
"flex min-w-0 flex-col gap-4 border-0 p-0 outline-none",
className
)}
{...props}
/>
)
}
function QuestionnaireTitle({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
return (
<QuestionnairePrimitive.Title
data-slot="questionnaire-title"
className={cn(
"font-heading text-base leading-snug font-medium text-pretty [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4",
className
)}
{...props}
/>
)
}
function QuestionnaireDescription({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
return (
<QuestionnairePrimitive.Description
data-slot="questionnaire-description"
className={cn("text-sm text-pretty text-muted-foreground", className)}
{...props}
/>
)
}
function QuestionnaireChoices({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
return (
<QuestionnairePrimitive.Choices
data-slot="questionnaire-choices"
className={cn(
"group/questionnaire-choices grid min-w-0 gap-2",
className
)}
{...props}
/>
)
}
function QuestionnaireChoice({
children,
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
return (
<QuestionnairePrimitive.Choice
data-slot="questionnaire-choice"
className={cn(
"group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border border-input bg-transparent px-3 py-2.5 text-start text-sm transition-colors outline-none select-none hover:bg-muted/50 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-3 has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive dark:bg-input/20 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted",
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<QuestionnairePrimitive.ChoiceInput
data-slot="questionnaire-choice-input"
className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span
aria-hidden="true"
data-slot="questionnaire-choice-indicator"
className="pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border border-input group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary"
>
<span
data-slot="questionnaire-choice-indicator-dot"
className="hidden size-2 rounded-full bg-primary-foreground group-data-[type=checkbox]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
/>
<CheckIcon
data-slot="questionnaire-choice-indicator-check"
className="hidden size-3.5 group-data-[type=radio]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
/>
</span>
<QuestionnairePrimitive.ChoiceLabel
data-slot="questionnaire-choice-label"
className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
>
{children}
</QuestionnairePrimitive.ChoiceLabel>
<QuestionnairePrimitive.ChoiceShortcut
data-slot="questionnaire-choice-shortcut"
className="pointer-events-none ms-auto hidden size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-md border border-input bg-background font-mono text-[0.625rem] leading-none font-medium text-muted-foreground group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[shortcut]/questionnaire-choice:inline-flex"
/>
</QuestionnairePrimitive.Choice>
)
}
function QuestionnaireChoiceDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="questionnaire-choice-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
function QuestionnaireInput({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
return (
<div
data-slot="questionnaire-input-wrapper"
className="group/questionnaire-input relative w-full min-w-0"
>
<QuestionnairePrimitive.Input
data-slot="questionnaire-input"
className={cn(
"h-8 min-h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 sm:min-h-0 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground",
className
)}
{...props}
/>
</div>
)
}
function QuestionnaireError({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
return (
<QuestionnairePrimitive.Error
data-slot="questionnaire-error"
className={cn("mt-2 text-sm text-destructive", className)}
{...props}
/>
)
}
function QuestionnaireActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="questionnaire-actions"
className={cn(
"grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2 sm:min-h-8",
className
)}
{...props}
/>
)
}
function QuestionnairePrevious({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Previous
data-slot="questionnaire-previous"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0",
className
)}
{...props}
>
{children ?? "Previous"}
</QuestionnairePrimitive.Previous>
)
}
function QuestionnaireSkip({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Skip
data-slot="questionnaire-skip"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0",
className
)}
{...props}
>
{children ?? "Skip"}
</QuestionnairePrimitive.Skip>
)
}
function QuestionnaireNext({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Next
data-slot="questionnaire-next"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
className
)}
{...props}
>
{children ?? "Next"}
</QuestionnairePrimitive.Next>
)
}
function QuestionnaireSubmit({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Submit
data-slot="questionnaire-submit"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
className
)}
{...props}
>
{children ?? "Submit"}
</QuestionnairePrimitive.Submit>
)
}
export {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
}

View File

@@ -1,36 +0,0 @@
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -1,50 +0,0 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -1,52 +0,0 @@
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,202 +0,0 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,135 +0,0 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -1,724 +0,0 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -1,114 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,87 +0,0 @@
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -1,44 +0,0 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -1,66 +0,0 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

31
spa/src/fonts.css Normal file
View File

@@ -0,0 +1,31 @@
/*
* Inter, latin subsets only. The package ships seven subsets and imports all
* of them; cyrillic, greek and vietnamese were ~85 kB of font nobody here was
* reading. Anything outside these ranges falls back to the system stack.
*/
@font-face {
font-family: "Inter Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("@fontsource-variable/inter/files/inter-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("@fontsource-variable/inter/files/inter-latin-ext-wght-normal.woff2")
format("woff2-variations");
unicode-range:
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

View File

@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { tokens } from "@/api/client"
import type { MintApiTokenRequest } from "@/api/schema"
const TOKENS = ["api-tokens"]
@@ -14,7 +15,7 @@ export function useMintApiToken() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (name: string) => tokens.mint(name),
mutationFn: (body: MintApiTokenRequest) => tokens.mint(body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: TOKENS }),
})
}

View File

@@ -1,13 +1,24 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { auth, users } from "@/api/client"
import { useNavigate } from "@tanstack/react-router"
import { auth, users } from "@/api/client"
import {
forgetSession,
hasSession,
refreshToken,
rememberSession,
} from "@/api/session"
/**
* The single read of the signed-in account. Everything that needs the profile
* goes through this key so one page load makes one request for it.
*/
export const CURRENT_USER_KEY = ["auth", "me"] as const
export function useAuth() {
const { data: user, isLoading } = useQuery({
queryKey: ["auth", "me"],
queryKey: CURRENT_USER_KEY,
queryFn: async () => {
const token = localStorage.getItem("accessToken")
if (!token) return null
if (!hasSession()) return null
try {
return await users.me()
@@ -31,12 +42,11 @@ export function useLogin() {
const navigate = useNavigate()
return useMutation({
meta: { success: "Welcome back!", silent: false },
meta: { silent: true, success: "Welcome back!" },
mutationFn: (body: { identifier: string; password: string }) =>
auth.login(body),
onSuccess: (data) => {
localStorage.setItem("accessToken", data.accessToken)
localStorage.setItem("refreshToken", data.refreshToken)
rememberSession(data)
queryClient.invalidateQueries({ queryKey: ["auth"] })
navigate({ to: "/" })
},
@@ -47,7 +57,7 @@ export function useRegister() {
const navigate = useNavigate()
return useMutation({
meta: { success: "Account created! Please log in." },
meta: { silent: true, success: "Account created! Please log in." },
mutationFn: (body: { username: string; email: string; password: string }) =>
auth.register(body),
onSuccess: () => {
@@ -63,14 +73,13 @@ export function useLogout() {
return useMutation({
meta: { silent: true },
mutationFn: async () => {
const refreshToken = localStorage.getItem("refreshToken")
if (refreshToken) {
await auth.logout(refreshToken)
const token = refreshToken()
if (token) {
await auth.logout(token)
}
},
onSettled: () => {
localStorage.removeItem("accessToken")
localStorage.removeItem("refreshToken")
forgetSession()
queryClient.clear()
navigate({ to: "/login" })
},

View File

@@ -1,10 +1,9 @@
import { useQuery } from "@tanstack/react-query"
import { entries } from "@/api/client"
import { format, startOfMonth, endOfMonth } from "date-fns"
import { rangeOfMonth } from "@/lib/instant-range"
export function useCalendar(month: Date) {
const from = format(startOfMonth(month), "yyyy-MM-dd'T'00:00:00+00:00")
const to = format(endOfMonth(month), "yyyy-MM-dd'T'23:59:59+00:00")
const { from, to } = rangeOfMonth(month)
return useQuery({
queryKey: ["calendar", from, to],

View File

@@ -1,24 +1,15 @@
import { useQuery } from "@tanstack/react-query"
import { entries } from "@/api/client"
import { useMoodStats } from "./use-stats"
/** The first and last day this account ever logged, if it has logged at all. */
export function useDateBounds() {
return useQuery({
queryKey: ["entries", "bounds"],
queryFn: async () => {
const all = await entries.list({ limit: 1, offset: 0 })
if (!all.length) return null
const newest = new Date(all[0].loggedAt)
const { data: stats } = useMoodStats("all")
const stats = await entries.stats()
const total = stats.totalEntries
if (!stats?.firstLoggedOn || !stats.lastLoggedOn) return { data: null }
if (total <= 1) return { oldest: newest, newest }
const last = await entries.list({ limit: 1, offset: total - 1 })
const oldest = last.length ? new Date(last[0].loggedAt) : newest
return { oldest, newest }
return {
data: {
oldest: new Date(stats.firstLoggedOn),
newest: new Date(stats.lastLoggedOn),
},
staleTime: 5 * 60_000,
})
}
}

View File

@@ -1,11 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type { QueryClient } from "@tanstack/react-query"
import { entries } from "@/api/client"
import type { CreateEntryRequest } from "@/api/schema"
export function useEntries(options?: { limit?: number; offset?: number }) {
export const ENTRY_PAGE_SIZE = 30
/** Everything a written entry can change. */
const JOURNAL_KEYS = [["entries"], ["stats"], ["trend"], ["calendar"]] as const
export function invalidateJournal(queryClient: QueryClient): void {
for (const queryKey of JOURNAL_KEYS) {
queryClient.invalidateQueries({ queryKey })
}
}
/** A narrowing of the journal. Every field left out widens the selection. */
export interface EntryFilters {
mood?: number
activity?: string
from?: string
to?: string
}
export function useEntryFeed(filters: EntryFilters = {}) {
return useInfiniteQuery({
queryKey: ["entries", "feed", filters],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
entries.list({ ...filters, limit: ENTRY_PAGE_SIZE, offset: pageParam }),
getNextPageParam: (page) =>
page.hasMore ? page.offset + page.limit : undefined,
})
}
export function useRecentEntries(limit: number) {
return useQuery({
queryKey: ["entries", { limit: options?.limit, offset: options?.offset }],
queryFn: () => entries.list(options),
queryKey: ["entries", "recent", limit],
queryFn: () => entries.list({ limit }),
})
}
@@ -23,10 +59,7 @@ export function useCreateEntry() {
return useMutation({
meta: { success: "Entry created" },
mutationFn: (body: CreateEntryRequest) => entries.create(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["entries"] })
queryClient.invalidateQueries({ queryKey: ["stats"] })
},
onSuccess: () => invalidateJournal(queryClient),
})
}
@@ -36,16 +69,6 @@ export function useDeleteEntry() {
return useMutation({
meta: { success: "Entry deleted" },
mutationFn: (id: string) => entries.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["entries"] })
queryClient.invalidateQueries({ queryKey: ["stats"] })
},
})
}
export function useMoodStats(from?: string, to?: string) {
return useQuery({
queryKey: ["stats", { from, to }],
queryFn: () => entries.stats(from && to ? { from, to } : undefined),
onSuccess: () => invalidateJournal(queryClient),
})
}

View File

@@ -0,0 +1,96 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { emptyDraft, isWorthKeeping } from "@/lib/entry-draft"
import type { EntryDraft } from "@/lib/entry-draft"
import { clearDraft, loadDraft, saveDraft } from "@/lib/store/draft-store"
const SAVE_AFTER_IDLE_FOR = 400
export interface DraftControls {
draft: EntryDraft
isLoading: boolean
/** True when what is on screen came back from a previous, abandoned session. */
wasRestored: boolean
set: <TKey extends keyof EntryDraft>(
key: TKey,
value: EntryDraft[TKey]
) => void
toggleActivity: (activityId: string) => void
discard: () => void
}
interface Options {
/** A draft to start from. Editing an existing entry supplies one. */
initial?: EntryDraft
/** Whether to keep this draft across visits. Only the new entry does. */
persist: boolean
}
export function useEntryDraft({ initial, persist }: Options): DraftControls {
const [draft, setDraft] = useState<EntryDraft>(initial ?? emptyDraft)
const [isLoading, setIsLoading] = useState(persist)
const [wasRestored, setWasRestored] = useState(false)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!persist) return
let stillMounted = true
loadDraft().then((stored) => {
if (!stillMounted) return
if (stored && isWorthKeeping(stored)) {
setDraft(stored)
setWasRestored(true)
}
setIsLoading(false)
})
return () => {
stillMounted = false
}
}, [persist])
useEffect(() => {
if (!persist || isLoading) return
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
if (isWorthKeeping(draft)) {
saveDraft(draft)
} else {
clearDraft()
}
}, SAVE_AFTER_IDLE_FOR)
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current)
}
}, [draft, persist, isLoading])
const set = useCallback(
<TKey extends keyof EntryDraft>(key: TKey, value: EntryDraft[TKey]) => {
setDraft((current) => ({ ...current, [key]: value }))
setWasRestored(false)
},
[]
)
const toggleActivity = useCallback((activityId: string) => {
setDraft((current) => ({
...current,
activityIds: current.activityIds.includes(activityId)
? current.activityIds.filter((id) => id !== activityId)
: [...current.activityIds, activityId],
}))
setWasRestored(false)
}, [])
const discard = useCallback(() => {
setDraft(emptyDraft())
setWasRestored(false)
if (persist) clearDraft()
}, [persist])
return { draft, isLoading, wasRestored, set, toggleActivity, discard }
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react"
interface InfiniteScroll {
hasMore: boolean
isLoading: boolean
loadMore: () => void
}
/**
* Loads the next page when a sentinel element scrolls into view. Returns the
* ref to hang on that sentinel.
*/
export function useInfiniteScroll({
hasMore,
isLoading,
loadMore,
}: InfiniteScroll) {
const sentinelRef = useRef<HTMLDivElement | null>(null)
const loadMoreRef = useRef(loadMore)
loadMoreRef.current = loadMore
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel || !hasMore || isLoading) return
const observer = new IntersectionObserver((observed) => {
if (observed.some((entry) => entry.isIntersecting)) {
loadMoreRef.current()
}
})
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMore, isLoading])
return sentinelRef
}

View File

@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { subDays } from "date-fns"
import { metrics } from "@/api/client"
import { formatDate } from "@/lib/day"
import type {
DailyMetricResponse,
MetricKind,
@@ -19,6 +21,7 @@ export function useSetDailyMetrics() {
const queryClient = useQueryClient()
return useMutation({
meta: { success: "Metrics saved" },
mutationFn: ({ date, values }: { date: string; values: MetricPayload[] }) =>
metrics.set(date, values),
onSuccess: () => queryClient.invalidateQueries({ queryKey: METRICS }),
@@ -30,3 +33,19 @@ export function byKind(
): Map<MetricKind, DailyMetricResponse> {
return new Map(stored?.map((metric) => [metric.kind, metric]) ?? [])
}
const DAYS_THAT_MAKE_A_HABIT = 30
/**
* Whether this account records daily metrics at all. Someone who has never
* entered one should not be shown a card asking about their steps.
*/
export function useTracksMetrics(): boolean {
const today = new Date()
const { data } = useDailyMetrics(
formatDate(subDays(today, DAYS_THAT_MAKE_A_HABIT)),
formatDate(today)
)
return (data?.length ?? 0) > 0
}

View File

@@ -0,0 +1,23 @@
import { useEntryDraft } from "./use-entry-draft"
import { useSaveNewEntry } from "./use-save-entry"
/**
* The new-entry form, wherever it is shown. The sheet and the full page share
* one stored draft, so opening one after the other picks up where it left off.
*/
export function useNewEntryForm(onSaved: () => void) {
const controls = useEntryDraft({ persist: true })
const save = useSaveNewEntry()
return {
controls,
isSaving: save.isPending,
submit: () =>
save.mutate(controls.draft, {
onSuccess: () => {
controls.discard()
onSaved()
},
}),
}
}

View File

@@ -0,0 +1,24 @@
import { useSyncExternalStore } from "react"
function subscribe(onChange: () => void): () => void {
window.addEventListener("online", onChange)
window.addEventListener("offline", onChange)
return () => {
window.removeEventListener("online", onChange)
window.removeEventListener("offline", onChange)
}
}
/**
* Whether the browser believes it has a connection. It can be wrong — a
* captive portal reads as online — so this steers the interface, and the
* outbox still decides what actually sent.
*/
export function useOnline(): boolean {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true
)
}

105
spa/src/hooks/use-outbox.ts Normal file
View File

@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { flushOutbox, isStuck, retryQueued } from "@/lib/outbox-sync"
import { dequeue, enqueue, queuedEntries } from "@/lib/store/outbox"
import type { QueuedEntry } from "@/lib/store/outbox"
import type { EntryDraft } from "@/lib/entry-draft"
import { invalidateJournal } from "./use-entries"
import { useOnline } from "./use-online"
export const OUTBOX_KEY = ["outbox"] as const
export function useQueuedEntries() {
return useQuery({
queryKey: OUTBOX_KEY,
queryFn: queuedEntries,
staleTime: Infinity,
})
}
export function useQueueEntry() {
const queryClient = useQueryClient()
return useMutation({
meta: { success: "Saved — it will send when you are back online" },
mutationFn: (draft: EntryDraft) => enqueue(draft),
onSuccess: () => queryClient.invalidateQueries({ queryKey: OUTBOX_KEY }),
})
}
export function useDiscardQueued() {
const queryClient = useQueryClient()
return useMutation({
meta: { success: "Discarded" },
mutationFn: (id: string) => dequeue(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: OUTBOX_KEY }),
})
}
export function useRetryQueued() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (queued: QueuedEntry) => retryQueued(queued),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: OUTBOX_KEY })
invalidateJournal(queryClient)
},
})
}
/**
* How long to wait after a failed pass before trying again. Without a pause
* the failure invalidates the queue, the queue wakes this effect, and it flushes
* straight into the same failure — a tight loop that burns an entry's attempts
* in a second. Regaining a connection resets it to the front.
*/
const RETRY_AFTER_SECONDS = [5, 15, 60, 300]
function pauseAfter(failures: number): number {
if (failures === 0) return 0
const index = Math.min(failures, RETRY_AFTER_SECONDS.length) - 1
return RETRY_AFTER_SECONDS[index] * 1000
}
/**
* Drains the outbox whenever there is something worth sending and a connection
* to send it over. Mounted once, in the authenticated layout.
*/
export function useOutboxSync() {
const queryClient = useQueryClient()
const online = useOnline()
const { data: pending } = useQueuedEntries()
const [failures, setFailures] = useState(0)
const wasOnline = useRef(online)
const sendable = pending?.filter((queued) => !isStuck(queued)).length ?? 0
const flush = useMutation({
meta: { silent: true },
mutationFn: flushOutbox,
onSuccess: (outcome) => {
setFailures((count) => (outcome.failed > 0 ? count + 1 : 0))
queryClient.invalidateQueries({ queryKey: OUTBOX_KEY })
if (outcome.sent > 0) invalidateJournal(queryClient)
},
onError: () => setFailures((count) => count + 1),
})
const { mutate: startFlush, isPending } = flush
// A connection that has just come back deserves an immediate try.
useEffect(() => {
if (online && !wasOnline.current) setFailures(0)
wasOnline.current = online
}, [online])
useEffect(() => {
if (!online || sendable === 0 || isPending) return
const timer = setTimeout(() => startFlush(), pauseAfter(failures))
return () => clearTimeout(timer)
}, [online, sendable, failures, isPending, startFlush])
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useMemo } from "react"
import type { MediaItem } from "@/lib/media"
/**
* Object URLs for media picked on this device, revoked as soon as the set they
* belong to is replaced. Without the revoke every discarded photo stays in
* memory for the life of the tab.
*/
export function usePreviewUrls(items: MediaItem[]): Map<string, string> {
const urls = useMemo(() => {
const created = new Map<string, string>()
for (const item of items) {
if (item.kind === "pending") {
created.set(item.localId, URL.createObjectURL(item.file))
}
}
return created
}, [items])
useEffect(() => {
return () => {
for (const url of urls.values()) URL.revokeObjectURL(url)
}
}, [urls])
return urls
}

Some files were not shown because too many files have changed in this diff Show More