mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 05:41:59 +00:00
fix: improve the style for the web
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/client/styles.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": false,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
+7
-1
@@ -39,12 +39,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.102.7",
|
"@tanstack/react-query": "^5.102.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"hono": "^4.13.5",
|
"hono": "^4.13.5",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.34.0",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19",
|
"react": "19",
|
||||||
"react-dom": "19",
|
"react-dom": "19",
|
||||||
"recharts": "^3"
|
"recharts": "^3",
|
||||||
|
"shadcn": "^4.19.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"workerd",
|
"workerd",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type FormEvent, useState } from 'react';
|
import { type FormEvent, useState } from 'react';
|
||||||
import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2, Zap } from 'lucide-react';
|
import { ArrowRight, Database, History, Pencil, Power, PowerOff, RefreshCw, Trash2, Zap } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import type { Monitor, MonitorInput, MonitorMethod } from '../api/monitors';
|
import type { Monitor, MonitorInput, MonitorMethod } from '../api/monitors';
|
||||||
import { SiteIcon } from '../components/SiteIcon';
|
import { SiteIcon } from '../components/SiteIcon';
|
||||||
import { useLogoutMutation } from '../queries/auth';
|
import { useLogoutMutation } from '../queries/auth';
|
||||||
@@ -112,12 +114,18 @@ export function DashboardPage() {
|
|||||||
<a className="nav-auth" href="/">
|
<a className="nav-auth" href="/">
|
||||||
View status page
|
View status page
|
||||||
</a>
|
</a>
|
||||||
<button className="nav-auth" type="button" onClick={() => navigate('/settings')}>
|
<Button variant="unstyled" className="nav-auth" type="button" onClick={() => navigate('/settings')}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</Button>
|
||||||
<button className="nav-auth" type="button" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="nav-auth"
|
||||||
|
type="button"
|
||||||
|
onClick={() => logoutMutation.mutate()}
|
||||||
|
disabled={logoutMutation.isPending}
|
||||||
|
>
|
||||||
{logoutMutation.isPending ? 'Signing out…' : 'Sign out'}
|
{logoutMutation.isPending ? 'Signing out…' : 'Sign out'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -129,14 +137,10 @@ export function DashboardPage() {
|
|||||||
<h1>Monitors</h1>
|
<h1>Monitors</h1>
|
||||||
<p>Track endpoint availability from Cloudflare's edge every five minutes.</p>
|
<p>Track endpoint availability from Cloudflare's edge every five minutes.</p>
|
||||||
</div>
|
</div>
|
||||||
{formOpen ? (
|
{monitorsQuery.isSuccess && monitors.length > 0 ? (
|
||||||
<button className="secondary-button" type="button" onClick={closeForm}>
|
<Button variant="unstyled" className="primary-button" type="button" onClick={openCreateForm}>
|
||||||
Close form
|
|
||||||
</button>
|
|
||||||
) : monitorsQuery.isSuccess && monitors.length > 0 ? (
|
|
||||||
<button className="primary-button" type="button" onClick={openCreateForm}>
|
|
||||||
Add monitor <ArrowRight />
|
Add monitor <ArrowRight />
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -158,15 +162,26 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{formOpen && (
|
<Dialog
|
||||||
<section className="monitor-form-panel" aria-labelledby="monitor-form-title">
|
open={formOpen}
|
||||||
<div className="form-panel-heading">
|
onOpenChange={(open) => {
|
||||||
<div>
|
if (!open) closeForm();
|
||||||
<p className="overline">Configuration</p>
|
}}
|
||||||
<h2 id="monitor-form-title">{editing ? `Edit ${editing.name}` : 'Add a monitor'}</h2>
|
>
|
||||||
</div>
|
<DialogContent
|
||||||
<p>Checks run on the configured schedule, with a minimum interval of five minutes.</p>
|
className="max-h-[85vh] w-[calc(100%-2rem)] overflow-y-auto sm:max-w-3xl"
|
||||||
</div>
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (formMutation.isPending) event.preventDefault();
|
||||||
|
}}
|
||||||
|
onInteractOutside={(event) => {
|
||||||
|
if (formMutation.isPending) event.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<p className="overline">Configuration</p>
|
||||||
|
<DialogTitle>{editing ? `Edit ${editing.name}` : 'Add a monitor'}</DialogTitle>
|
||||||
|
<DialogDescription>Checks run on the configured schedule, with a minimum interval of five minutes.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
<form className="monitor-form" onSubmit={handleSubmit}>
|
<form className="monitor-form" onSubmit={handleSubmit}>
|
||||||
<label className="field field-name">
|
<label className="field field-name">
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
@@ -243,12 +258,12 @@ export function DashboardPage() {
|
|||||||
<span>Enable incident alerts</span>
|
<span>Enable incident alerts</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-actions compact-actions">
|
<div className="form-actions compact-actions">
|
||||||
<button className="secondary-button" type="button" onClick={closeForm}>
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={closeForm}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button className="primary-button" type="submit" disabled={formMutation.isPending}>
|
<Button variant="unstyled" className="primary-button" type="submit" disabled={formMutation.isPending}>
|
||||||
{formMutation.isPending ? 'Saving…' : editing ? 'Save changes' : 'Add monitor'}
|
{formMutation.isPending ? 'Saving…' : editing ? 'Save changes' : 'Add monitor'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{formMutation.isError && (
|
{formMutation.isError && (
|
||||||
<p className="form-error" role="alert">
|
<p className="form-error" role="alert">
|
||||||
@@ -256,8 +271,8 @@ export function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</DialogContent>
|
||||||
)}
|
</Dialog>
|
||||||
|
|
||||||
<section className="services-panel" aria-labelledby="monitor-list-title">
|
<section className="services-panel" aria-labelledby="monitor-list-title">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
@@ -265,7 +280,8 @@ export function DashboardPage() {
|
|||||||
<h2 id="monitor-list-title">Configured sites</h2>
|
<h2 id="monitor-list-title">Configured sites</h2>
|
||||||
<p>Latest result for each monitored endpoint.</p>
|
<p>Latest result for each monitored endpoint.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void monitorsQuery.refetch()}
|
onClick={() => void monitorsQuery.refetch()}
|
||||||
@@ -273,7 +289,7 @@ export function DashboardPage() {
|
|||||||
aria-label="Refresh monitors"
|
aria-label="Refresh monitors"
|
||||||
>
|
>
|
||||||
<RefreshCw className={monitorsQuery.isFetching ? 'is-spinning' : ''} />
|
<RefreshCw className={monitorsQuery.isFetching ? 'is-spinning' : ''} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{monitorsQuery.isPending ? (
|
{monitorsQuery.isPending ? (
|
||||||
@@ -290,9 +306,9 @@ export function DashboardPage() {
|
|||||||
<div className="panel-state error-state">
|
<div className="panel-state error-state">
|
||||||
<strong>Monitors could not be loaded</strong>
|
<strong>Monitors could not be loaded</strong>
|
||||||
<p>{errorMessage(monitorsQuery.error, 'Unknown request error')}</p>
|
<p>{errorMessage(monitorsQuery.error, 'Unknown request error')}</p>
|
||||||
<button className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void monitorsQuery.refetch()}>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : monitors.length === 0 ? (
|
) : monitors.length === 0 ? (
|
||||||
<div className="panel-state empty-state">
|
<div className="panel-state empty-state">
|
||||||
@@ -302,9 +318,9 @@ export function DashboardPage() {
|
|||||||
<strong>No monitors yet</strong>
|
<strong>No monitors yet</strong>
|
||||||
<p>Add the first endpoint to start collecting availability checks.</p>
|
<p>Add the first endpoint to start collecting availability checks.</p>
|
||||||
{!formOpen && (
|
{!formOpen && (
|
||||||
<button className="primary-button" type="button" onClick={openCreateForm}>
|
<Button variant="unstyled" className="primary-button" type="button" onClick={openCreateForm}>
|
||||||
Add first site <ArrowRight />
|
Add first site <ArrowRight />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -326,9 +342,14 @@ export function DashboardPage() {
|
|||||||
<SiteIcon key={monitor.url} monitorId={monitor.id} />
|
<SiteIcon key={monitor.url} monitorId={monitor.id} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="monitor-name-link"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/monitors/${monitor.id}`)}
|
||||||
|
>
|
||||||
{monitor.name}
|
{monitor.name}
|
||||||
</button>
|
</Button>
|
||||||
<small title={monitor.url}>{monitor.url}</small>
|
<small title={monitor.url}>{monitor.url}</small>
|
||||||
<span className="monitor-meta">
|
<span className="monitor-meta">
|
||||||
{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
|
{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
|
||||||
@@ -347,11 +368,17 @@ export function DashboardPage() {
|
|||||||
<small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small>
|
<small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions" aria-label={`Actions for ${monitor.name}`}>
|
<div className="row-actions" aria-label={`Actions for ${monitor.name}`}>
|
||||||
<button className="row-action row-action-labeled" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="row-action row-action-labeled"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/monitors/${monitor.id}`)}
|
||||||
|
>
|
||||||
<History aria-hidden="true" />
|
<History aria-hidden="true" />
|
||||||
<span>History</span>
|
<span>History</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="row-action row-action-labeled row-action-accent"
|
className="row-action row-action-labeled row-action-accent"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => checkMutation.mutate(monitor.id)}
|
onClick={() => checkMutation.mutate(monitor.id)}
|
||||||
@@ -360,8 +387,9 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={checking ? 'is-spinning' : ''} aria-hidden="true" />
|
<RefreshCw className={checking ? 'is-spinning' : ''} aria-hidden="true" />
|
||||||
<span>{checking ? 'Checking' : 'Check now'}</span>
|
<span>{checking ? 'Checking' : 'Check now'}</span>
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="row-action row-action-icon"
|
className="row-action row-action-icon"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEditForm(monitor)}
|
onClick={() => openEditForm(monitor)}
|
||||||
@@ -369,8 +397,9 @@ export function DashboardPage() {
|
|||||||
title="Edit monitor"
|
title="Edit monitor"
|
||||||
>
|
>
|
||||||
<Pencil aria-hidden="true" />
|
<Pencil aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="row-action row-action-icon"
|
className="row-action row-action-icon"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
|
onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })}
|
||||||
@@ -379,8 +408,9 @@ export function DashboardPage() {
|
|||||||
title={`${monitor.enabled ? 'Disable' : 'Enable'} monitor`}
|
title={`${monitor.enabled ? 'Disable' : 'Enable'} monitor`}
|
||||||
>
|
>
|
||||||
{monitor.enabled ? <PowerOff aria-hidden="true" /> : <Power aria-hidden="true" />}
|
{monitor.enabled ? <PowerOff aria-hidden="true" /> : <Power aria-hidden="true" />}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="row-action row-action-icon danger-action"
|
className="row-action row-action-icon danger-action"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(monitor)}
|
onClick={() => handleDelete(monitor)}
|
||||||
@@ -390,7 +420,7 @@ export function DashboardPage() {
|
|||||||
aria-busy={deleting}
|
aria-busy={deleting}
|
||||||
>
|
>
|
||||||
<Trash2 aria-hidden="true" />
|
<Trash2 aria-hidden="true" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useEffect, useState } from 'react';
|
import { type FormEvent, useEffect, useState } from 'react';
|
||||||
import { Zap } from 'lucide-react';
|
import { Zap } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLoginMutation, useSessionQuery } from '../queries/auth';
|
import { useLoginMutation, useSessionQuery } from '../queries/auth';
|
||||||
|
|
||||||
@@ -53,9 +54,9 @@ export function LoginPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button className="auth-submit" type="submit" disabled={loginMutation.isPending}>
|
<Button variant="unstyled" className="auth-submit" type="submit" disabled={loginMutation.isPending}>
|
||||||
{loginMutation.isPending ? 'Signing in…' : 'Sign in'}
|
{loginMutation.isPending ? 'Signing in…' : 'Sign in'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
|
import { ArrowLeft, BellOff, CheckCircle2, Clock3, ExternalLink, RefreshCw, Zap } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { LatencySparkline } from '../components/charts/LatencySparkline';
|
import { LatencySparkline } from '../components/charts/LatencySparkline';
|
||||||
import { UptimeBar } from '../components/charts/UptimeBar';
|
import { UptimeBar } from '../components/charts/UptimeBar';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
@@ -54,9 +55,9 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
<div className="detail-error">
|
<div className="detail-error">
|
||||||
<strong>Monitor not found</strong>
|
<strong>Monitor not found</strong>
|
||||||
<p>The requested monitor could not be loaded.</p>
|
<p>The requested monitor could not be loaded.</p>
|
||||||
<button className="secondary-button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="secondary-button" onClick={() => navigate('/dashboard')}>
|
||||||
Return to dashboard
|
Return to dashboard
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -64,24 +65,24 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<header className="dashboard-header">
|
<header className="dashboard-header">
|
||||||
<div className="dashboard-header-inner">
|
<div className="dashboard-header-inner">
|
||||||
<button className="brand brand-button" type="button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="brand brand-button" type="button" onClick={() => navigate('/dashboard')}>
|
||||||
<Zap className="brand-mark" fill="currentColor" />
|
<Zap className="brand-mark" fill="currentColor" />
|
||||||
<span>upwatch</span>
|
<span>upwatch</span>
|
||||||
</button>
|
</Button>
|
||||||
<div className="nav-actions">
|
<div className="nav-actions">
|
||||||
<button className="nav-auth" onClick={() => navigate('/settings')}>
|
<Button variant="unstyled" className="nav-auth" onClick={() => navigate('/settings')}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</Button>
|
||||||
<button className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
|
<Button variant="unstyled" className="nav-auth" onClick={() => logoutMutation.mutate()} disabled={logoutMutation.isPending}>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="dashboard-main detail-main">
|
<main className="dashboard-main detail-main">
|
||||||
<button className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
||||||
<ArrowLeft /> All monitors
|
<ArrowLeft /> All monitors
|
||||||
</button>
|
</Button>
|
||||||
<section className="detail-hero">
|
<section className="detail-hero">
|
||||||
<div className="detail-title">
|
<div className="detail-title">
|
||||||
<span className={`status-orb ${status.className}`} />
|
<span className={`status-orb ${status.className}`} />
|
||||||
@@ -104,9 +105,15 @@ export function MonitorDetailPage({ id }: { id: number }) {
|
|||||||
<BellOff /> Alerts muted
|
<BellOff /> Alerts muted
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button className="primary-button" type="button" onClick={() => checkMutation.mutate(id)} disabled={checkMutation.isPending}>
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="primary-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => checkMutation.mutate(id)}
|
||||||
|
disabled={checkMutation.isPending}
|
||||||
|
>
|
||||||
{checkMutation.isPending ? 'Checking…' : 'Check now'}
|
{checkMutation.isPending ? 'Checking…' : 'Check now'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type FormEvent, useState } from 'react';
|
import { type FormEvent, useState } from 'react';
|
||||||
import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
|
import { ArrowLeft, BellRing, Send, Zap } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import type { NotificationSettings } from '../api/settings';
|
import type { NotificationSettings } from '../api/settings';
|
||||||
import { navigate } from '../lib/router';
|
import { navigate } from '../lib/router';
|
||||||
import { useLogoutMutation } from '../queries/auth';
|
import { useLogoutMutation } from '../queries/auth';
|
||||||
@@ -45,17 +46,18 @@ function SettingsForm({ settings }: { settings: NotificationSettings }) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => testMutation.mutate()}
|
onClick={() => testMutation.mutate()}
|
||||||
disabled={!settings.webhookUrl || testMutation.isPending}
|
disabled={!settings.webhookUrl || testMutation.isPending}
|
||||||
>
|
>
|
||||||
<Send /> {testMutation.isPending ? 'Sending…' : 'Send test'}
|
<Send /> {testMutation.isPending ? 'Sending…' : 'Send test'}
|
||||||
</button>
|
</Button>
|
||||||
<button className="primary-button" type="submit" disabled={updateMutation.isPending}>
|
<Button variant="unstyled" className="primary-button" type="submit" disabled={updateMutation.isPending}>
|
||||||
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
|
{updateMutation.isPending ? 'Saving…' : 'Save settings'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
|
{updateMutation.isSuccess && <p className="settings-success">Notification settings saved.</p>}
|
||||||
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>}
|
{testMutation.isSuccess && <p className="settings-success">Test webhook delivered successfully.</p>}
|
||||||
@@ -73,22 +75,22 @@ export function SettingsPage() {
|
|||||||
<div className="dashboard-shell">
|
<div className="dashboard-shell">
|
||||||
<header className="dashboard-header">
|
<header className="dashboard-header">
|
||||||
<div className="dashboard-header-inner">
|
<div className="dashboard-header-inner">
|
||||||
<button className="brand brand-button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="brand brand-button" onClick={() => navigate('/dashboard')}>
|
||||||
<Zap className="brand-mark" fill="currentColor" />
|
<Zap className="brand-mark" fill="currentColor" />
|
||||||
<span>upwatch</span>
|
<span>upwatch</span>
|
||||||
</button>
|
</Button>
|
||||||
<div className="nav-actions">
|
<div className="nav-actions">
|
||||||
<span className="header-context">Settings</span>
|
<span className="header-context">Settings</span>
|
||||||
<button className="nav-auth" onClick={() => logoutMutation.mutate()}>
|
<Button variant="unstyled" className="nav-auth" onClick={() => logoutMutation.mutate()}>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="settings-main">
|
<main className="settings-main">
|
||||||
<button className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
<Button variant="unstyled" className="back-link" type="button" onClick={() => navigate('/dashboard')}>
|
||||||
<ArrowLeft /> Dashboard
|
<ArrowLeft /> Dashboard
|
||||||
</button>
|
</Button>
|
||||||
<section className="settings-heading">
|
<section className="settings-heading">
|
||||||
<p className="overline">Integrations</p>
|
<p className="overline">Integrations</p>
|
||||||
<h1>Notifications</h1>
|
<h1>Notifications</h1>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
|
import { Activity, CircleCheck, Database, RefreshCw, TriangleAlert, Zap } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
|
import type { PublicOverallStatus, PublicServiceStatus } from '../api/status';
|
||||||
import { SiteIcon } from '../components/SiteIcon';
|
import { SiteIcon } from '../components/SiteIcon';
|
||||||
import { StatusHistoryBar } from '../components/StatusHistoryBar';
|
import { StatusHistoryBar } from '../components/StatusHistoryBar';
|
||||||
@@ -65,13 +66,14 @@ export function StatusPage() {
|
|||||||
<Zap className="brand-mark" fill="currentColor" />
|
<Zap className="brand-mark" fill="currentColor" />
|
||||||
<span>upwatch</span>
|
<span>upwatch</span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="status-header-action"
|
className="status-header-action"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(sessionQuery.data?.authenticated ? '/dashboard' : '/login')}
|
onClick={() => navigate(sessionQuery.data?.authenticated ? '/dashboard' : '/login')}
|
||||||
>
|
>
|
||||||
{sessionQuery.data?.authenticated ? 'Dashboard' : 'Sign in'}
|
{sessionQuery.data?.authenticated ? 'Dashboard' : 'Sign in'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -102,9 +104,9 @@ export function StatusPage() {
|
|||||||
</span>
|
</span>
|
||||||
<strong>Status could not be loaded</strong>
|
<strong>Status could not be loaded</strong>
|
||||||
<p>{errorMessage(statusQuery.error)}</p>
|
<p>{errorMessage(statusQuery.error)}</p>
|
||||||
<button className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
|
<Button variant="unstyled" className="secondary-button" type="button" onClick={() => void statusQuery.refetch()}>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</Button>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -125,7 +127,8 @@ export function StatusPage() {
|
|||||||
<h2 id="public-services-title">Services</h2>
|
<h2 id="public-services-title">Services</h2>
|
||||||
<p>Availability is calculated from checks collected over the last 90 days.</p>
|
<p>Availability is calculated from checks collected over the last 90 days.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void statusQuery.refetch()}
|
onClick={() => void statusQuery.refetch()}
|
||||||
@@ -133,7 +136,7 @@ export function StatusPage() {
|
|||||||
aria-label="Refresh service status"
|
aria-label="Refresh service status"
|
||||||
>
|
>
|
||||||
<RefreshCw className={statusQuery.isFetching ? 'is-spinning' : ''} />
|
<RefreshCw className={statusQuery.isFetching ? 'is-spinning' : ''} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.services.length === 0 ? (
|
{status.services.length === 0 ? (
|
||||||
|
|||||||
+118
-43
@@ -1,5 +1,9 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'DM Sans', 'Helvetica Neue', sans-serif;
|
font-family: 'DM Sans', 'Helvetica Neue', sans-serif;
|
||||||
@@ -8,13 +12,45 @@
|
|||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
--primary: #3ecf8e;
|
--primary: #3ecf8e;
|
||||||
|
--shadcn-primary: oklch(0.205 0 0);
|
||||||
--primary-deep: #24b47e;
|
--primary-deep: #24b47e;
|
||||||
--ink: #171717;
|
--ink: #171717;
|
||||||
--muted: #707070;
|
--muted: #707070;
|
||||||
|
--shadcn-muted: oklch(0.97 0 0);
|
||||||
--faint: #9a9a9a;
|
--faint: #9a9a9a;
|
||||||
--hairline: #dfdfdf;
|
--hairline: #dfdfdf;
|
||||||
--soft: #fafafa;
|
--soft: #fafafa;
|
||||||
--night: #1c1c1c;
|
--night: #1c1c1c;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -253,35 +289,6 @@ button {
|
|||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.monitor-form-panel {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding: 28px;
|
|
||||||
border: 1px solid #dcdcdc;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 8px 28px rgb(29 74 54 / 0.06);
|
|
||||||
}
|
|
||||||
.form-panel-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 32px;
|
|
||||||
padding-bottom: 24px;
|
|
||||||
border-bottom: 1px solid #ededed;
|
|
||||||
}
|
|
||||||
.form-panel-heading h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: -0.4px;
|
|
||||||
}
|
|
||||||
.form-panel-heading > p {
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.monitor-form {
|
.monitor-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -575,7 +582,7 @@ button {
|
|||||||
}
|
}
|
||||||
.row-actions {
|
.row-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto repeat(3, 34px);
|
grid-template-columns: auto auto repeat(3, 38px);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -587,11 +594,11 @@ button {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 34px;
|
min-height: 38px;
|
||||||
padding: 6px 10px;
|
padding: 7px 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: #626262;
|
color: #626262;
|
||||||
@@ -606,12 +613,12 @@ button {
|
|||||||
}
|
}
|
||||||
.row-action svg {
|
.row-action svg {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 14px;
|
width: 15px;
|
||||||
height: 14px;
|
height: 15px;
|
||||||
stroke-width: 1.8;
|
stroke-width: 1.8;
|
||||||
}
|
}
|
||||||
.row-action-icon {
|
.row-action-icon {
|
||||||
width: 34px;
|
width: 38px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.row-action:hover:not(:disabled) {
|
.row-action:hover:not(:disabled) {
|
||||||
@@ -1920,13 +1927,6 @@ button {
|
|||||||
.metric-card p {
|
.metric-card p {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.monitor-form-panel {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.form-panel-heading {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.monitor-form {
|
.monitor-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -2138,3 +2138,78 @@ button {
|
|||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--shadcn-muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--shadcn-primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--shadcn-primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--shadcn-muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-oklch(0.922 0 0) border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-oklch(0.708 0 0) focus-visible:ring-3 focus-visible:ring-oklch(0.708 0 0)/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-oklch(0.577 0.245 27.325) aria-invalid:ring-3 aria-invalid:ring-oklch(0.577 0.245 27.325)/20 dark:aria-invalid:border-oklch(0.577 0.245 27.325)/50 dark:aria-invalid:ring-oklch(0.577 0.245 27.325)/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 dark:border-oklch(1 0 0 / 10%) dark:focus-visible:border-oklch(0.556 0 0) dark:focus-visible:ring-oklch(0.556 0 0)/50 dark:aria-invalid:border-oklch(0.704 0.191 22.216) dark:aria-invalid:ring-oklch(0.704 0.191 22.216)/20 dark:dark:aria-invalid:border-oklch(0.704 0.191 22.216)/50 dark:dark:aria-invalid:ring-oklch(0.704 0.191 22.216)/40 cursor-pointer",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
unstyled: null,
|
||||||
|
default:
|
||||||
|
'bg-oklch(0.205 0 0) text-oklch(0.985 0 0) hover:bg-oklch(0.205 0 0)/80 dark:bg-oklch(0.922 0 0) dark:text-oklch(0.205 0 0) dark:hover:bg-oklch(0.922 0 0)/80',
|
||||||
|
outline:
|
||||||
|
'border-oklch(0.922 0 0) bg-oklch(1 0 0) hover:bg-oklch(0.97 0 0) hover:text-oklch(0.145 0 0) aria-expanded:bg-oklch(0.97 0 0) aria-expanded:text-oklch(0.145 0 0) dark:border-oklch(0.922 0 0) dark:bg-oklch(0.922 0 0)/30 dark:hover:bg-oklch(0.922 0 0)/50 dark:border-oklch(1 0 0 / 10%) dark:bg-oklch(0.145 0 0) dark:hover:bg-oklch(0.269 0 0) dark:hover:text-oklch(0.985 0 0) dark:aria-expanded:bg-oklch(0.269 0 0) dark:aria-expanded:text-oklch(0.985 0 0) dark:dark:border-oklch(1 0 0 / 15%) dark:dark:bg-oklch(1 0 0 / 15%)/30 dark:dark:hover:bg-oklch(1 0 0 / 15%)/50',
|
||||||
|
secondary:
|
||||||
|
'bg-oklch(0.97 0 0) text-oklch(0.205 0 0) hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-oklch(0.97 0 0) aria-expanded:text-oklch(0.205 0 0) dark:bg-oklch(0.269 0 0) dark:text-oklch(0.985 0 0) dark:aria-expanded:bg-oklch(0.269 0 0) dark:aria-expanded:text-oklch(0.985 0 0)',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-oklch(0.97 0 0) hover:text-oklch(0.145 0 0) aria-expanded:bg-oklch(0.97 0 0) aria-expanded:text-oklch(0.145 0 0) dark:hover:bg-oklch(0.97 0 0)/50 dark:hover:bg-oklch(0.269 0 0) dark:hover:text-oklch(0.985 0 0) dark:aria-expanded:bg-oklch(0.269 0 0) dark:aria-expanded:text-oklch(0.985 0 0) dark:dark:hover:bg-oklch(0.269 0 0)/50',
|
||||||
|
destructive:
|
||||||
|
'bg-oklch(0.577 0.245 27.325)/10 text-oklch(0.577 0.245 27.325) hover:bg-oklch(0.577 0.245 27.325)/20 focus-visible:border-oklch(0.577 0.245 27.325)/40 focus-visible:ring-oklch(0.577 0.245 27.325)/20 dark:bg-oklch(0.577 0.245 27.325)/20 dark:hover:bg-oklch(0.577 0.245 27.325)/30 dark:focus-visible:ring-oklch(0.577 0.245 27.325)/40 dark:bg-oklch(0.704 0.191 22.216)/10 dark:text-oklch(0.704 0.191 22.216) dark:hover:bg-oklch(0.704 0.191 22.216)/20 dark:focus-visible:border-oklch(0.704 0.191 22.216)/40 dark:focus-visible:ring-oklch(0.704 0.191 22.216)/20 dark:dark:bg-oklch(0.704 0.191 22.216)/20 dark:dark:hover:bg-oklch(0.704 0.191 22.216)/30 dark:dark:focus-visible:ring-oklch(0.704 0.191 22.216)/40',
|
||||||
|
link: 'text-oklch(0.205 0 0) underline-offset-4 hover:underline dark:text-oklch(0.922 0 0)',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg 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 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
icon: 'size-8',
|
||||||
|
'icon-xs':
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : 'button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={
|
||||||
|
variant === 'unstyled'
|
||||||
|
? cn('cursor-pointer disabled:cursor-not-allowed disabled:opacity-50', className)
|
||||||
|
: cn(buttonVariants({ variant, size, className }))
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-white p-6 text-sm text-neutral-900 shadow-xl ring-1 ring-black/10 duration-100 outline-none sm:max-w-sm 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 dark:bg-neutral-900 dark:text-neutral-50 dark:ring-white/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
className="absolute top-4 right-4 inline-flex size-8 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:pointer-events-none disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-50"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close dialog"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" aria-hidden="true" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
'-mx-6 -mb-6 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-neutral-100/50 p-6 sm:flex-row sm:justify-end dark:bg-neutral-800/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return <DialogPrimitive.Title data-slot="dialog-title" className={cn('text-base leading-none font-medium', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
'text-sm text-neutral-500 *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-neutral-900 dark:text-neutral-400 dark:*:[a]:hover:text-neutral-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { cloudflare } from '@cloudflare/vite-plugin';
|
import { cloudflare } from '@cloudflare/vite-plugin';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), cloudflare()],
|
plugins: [react(), tailwindcss(), cloudflare()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(import.meta.dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user