style upgrades
This commit is contained in:
@@ -98,20 +98,21 @@ const Avatar = ({
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
variant="white"
|
||||
color="dark"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
aria-label="Close image preview"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: -10,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
onClick={() => setIsFullscreenOpen(false)}
|
||||
>
|
||||
<XIcon size={18} color="white" />
|
||||
<XIcon size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Image
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.time {
|
||||
transition: color 300ms ease-out;
|
||||
}
|
||||
|
||||
.seconds {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tick {
|
||||
animation: digit-tick 200ms ease-out;
|
||||
}
|
||||
|
||||
.finalTick {
|
||||
animation: final-tick 250ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes digit-tick {
|
||||
from {
|
||||
opacity: 0.2;
|
||||
transform: translateY(-0.12em);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes final-tick {
|
||||
from {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tick,
|
||||
.finalTick {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import useNow from '@/hooks/use-now';
|
||||
import { Text, Group } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import classes from './countdown.module.css';
|
||||
|
||||
interface CountdownProps {
|
||||
date: Date;
|
||||
@@ -17,7 +18,7 @@ interface TimeLeft {
|
||||
|
||||
function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft {
|
||||
const difference = targetDate.getTime() - currentTime.getTime();
|
||||
|
||||
|
||||
if (difference <= 0) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
}
|
||||
@@ -30,28 +31,45 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
|
||||
};
|
||||
}
|
||||
|
||||
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||
|
||||
export function Countdown({ date, label, color }: CountdownProps) {
|
||||
const now = useNow();
|
||||
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||
|
||||
const formatTime = () => {
|
||||
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||
|
||||
if (timeLeft.days > 0) {
|
||||
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||
} else {
|
||||
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||
}
|
||||
};
|
||||
const totalSecondsLeft = Math.max(
|
||||
0,
|
||||
Math.floor((date.getTime() - now.getTime()) / 1000)
|
||||
);
|
||||
const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
|
||||
|
||||
const prefix =
|
||||
timeLeft.days > 0
|
||||
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
||||
<Text size='sm' fw={600} c={color} ff="monospace">
|
||||
{formatTime()}
|
||||
<Text
|
||||
size='sm'
|
||||
fw={600}
|
||||
c={isFinalStretch ? 'red' : color}
|
||||
ff="monospace"
|
||||
className={classes.time}
|
||||
>
|
||||
{prefix}
|
||||
<span
|
||||
key={timeLeft.seconds}
|
||||
className={`${classes.seconds} ${
|
||||
isFinalStretch ? classes.finalTick : classes.tick
|
||||
}`}
|
||||
>
|
||||
{pad(timeLeft.seconds)}
|
||||
</span>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default Countdown;
|
||||
export default Countdown;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
const EmptyState = ({ icon, title, description, action }: EmptyStateProps) => {
|
||||
return (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack align="center" gap={4}>
|
||||
<Title order={3} c="dimmed" ta="center">
|
||||
{title}
|
||||
</Title>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
@@ -16,6 +16,7 @@ interface GlitchAvatarProps
|
||||
contain?: boolean;
|
||||
children?: React.ReactNode;
|
||||
px?: string | number;
|
||||
frame?: boolean;
|
||||
}
|
||||
|
||||
const GlitchAvatar = ({
|
||||
@@ -28,6 +29,7 @@ const GlitchAvatar = ({
|
||||
contain = false,
|
||||
children,
|
||||
px,
|
||||
frame = false,
|
||||
...props
|
||||
}: GlitchAvatarProps) => {
|
||||
const [showGlitch, setShowGlitch] = useState(false);
|
||||
@@ -37,13 +39,16 @@ const GlitchAvatar = ({
|
||||
useEffect(() => {
|
||||
if (!glitchSrc) return;
|
||||
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const scheduleNextGlitch = () => {
|
||||
const delay = Math.random() * 10000 + 5000;
|
||||
return setTimeout(() => {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShowGlitch(true);
|
||||
setIsPlaying(true);
|
||||
|
||||
setTimeout(() => {
|
||||
timeoutId = setTimeout(() => {
|
||||
setShowGlitch(false);
|
||||
setIsPlaying(false);
|
||||
scheduleNextGlitch();
|
||||
@@ -51,7 +56,7 @@ const GlitchAvatar = ({
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const timeoutId = scheduleNextGlitch();
|
||||
scheduleNextGlitch();
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [glitchSrc]);
|
||||
|
||||
@@ -94,12 +99,18 @@ const GlitchAvatar = ({
|
||||
? `${radius + 8}px`
|
||||
: "calc(var(--mantine-radius-md) + 8px)",
|
||||
position: "relative",
|
||||
...(frame && {
|
||||
boxShadow:
|
||||
"0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), 0 8px 32px -8px color-mix(in srgb, var(--mantine-primary-color-filled) 30%, transparent)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
opacity: showGlitch ? 0 : 1,
|
||||
transition: "opacity 0.05s ease-in-out",
|
||||
transition: showGlitch
|
||||
? "opacity 0.05s ease-in"
|
||||
: "opacity 0.25s ease-out",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
@@ -141,7 +152,7 @@ const GlitchAvatar = ({
|
||||
left: "8px",
|
||||
opacity: showGlitch ? 1 : 0,
|
||||
visibility: showGlitch ? "visible" : "hidden",
|
||||
transition: "opacity 0.05s ease-in-out",
|
||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
|
||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||
import styles from "./list-row.module.css";
|
||||
|
||||
interface ListButtonProps {
|
||||
label: string;
|
||||
@@ -12,6 +13,7 @@ const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
|
||||
return (
|
||||
<>
|
||||
<UnstyledButton
|
||||
className={styles.listRow}
|
||||
w="100%"
|
||||
p="md"
|
||||
component={"button"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Divider, NavLink, Text } from "@mantine/core";
|
||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import styles from "./list-row.module.css";
|
||||
|
||||
interface ListLinkProps {
|
||||
label: string;
|
||||
@@ -15,6 +16,7 @@ const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
|
||||
return (
|
||||
<>
|
||||
<NavLink
|
||||
className={styles.listRow}
|
||||
disabled={disabled}
|
||||
w="100%"
|
||||
p="md"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.listRow {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.listRow:hover:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.listRow:active:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.listRow {
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
|
||||
.listRow:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Title,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { Box, Container, Title, useComputedColorScheme } from "@mantine/core";
|
||||
import { PropsWithChildren, useEffect, useRef } from "react";
|
||||
import { Drawer as VaulDrawer } from "vaul";
|
||||
import { CHROME_COLORS, setThemeColorMeta } from "@/lib/mantine/theme-colors";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface DrawerProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
onClose?: () => void;
|
||||
onExited?: () => void;
|
||||
}
|
||||
|
||||
const Drawer: React.FC<DrawerProps> = ({
|
||||
@@ -19,55 +17,25 @@ const Drawer: React.FC<DrawerProps> = ({
|
||||
children,
|
||||
opened,
|
||||
onChange,
|
||||
onExited,
|
||||
}) => {
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const openedRef = useRef(opened);
|
||||
|
||||
useEffect(() => {
|
||||
openedRef.current = opened;
|
||||
}, [opened]);
|
||||
if (!opened) return;
|
||||
|
||||
useEffect(() => {
|
||||
const appElement = document.querySelector(".app") as HTMLElement;
|
||||
const appElement = document.querySelector<HTMLElement>(".app");
|
||||
const colors = CHROME_COLORS[colorScheme];
|
||||
|
||||
if (!appElement) return;
|
||||
|
||||
let themeColorMeta = document.querySelector(
|
||||
'meta[name="theme-color"]'
|
||||
) as HTMLMetaElement;
|
||||
if (!themeColorMeta) {
|
||||
themeColorMeta = document.createElement("meta");
|
||||
themeColorMeta.name = "theme-color";
|
||||
document.head.appendChild(themeColorMeta);
|
||||
}
|
||||
|
||||
const colors = {
|
||||
light: {
|
||||
normal: "rgb(255,255,255)",
|
||||
overlay: "rgb(153,153,153)",
|
||||
},
|
||||
dark: {
|
||||
normal: "rgb(36,36,36)",
|
||||
overlay: "rgb(22,22,22)",
|
||||
},
|
||||
};
|
||||
|
||||
const currentColors = colors[colorScheme] || colors.light;
|
||||
|
||||
if (opened) {
|
||||
appElement.classList.add("drawer-scaling");
|
||||
themeColorMeta.content = currentColors.overlay;
|
||||
} else {
|
||||
appElement.classList.remove("drawer-scaling");
|
||||
themeColorMeta.content = currentColors.normal;
|
||||
}
|
||||
appElement?.classList.add("drawer-scaling");
|
||||
setThemeColorMeta(colors.dimmed);
|
||||
|
||||
return () => {
|
||||
appElement.classList.remove("drawer-scaling");
|
||||
themeColorMeta.content = currentColors.normal;
|
||||
appElement?.classList.remove("drawer-scaling");
|
||||
setThemeColorMeta(colors.base);
|
||||
};
|
||||
}, [opened]);
|
||||
}, [opened, colorScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !contentRef.current) return;
|
||||
@@ -109,6 +77,9 @@ const Drawer: React.FC<DrawerProps> = ({
|
||||
repositionInputs={false}
|
||||
open={opened}
|
||||
onOpenChange={onChange}
|
||||
onAnimationEnd={(open) => {
|
||||
if (!open) onExited?.();
|
||||
}}
|
||||
>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||
|
||||
@@ -5,13 +5,30 @@ interface ModalProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onChange?: (next: boolean) => void;
|
||||
onExited?: () => void;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
title,
|
||||
children,
|
||||
opened,
|
||||
onClose,
|
||||
onExited,
|
||||
}) => (
|
||||
<MantineModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>{title}</Title>}
|
||||
radius={20}
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
duration: 200,
|
||||
timingFunction: "ease-out",
|
||||
onExited,
|
||||
}}
|
||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
||||
closeButtonProps={{ "aria-label": "Close" }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { PropsWithChildren, Suspense, useCallback } from "react";
|
||||
import {
|
||||
PropsWithChildren,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import Drawer from "./drawer";
|
||||
import Modal from "./modal";
|
||||
@@ -10,13 +17,35 @@ interface SheetProps extends PropsWithChildren {
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const DRAWER_EXIT_MS = 500;
|
||||
const MODAL_EXIT_MS = 200;
|
||||
const EXIT_BUFFER_MS = 100;
|
||||
|
||||
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const handleClose = useCallback(() => onChange(false), [onChange]);
|
||||
|
||||
const [mounted, setMounted] = useState(opened);
|
||||
const openedRef = useRef(opened);
|
||||
openedRef.current = opened;
|
||||
const handleExited = useCallback(() => {
|
||||
if (!openedRef.current) setMounted(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setMounted(true);
|
||||
return;
|
||||
}
|
||||
const exitDuration =
|
||||
(isMobile ? DRAWER_EXIT_MS : MODAL_EXIT_MS) + EXIT_BUFFER_MS;
|
||||
const timer = window.setTimeout(() => setMounted(false), exitDuration);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [opened, isMobile]);
|
||||
|
||||
const SheetComponent = isMobile ? Drawer : Modal;
|
||||
|
||||
if (!opened) return null;
|
||||
if (!opened && !mounted) return null;
|
||||
|
||||
return (
|
||||
<SheetComponent
|
||||
@@ -24,6 +53,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
opened={opened}
|
||||
onChange={onChange}
|
||||
onClose={handleClose}
|
||||
onExited={handleExited}
|
||||
>
|
||||
<Suspense fallback={
|
||||
<Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
|
||||
|
||||
@@ -150,7 +150,11 @@ const SlidePanel = ({
|
||||
{panelConfig && (
|
||||
<>
|
||||
<Group justify="space-between" p="md" align="center" w="100%">
|
||||
<ActionIcon variant="transparent" onClick={closePanel}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
onClick={closePanel}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeftIcon size={24} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>{panelConfig.title}</Text>
|
||||
@@ -158,6 +162,7 @@ const SlidePanel = ({
|
||||
variant="transparent"
|
||||
color="green"
|
||||
onClick={handleConfirm}
|
||||
aria-label="Confirm"
|
||||
>
|
||||
<CheckIcon size={24} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -20,3 +20,9 @@
|
||||
outline: none;
|
||||
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawerContent {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user