fix: tighten sharing and shadcn composition
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export function AppToaster({ ...props }: ToasterProps) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
|
||||
};
|
||||
sync();
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AvatarFallbackWithName({
|
||||
name,
|
||||
initial,
|
||||
image,
|
||||
size = "default",
|
||||
className,
|
||||
fallbackStyle,
|
||||
title,
|
||||
}: {
|
||||
name?: string;
|
||||
initial?: string;
|
||||
image?: string | null;
|
||||
size?: "sm" | "default" | "lg";
|
||||
className?: string;
|
||||
fallbackStyle?: React.CSSProperties;
|
||||
title?: string;
|
||||
}) {
|
||||
const computedInitial = initial ?? name?.trim()[0]?.toUpperCase() ?? "?";
|
||||
const sizeClass =
|
||||
size === "sm" ? "size-7 text-xs" : size === "lg" ? "size-10 text-base" : "size-8 text-sm";
|
||||
return (
|
||||
<span title={title}>
|
||||
<Avatar className={cn(sizeClass, className)}>
|
||||
{image ? (
|
||||
<AvatarImage src={image} alt={name ?? ""} />
|
||||
) : (
|
||||
<AvatarFallback
|
||||
style={fallbackStyle}
|
||||
className={cn(!fallbackStyle && "bg-[var(--c-household)]")}
|
||||
>
|
||||
{computedInitial}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export function DashboardEditor({
|
||||
onClick={() => setPresetMenuOpen((o) => !o)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<LayoutGrid className="size-4 mr-1" />
|
||||
<LayoutGrid data-icon="inline-start" />
|
||||
Preset
|
||||
</Button>
|
||||
{presetMenuOpen && (
|
||||
@@ -173,7 +173,7 @@ export function DashboardEditor({
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
|
||||
<RotateCcw className="size-4 mr-1" />
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
@@ -182,7 +182,7 @@ export function DashboardEditor({
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Plus className="size-4 mr-1" />
|
||||
<Plus data-icon="inline-start" />
|
||||
Add widget
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { DashboardMeta } from "@/app/d/actions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
@@ -168,28 +169,32 @@ function DashboardKebab({
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
||||
<PenLine className="size-4 mr-2" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{!dashboard.isDefault && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
|
||||
>
|
||||
<Star className="size-4 mr-2" />
|
||||
Set as default
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
||||
<PenLine />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!dashboard.isDefault && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
|
||||
>
|
||||
<Star />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { setNotifChannel } from "@/app/settings/notify-actions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
type Channel = "push" | "inapp" | "ntfy";
|
||||
|
||||
@@ -46,12 +47,10 @@ export function NotifyChannelToggles({
|
||||
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
checked={value}
|
||||
disabled={isPending || disabled}
|
||||
onChange={(e) => toggle(key, e.target.checked)}
|
||||
className="size-4 cursor-pointer"
|
||||
onCheckedChange={(checked) => toggle(key, checked)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -101,7 +101,7 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
||||
if (status === "subscribed") {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
<span className="text-sm text-[var(--c-success)] dark:text-[var(--c-success)]">
|
||||
Push notifications enabled
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Link, Check, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -32,7 +33,9 @@ export function ShareButton({
|
||||
setShareUrl(result.url);
|
||||
setOpen(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create share link");
|
||||
const message = err instanceof Error ? err.message : "Failed to create share link";
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -41,6 +44,7 @@ export function ShareButton({
|
||||
if (!shareUrl) return;
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
@@ -49,7 +53,7 @@ export function ShareButton({
|
||||
<>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button variant="outline" onClick={share} disabled={isPending}>
|
||||
<Link />
|
||||
<Link data-icon="inline-start" />
|
||||
Share
|
||||
</Button>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
@@ -67,7 +71,7 @@ export function ShareButton({
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon" onClick={copyUrl} aria-label="Copy link">
|
||||
{copied ? <Check className="text-green-600" /> : <Copy />}
|
||||
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
+12
-17
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { getRegistry } from "@/modules/_core/registry";
|
||||
@@ -7,6 +8,7 @@ import { householdMembers, households, users } from "@/modules/_core/schema";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { NavIcon } from "@/components/nav-icon";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||
|
||||
type Variant = "side" | "top";
|
||||
|
||||
@@ -102,23 +104,16 @@ function HouseholdPill({
|
||||
return (
|
||||
<div className="household-pill" title={info.household.name}>
|
||||
<div style={{ display: "flex" }}>
|
||||
{visible.map((m, i) => {
|
||||
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||
return (
|
||||
<span
|
||||
key={m.id}
|
||||
className="avatar avatar-sm"
|
||||
style={{
|
||||
background: avatarColor(m.id),
|
||||
marginLeft: i > 0 ? -6 : 0,
|
||||
boxShadow: "0 0 0 1.5px var(--card)",
|
||||
}}
|
||||
title={m.name ?? m.email ?? undefined}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{visible.map((m, i) => (
|
||||
<AvatarFallbackWithName
|
||||
key={m.id}
|
||||
name={m.name ?? m.email ?? undefined}
|
||||
image={m.image}
|
||||
className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
|
||||
fallbackStyle={{ background: avatarColor(m.id) }}
|
||||
title={m.name ?? m.email ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="household-text" style={{ minWidth: 0 }}>
|
||||
<div
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -41,6 +42,11 @@ interface Props {
|
||||
signedIn?: boolean;
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS = PALETTES.map((p) => ({ label: p.label, value: p.id }));
|
||||
const FONT_PAIR_ITEMS = FONT_PAIRS.map((f) => ({ label: f.label, value: f.id }));
|
||||
const DASH_LAYOUT_ITEMS = DASH_LAYOUTS.map((d) => ({ label: d.label, value: d.id }));
|
||||
const NAV_STYLE_ITEMS = NAV_STYLES.map((n) => ({ label: n.label, value: n.id }));
|
||||
|
||||
export function ThemePicker({
|
||||
initialPalette = "clay",
|
||||
initialMode = "system",
|
||||
@@ -68,7 +74,11 @@ export function ThemePicker({
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Theme</Label>
|
||||
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
|
||||
<Select
|
||||
items={PALETTE_ITEMS}
|
||||
value={t.palette}
|
||||
onValueChange={(v) => t.setPalette(v as Palette)}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
@@ -84,20 +94,22 @@ export function ThemePicker({
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PALETTES.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<div className="flex items-center justify-between gap-6 w-40">
|
||||
<span>{p.label}</span>
|
||||
<span
|
||||
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
|
||||
borderColor: "var(--hair-2)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{PALETTES.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<div className="flex items-center justify-between gap-6 w-40">
|
||||
<span>{p.label}</span>
|
||||
<span
|
||||
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
|
||||
borderColor: "var(--hair-2)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -121,16 +133,22 @@ export function ThemePicker({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type pairing</Label>
|
||||
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
|
||||
<Select
|
||||
items={FONT_PAIR_ITEMS}
|
||||
value={t.fontPair}
|
||||
onValueChange={(v) => t.setFontPair(v as FontPair)}
|
||||
>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_PAIRS.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{FONT_PAIRS.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -154,16 +172,22 @@ export function ThemePicker({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Dashboard layout</Label>
|
||||
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
|
||||
<Select
|
||||
items={DASH_LAYOUT_ITEMS}
|
||||
value={t.dashLayout}
|
||||
onValueChange={(v) => t.setDashLayout(v as DashLayout)}
|
||||
>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DASH_LAYOUTS.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{DASH_LAYOUTS.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -187,16 +211,22 @@ export function ThemePicker({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Navigation</Label>
|
||||
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
|
||||
<Select
|
||||
items={NAV_STYLE_ITEMS}
|
||||
value={t.navStyle}
|
||||
onValueChange={(v) => t.setNavStyle(v as NavStyle)}
|
||||
>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NAV_STYLES.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{NAV_STYLES.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NotificationBell } from "@/components/notification-bell";
|
||||
import { TopbarSearch } from "@/components/topbar-search";
|
||||
import { TopbarTitle } from "@/components/topbar-title";
|
||||
import { TopbarNewButton } from "@/components/topbar-new-button";
|
||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||
|
||||
async function getNotifications(userId: string) {
|
||||
const rows = await db
|
||||
@@ -35,7 +36,6 @@ export async function Topbar() {
|
||||
? await getNotifications(userId)
|
||||
: { rows: [], unread: 0 };
|
||||
const userRow = userId ? await getUserAvatar(userId) : null;
|
||||
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
|
||||
|
||||
return (
|
||||
<div className="topbar">
|
||||
@@ -56,22 +56,12 @@ export async function Topbar() {
|
||||
)}
|
||||
<TopbarNewButton />
|
||||
{userId && (
|
||||
<span
|
||||
className="avatar"
|
||||
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
|
||||
<AvatarFallbackWithName
|
||||
name={userRow?.name ?? userRow?.email ?? undefined}
|
||||
image={userRow?.image}
|
||||
size="sm"
|
||||
title={userRow?.name ?? userRow?.email ?? undefined}
|
||||
>
|
||||
{userRow?.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={userRow.image}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</span>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user