feat(client): qrcode checkin dialog
Signed-off-by: Noa Virellia <noa@requiem.garden>
This commit is contained in:
49
client/src/components/checkin/qr-dialog.tsx
Normal file
49
client/src/components/checkin/qr-dialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { QRCode } from '@/components/ui/shadcn-io/qr-code';
|
||||
import { useCheckinCode } from '@/hooks/data/useGetCheckInCode';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function QrDialog(
|
||||
{ eventId }: { eventId: string },
|
||||
) {
|
||||
const { data } = useCheckinCode(eventId);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-20">签到</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>QR Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
请工作人员扫描下面的二维码为你签到。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<QrDialogContent checkinCode={data.data.checkin_code} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function QrDialogContent({ checkinCode }: { checkinCode: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={checkinCode} className="size-60" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||
{checkinCode}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { CheckinCard } from './workbenchCards/checkin';
|
||||
|
||||
export function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<CheckinCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
client/src/components/ui/dialog.tsx
Normal file
141
client/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</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 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
86
client/src/components/ui/shadcn-io/qr-code/index.tsx
Normal file
86
client/src/components/ui/shadcn-io/qr-code/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { formatHex, oklch } from 'culori';
|
||||
import QR from 'qrcode';
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type QRCodeProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: string;
|
||||
foreground?: string;
|
||||
background?: string;
|
||||
robustness?: 'L' | 'M' | 'Q' | 'H';
|
||||
};
|
||||
|
||||
const oklchRegex = /oklch\(([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\)/;
|
||||
|
||||
const getOklch = (color: string, fallback: [number, number, number]) => {
|
||||
const oklchMatch = color.match(oklchRegex);
|
||||
|
||||
if (!oklchMatch) {
|
||||
return { l: fallback[0], c: fallback[1], h: fallback[2] };
|
||||
}
|
||||
|
||||
return {
|
||||
l: Number.parseFloat(oklchMatch[1]),
|
||||
c: Number.parseFloat(oklchMatch[2]),
|
||||
h: Number.parseFloat(oklchMatch[3]),
|
||||
};
|
||||
};
|
||||
|
||||
export const QRCode = ({
|
||||
data,
|
||||
foreground,
|
||||
background,
|
||||
robustness = 'M',
|
||||
className,
|
||||
...props
|
||||
}: QRCodeProps) => {
|
||||
const [svg, setSVG] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const foregroundColor =
|
||||
foreground ?? styles.getPropertyValue('--foreground');
|
||||
const backgroundColor =
|
||||
background ?? styles.getPropertyValue('--background');
|
||||
|
||||
const foregroundOklch = getOklch(
|
||||
foregroundColor,
|
||||
[0.21, 0.006, 285.885]
|
||||
);
|
||||
const backgroundOklch = getOklch(backgroundColor, [0.985, 0, 0]);
|
||||
|
||||
const newSvg = await QR.toString(data, {
|
||||
type: 'svg',
|
||||
color: {
|
||||
dark: formatHex(oklch({ mode: 'oklch', ...foregroundOklch })),
|
||||
light: formatHex(oklch({ mode: 'oklch', ...backgroundOklch })),
|
||||
},
|
||||
width: 200,
|
||||
errorCorrectionLevel: robustness,
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
setSVG(newSvg);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [data, foreground, background, robustness]);
|
||||
|
||||
if (!svg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('size-full', '[&_svg]:size-full', className)}
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required for SVG"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +1,30 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useCheckin } from '@/hooks/data/useCheckin';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { QrDialog } from '../checkin/qr-dialog';
|
||||
|
||||
export function CheckinCard() {
|
||||
const { mutateAsync, isPending } = useCheckin();
|
||||
const { data } = useUserInfo();
|
||||
const queryClient = useQueryClient();
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>签到状态</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<Button
|
||||
className="w-20"
|
||||
onClick={() => {
|
||||
mutateAsync().then(() => {
|
||||
toast('签到成功');
|
||||
void queryClient.invalidateQueries({ queryKey: ['userInfo'] });
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
toast('签到失败');
|
||||
});
|
||||
}}
|
||||
disabled={isPending || data.checkin !== null}
|
||||
>
|
||||
签到
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>签到状态</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{data.checkin !== null ? '已签到' : '未签到'}
|
||||
{data.checkin !== null && <span className="text-sm font-medium ml-2">{`${new Date(data.checkin).toLocaleString()}`}</span>}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">Day 1</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<QrDialog
|
||||
eventId="019b5bf2-90ce-75f5-93a4-a66914c2ef11"
|
||||
>
|
||||
</QrDialog>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { axiosClient } from '@/lib/axios';
|
||||
|
||||
export function useCheckin() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
return axiosClient.post<object>('/user/checkin');
|
||||
},
|
||||
});
|
||||
}
|
||||
17
client/src/hooks/data/useGetCheckInCode.ts
Normal file
17
client/src/hooks/data/useGetCheckInCode.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { axiosClient } from '@/lib/axios';
|
||||
|
||||
export function useCheckinCode(eventId: string) {
|
||||
return useSuspenseQuery({
|
||||
queryKey: ['getCheckinCode', eventId],
|
||||
queryFn: async () => {
|
||||
return axiosClient.get<{
|
||||
checkin_code: string;
|
||||
}>('/user/checkin', {
|
||||
params: {
|
||||
event_id: eventId,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function useValidateMagicLink(ticket: string) {
|
||||
return useSuspenseQuery({
|
||||
queryKey: ['validateMagicLink', ticket],
|
||||
queryFn: async () => {
|
||||
return axiosClient.get<{ jwt_token: string; email: string }>('/auth/magic/verify', { params: { token: ticket } });
|
||||
return axiosClient.get<{ access_token: string; refresh_token: string }>('/auth/magic/verify', { params: { token: ticket } });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useCallback } from 'react';
|
||||
import { removeToken } from '@/lib/token';
|
||||
import { clearTokens } from '@/lib/token';
|
||||
|
||||
export function useLogout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = useCallback(() => {
|
||||
removeToken();
|
||||
clearTokens();
|
||||
void navigate({ to: '/login' });
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@@ -42,9 +43,9 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-serif: 'Lora', serif;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-mono: "Noto Sans Mono", monospace;
|
||||
--font-serif: "Lora", serif;
|
||||
--radius: 0.5rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
@@ -73,23 +74,23 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: oklch(0.9816 0.0017 247.8390);
|
||||
--background: oklch(0.9816 0.0017 247.839);
|
||||
--foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--card: oklch(1.0000 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--popover: oklch(1.0000 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--primary: oklch(0.5502 0.1193 263.8209);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.7499 0.0898 239.3977);
|
||||
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--muted: oklch(0.9417 0.0052 247.8790);
|
||||
--muted: oklch(0.9417 0.0052 247.879);
|
||||
--muted-foreground: oklch(0.5575 0.0165 244.8933);
|
||||
--accent: oklch(0.9417 0.0052 247.8790);
|
||||
--accent: oklch(0.9417 0.0052 247.879);
|
||||
--accent-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--destructive: oklch(0.5915 0.2020 21.2388);
|
||||
--border: oklch(0.9109 0.0070 247.9014);
|
||||
--input: oklch(1.0000 0 0);
|
||||
--destructive: oklch(0.5915 0.202 21.2388);
|
||||
--border: oklch(0.9109 0.007 247.9014);
|
||||
--input: oklch(1 0 0);
|
||||
--ring: oklch(0.5502 0.1193 263.8209);
|
||||
--chart-1: oklch(0.5502 0.1193 263.8209);
|
||||
--chart-2: oklch(0.7499 0.0898 239.3977);
|
||||
@@ -99,15 +100,15 @@
|
||||
--sidebar: oklch(0.9632 0.0034 247.8585);
|
||||
--sidebar-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.9417 0.0052 247.8790);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.9417 0.0052 247.879);
|
||||
--sidebar-accent-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--sidebar-border: oklch(0.9109 0.0070 247.9014);
|
||||
--sidebar-border: oklch(0.9109 0.007 247.9014);
|
||||
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-serif: 'Lora', serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-serif: "Lora", serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--shadow-color: #000000;
|
||||
--shadow-opacity: 0.05;
|
||||
--shadow-blur: 0.5rem;
|
||||
@@ -118,52 +119,62 @@
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
|
||||
--shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.03);
|
||||
--shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 2px 4px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-lg: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 4px 6px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05), 0rem 8px 10px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-sm:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
|
||||
0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
|
||||
0rem 1px 2px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-md:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
|
||||
0rem 2px 4px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-lg:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
|
||||
0rem 4px 6px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-xl:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.05),
|
||||
0rem 8px 10px -1px hsl(0 0% 0% / 0.05);
|
||||
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.13);
|
||||
--tracking-normal: 0em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.2270 0.0120 270.8402);
|
||||
--background: oklch(0.227 0.012 270.8402);
|
||||
--foreground: oklch(0.9067 0 0);
|
||||
--card: oklch(0.2630 0.0127 258.3724);
|
||||
--card: oklch(0.263 0.0127 258.3724);
|
||||
--card-foreground: oklch(0.9067 0 0);
|
||||
--popover: oklch(0.2630 0.0127 258.3724);
|
||||
--popover: oklch(0.263 0.0127 258.3724);
|
||||
--popover-foreground: oklch(0.9067 0 0);
|
||||
--primary: oklch(0.5774 0.1248 263.3770);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--primary: oklch(0.5774 0.1248 263.377);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.7636 0.0866 239.8852);
|
||||
--secondary-foreground: oklch(0.2621 0.0095 248.1897);
|
||||
--muted: oklch(0.3006 0.0156 264.3078);
|
||||
--muted-foreground: oklch(0.7137 0.0192 261.3246);
|
||||
--accent: oklch(0.3006 0.0156 264.3078);
|
||||
--accent-foreground: oklch(0.9067 0 0);
|
||||
--destructive: oklch(0.5915 0.2020 21.2388);
|
||||
--destructive: oklch(0.5915 0.202 21.2388);
|
||||
--border: oklch(0.3451 0.0133 248.2124);
|
||||
--input: oklch(0.2630 0.0127 258.3724);
|
||||
--input: oklch(0.263 0.0127 258.3724);
|
||||
--ring: oklch(0.5502 0.1193 263.8209);
|
||||
--chart-1: oklch(0.5502 0.1193 263.8209);
|
||||
--chart-2: oklch(0.7499 0.0898 239.3977);
|
||||
--chart-3: oklch(0.4711 0.0998 264.0792);
|
||||
--chart-4: oklch(0.6689 0.0699 240.3096);
|
||||
--chart-5: oklch(0.5107 0.1098 263.6921);
|
||||
--sidebar: oklch(0.2270 0.0120 270.8402);
|
||||
--sidebar: oklch(0.227 0.012 270.8402);
|
||||
--sidebar-foreground: oklch(0.9067 0 0);
|
||||
--sidebar-primary: oklch(0.5502 0.1193 263.8209);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.3006 0.0156 264.3078);
|
||||
--sidebar-accent-foreground: oklch(0.9067 0 0);
|
||||
--sidebar-border: oklch(0.3451 0.0133 248.2124);
|
||||
--sidebar-ring: oklch(0.5502 0.1193 263.8209);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--destructive-foreground: oklch(1 0 0);
|
||||
--radius: 0.5rem;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-serif: 'Lora', serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-serif: "Lora", serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--shadow-color: #000000;
|
||||
--shadow-opacity: 0.3;
|
||||
--shadow-blur: 0.5rem;
|
||||
@@ -174,11 +185,21 @@
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
|
||||
--shadow-xs: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.15);
|
||||
--shadow-sm: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30);
|
||||
--shadow: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 1px 2px -1px hsl(0 0% 0% / 0.30);
|
||||
--shadow-md: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 2px 4px -1px hsl(0 0% 0% / 0.30);
|
||||
--shadow-lg: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 4px 6px -1px hsl(0 0% 0% / 0.30);
|
||||
--shadow-xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.30), 0rem 8px 10px -1px hsl(0 0% 0% / 0.30);
|
||||
--shadow-sm:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||
0rem 1px 2px -1px hsl(0 0% 0% / 0.3);
|
||||
--shadow:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||
0rem 1px 2px -1px hsl(0 0% 0% / 0.3);
|
||||
--shadow-md:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||
0rem 2px 4px -1px hsl(0 0% 0% / 0.3);
|
||||
--shadow-lg:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||
0rem 4px 6px -1px hsl(0 0% 0% / 0.3);
|
||||
--shadow-xl:
|
||||
0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.3),
|
||||
0rem 8px 10px -1px hsl(0 0% 0% / 0.3);
|
||||
--shadow-2xl: 0rem 0.1rem 0.5rem 0rem hsl(0 0% 0% / 0.75);
|
||||
}
|
||||
|
||||
@@ -190,4 +211,8 @@
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="dialog-overlay"] {
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AxiosError } from 'axios';
|
||||
import axios from 'axios';
|
||||
import type { AxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { router } from '@/lib/router';
|
||||
import { getToken, hasToken } from './token';
|
||||
import { doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token';
|
||||
|
||||
export const axiosClient = axios.create({
|
||||
baseURL: '/api/v1/',
|
||||
@@ -10,19 +10,35 @@ export const axiosClient = axios.create({
|
||||
axiosClient.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token !== null) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
type RetryConfig = AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
axiosClient.interceptors.response.use(undefined, async (error: AxiosError) => {
|
||||
if (error.response && error?.response.status === 401) {
|
||||
// TODO: refresh token
|
||||
if (!hasToken()) {
|
||||
await router.navigate({ to: '/login' });
|
||||
return;
|
||||
}
|
||||
else { return Promise.reject(error); }
|
||||
const originalRequest = error.config as RetryConfig | undefined;
|
||||
if (!error.response || error.response.status !== 401 || !originalRequest) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response.status === 401 && getRefreshToken() !== null) {
|
||||
try {
|
||||
const maybeRefreshTokenValue = await doRefreshToken();
|
||||
const { access_token, refresh_token } = maybeRefreshTokenValue.data;
|
||||
originalRequest.headers = originalRequest.headers ?? {};
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
setToken(access_token);
|
||||
setRefreshToken(refresh_token);
|
||||
return await axiosClient(originalRequest);
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof AxiosError && e.status === 401) {
|
||||
await router.navigate({ to: '/login' });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
@@ -13,3 +15,20 @@ export function removeToken() {
|
||||
export function hasToken() {
|
||||
return getToken() !== null;
|
||||
}
|
||||
|
||||
export function setRefreshToken(refreshToken: string) {
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
}
|
||||
|
||||
export function getRefreshToken() {
|
||||
return localStorage.getItem('refreshToken');
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
removeToken();
|
||||
setRefreshToken('');
|
||||
}
|
||||
|
||||
export async function doRefreshToken() {
|
||||
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { SectionCards } from '@/components/section-cards';
|
||||
import { CheckinCard } from '@/components/workbenchCards/checkin';
|
||||
import { hasToken } from '@/lib/token';
|
||||
|
||||
export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
component: Index,
|
||||
beforeLoad: async () => {
|
||||
loader: async () => {
|
||||
if (!hasToken()) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
@@ -13,6 +13,14 @@ export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
},
|
||||
});
|
||||
|
||||
function SectionCards() {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<CheckinCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Index() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { zodValidator } from '@tanstack/zod-adapter';
|
||||
import z from 'zod';
|
||||
import { LoginForm } from '@/components/login-form';
|
||||
import { useValidateMagicLink } from '@/hooks/data/useValidateMagicLink';
|
||||
import { setToken } from '@/lib/token';
|
||||
import { setRefreshToken, setToken } from '@/lib/token';
|
||||
|
||||
const loginMagicLinkReceiverSchema = z.object({
|
||||
ticket: z.string().optional(),
|
||||
@@ -18,7 +18,8 @@ function ReceiveMagicLinkComponent() {
|
||||
const { ticket } = Route.useSearch();
|
||||
const { data } = useValidateMagicLink(ticket!);
|
||||
|
||||
setToken(data.data.jwt_token);
|
||||
setToken(data.data.access_token);
|
||||
setRefreshToken(data.data.refresh_token);
|
||||
|
||||
return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user