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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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
56
spa/src/api/server-url.ts
Normal 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
45
spa/src/api/session.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
24
spa/src/components/auth/auth-card.tsx
Normal file
24
spa/src/components/auth/auth-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
spa/src/components/auth/form-error.tsx
Normal file
11
spa/src/components/auth/form-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
spa/src/components/auth/password-strength.tsx
Normal file
60
spa/src/components/auth/password-strength.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
spa/src/components/dashboard/metrics-card.tsx
Normal file
64
spa/src/components/dashboard/metrics-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
spa/src/components/dashboard/setup-card.tsx
Normal file
84
spa/src/components/dashboard/setup-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
spa/src/components/dashboard/today-card.tsx
Normal file
39
spa/src/components/dashboard/today-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
spa/src/components/dev/devtools.tsx
Normal file
11
spa/src/components/dev/devtools.tsx
Normal 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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
spa/src/components/entry/detail/entry-activities.tsx
Normal file
40
spa/src/components/entry/detail/entry-activities.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
spa/src/components/entry/detail/entry-photos.tsx
Normal file
43
spa/src/components/entry/detail/entry-photos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
spa/src/components/entry/draft-restored-notice.tsx
Normal file
19
spa/src/components/entry/draft-restored-notice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
spa/src/components/entry/entry-details.tsx
Normal file
85
spa/src/components/entry/entry-details.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
spa/src/components/entry/entry-filters.tsx
Normal file
96
spa/src/components/entry/entry-filters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
27
spa/src/components/entry/entry-missing.tsx
Normal file
27
spa/src/components/entry/entry-missing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
spa/src/components/entry/fields/location-field.tsx
Normal file
71
spa/src/components/entry/fields/location-field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
spa/src/components/entry/fields/song-field.tsx
Normal file
65
spa/src/components/entry/fields/song-field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
spa/src/components/entry/log-sheet.tsx
Normal file
47
spa/src/components/entry/log-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
spa/src/components/entry/pending-entries.tsx
Normal file
103
spa/src/components/entry/pending-entries.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
67
spa/src/components/layout/route-error.tsx
Normal file
67
spa/src/components/layout/route-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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")}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
91
spa/src/components/media/photo-lightbox.tsx
Normal file
91
spa/src/components/media/photo-lightbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
52
spa/src/components/metric/day-stepper.tsx
Normal file
52
spa/src/components/metric/day-stepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
spa/src/components/metric/metrics-editor.tsx
Normal file
114
spa/src/components/metric/metrics-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
62
spa/src/components/offline/connection-banner.tsx
Normal file
62
spa/src/components/offline/connection-banner.tsx
Normal 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)}…`
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
50
spa/src/components/settings/collapsible-card.tsx
Normal file
50
spa/src/components/settings/collapsible-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
spa/src/components/settings/server-card.tsx
Normal file
24
spa/src/components/settings/server-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
spa/src/components/settings/server-picker.tsx
Normal file
94
spa/src/components/settings/server-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
50
spa/src/components/stats/heatmap-section.tsx
Normal file
50
spa/src/components/stats/heatmap-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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] : ""))
|
||||
}
|
||||
|
||||
43
spa/src/components/stats/mood-summary.tsx
Normal file
43
spa/src/components/stats/mood-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
DirectionProvider,
|
||||
useDirection,
|
||||
} from "@base-ui/react/direction-provider"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
31
spa/src/fonts.css
Normal 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;
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
96
spa/src/hooks/use-entry-draft.ts
Normal file
96
spa/src/hooks/use-entry-draft.ts
Normal 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 }
|
||||
}
|
||||
37
spa/src/hooks/use-infinite-scroll.ts
Normal file
37
spa/src/hooks/use-infinite-scroll.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
23
spa/src/hooks/use-new-entry-form.ts
Normal file
23
spa/src/hooks/use-new-entry-form.ts
Normal 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()
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
24
spa/src/hooks/use-online.ts
Normal file
24
spa/src/hooks/use-online.ts
Normal 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
105
spa/src/hooks/use-outbox.ts
Normal 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])
|
||||
}
|
||||
29
spa/src/hooks/use-preview-urls.ts
Normal file
29
spa/src/hooks/use-preview-urls.ts
Normal 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
Reference in New Issue
Block a user