Aurora SpotiRip desktop and web app

- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
This commit is contained in:
2026-09-25 23:20:57 -05:00
commit 3178b8f4f7
204 changed files with 26045 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules/
venv/
__pycache__/
dist/
dist-electron/
release/
build/linux/
build/windows/
build/mac/
env.json
+168
View File
@@ -0,0 +1,168 @@
import React, { useEffect } from "react";
import { BrowserRouter, Routes, Route, Outlet } from "react-router-dom";
import { GlobalContextProviders } from "./components/_globalContextProviders";
import Page_0 from "./pages/_index.tsx";
import PageLayout_0 from "./pages/_index.pageLayout.tsx";
import Page_1 from "./pages/identify.tsx";
import PageLayout_1 from "./pages/identify.pageLayout.tsx";
import Page_2 from "./pages/spotify.callback.tsx";
import PageLayout_2 from "./pages/spotify.callback.pageLayout.tsx";
import Page_3 from "./pages/settings.tsx";
import PageLayout_3 from "./pages/settings.pageLayout.tsx";
import Page_4 from "./pages/convert.tsx";
import PageLayout_4 from "./pages/convert.pageLayout.tsx";
import Page_5 from "./pages/quality.tsx";
import PageLayout_5 from "./pages/quality.pageLayout.tsx";
import Page_6 from "./pages/history.tsx";
import PageLayout_6 from "./pages/history.pageLayout.tsx";
import Page_7 from "./pages/analyze.tsx";
import PageLayout_7 from "./pages/analyze.pageLayout.tsx";
import Page_8 from "./pages/library.tsx";
import PageLayout_8 from "./pages/library.pageLayout.tsx";
import Page_9 from "./pages/spotify-library.tsx";
import PageLayout_9 from "./pages/spotify-library.pageLayout.tsx";
import Page_10 from "./pages/artist.$id.tsx";
import PageLayout_10 from "./pages/artist.$id.pageLayout.tsx";
import Page_11 from "./pages/album.$id.tsx";
import PageLayout_11 from "./pages/album.$id.pageLayout.tsx";
if (!window.requestIdleCallback) {
window.requestIdleCallback = (cb) => {
setTimeout(cb, 1);
};
}
import "./base.css";
import "./global.css";
const fileNameToRoute = new Map([["./pages/_index.tsx","/"],["./pages/identify.tsx","/identify"],["./pages/spotify.callback.tsx","/spotify/callback"],["./pages/settings.tsx","/settings"],["./pages/convert.tsx","/convert"],["./pages/quality.tsx","/quality"],["./pages/history.tsx","/history"],["./pages/analyze.tsx","/analyze"],["./pages/library.tsx","/library"],["./pages/spotify-library.tsx","/spotify-library"],["./pages/artist.$id.tsx","/artist/:id"],["./pages/album.$id.tsx","/album/:id"]]);
const fileNameToComponent = new Map([
["./pages/_index.tsx", Page_0],
["./pages/identify.tsx", Page_1],
["./pages/spotify.callback.tsx", Page_2],
["./pages/settings.tsx", Page_3],
["./pages/convert.tsx", Page_4],
["./pages/quality.tsx", Page_5],
["./pages/history.tsx", Page_6],
["./pages/analyze.tsx", Page_7],
["./pages/library.tsx", Page_8],
["./pages/spotify-library.tsx", Page_9],
["./pages/artist.$id.tsx", Page_10],
["./pages/album.$id.tsx", Page_11],
]);
function makePageRoute(filename: string) {
const Component = fileNameToComponent.get(filename);
return <Component />;
}
function toElement({
trie,
fileNameToRoute,
makePageRoute,
}: {
trie: LayoutTrie;
fileNameToRoute: Map<string, string>;
makePageRoute: (filename: string) => React.ReactNode;
}) {
return [
...trie.topLevel.map((filename) => (
<Route
key={fileNameToRoute.get(filename)}
path={fileNameToRoute.get(filename)}
element={makePageRoute(filename)}
/>
)),
...Array.from(trie.trie.entries()).map(([Component, child], index) => (
<Route
key={index}
element={
<Component>
<Outlet />
</Component>
}
>
{toElement({ trie: child, fileNameToRoute, makePageRoute })}
</Route>
)),
];
}
type LayoutTrieNode = Map<
React.ComponentType<{ children: React.ReactNode }>,
LayoutTrie
>;
type LayoutTrie = { topLevel: string[]; trie: LayoutTrieNode };
function buildLayoutTrie(layouts: {
[fileName: string]: React.ComponentType<{ children: React.ReactNode }>[];
}): LayoutTrie {
const result: LayoutTrie = { topLevel: [], trie: new Map() };
Object.entries(layouts).forEach(([fileName, components]) => {
let cur: LayoutTrie = result;
for (const component of components) {
if (!cur.trie.has(component)) {
cur.trie.set(component, {
topLevel: [],
trie: new Map(),
});
}
cur = cur.trie.get(component)!;
}
cur.topLevel.push(fileName);
});
return result;
}
function NotFound() {
return (
<div>
<h1>Not Found</h1>
<p>The page you are looking for does not exist.</p>
<p>Go back to the <a href="/" style={{ color: 'blue' }}>home page</a>.</p>
</div>
);
}
import { useLocation, useNavigationType } from "react-router-dom";
export default function ScrollManager() {
const { pathname, search, hash } = useLocation();
const navType = useNavigationType();
useEffect(() => {
if (navType === "POP") return;
if (hash) return;
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, [pathname, search, hash, navType]);
return null;
}
export function App() {
return (
<BrowserRouter future={{ v7_startTransition: false, v7_relativeSplatPath: false }}>
<ScrollManager />
<GlobalContextProviders>
<Routes>
{toElement({ trie: buildLayoutTrie({
"./pages/_index.tsx": PageLayout_0,
"./pages/identify.tsx": PageLayout_1,
"./pages/spotify.callback.tsx": PageLayout_2,
"./pages/settings.tsx": PageLayout_3,
"./pages/convert.tsx": PageLayout_4,
"./pages/quality.tsx": PageLayout_5,
"./pages/history.tsx": PageLayout_6,
"./pages/analyze.tsx": PageLayout_7,
"./pages/library.tsx": PageLayout_8,
"./pages/spotify-library.tsx": PageLayout_9,
"./pages/artist.$id.tsx": PageLayout_10,
"./pages/album.$id.tsx": PageLayout_11,
}), fileNameToRoute, makePageRoute })}
<Route path="*" element={<NotFound />} />
</Routes>
</GlobalContextProviders>
</BrowserRouter>
);
}
+160
View File
@@ -0,0 +1,160 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap');
:root {
--z-overlay: 400;
--z-spotlight: 1000;
--z-critical: 2000;
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
}
:root {
--background: #0b0c10;
--foreground: #f4f6f8;
--surface: #111319;
--surface-foreground: #f0f3f5;
--sidebar: #101216;
--sidebar-foreground: #edf1f4;
--card: #15181f;
--card-foreground: #f4f6f8;
--popup: #181b22;
--popup-foreground: #f4f6f8;
--primary: #18bfd0;
--primary-foreground: #041013;
--secondary: #20242d;
--secondary-foreground: #f0f3f5;
--accent: #7867d9;
--accent-foreground: #ffffff;
--muted: #1b1f27;
--muted-foreground: #8f99a6;
--success: #3ad7a5;
--success-foreground: #03120d;
--error: #ff5d8f;
--error-foreground: #24040f;
--warning: #ffcb66;
--warning-foreground: #1e1200;
--info: #5ca8ef;
--info-foreground: #031326;
--info-hover: #6fb8ff;
--info-active: #2c92ef;
--border: #2a2f39;
--aurora-cyan: #18bfd0;
--aurora-teal: #3ad7a5;
--aurora-blue: #4f90d9;
--aurora-violet: #7867d9;
--aurora-magenta: #a965c9;
--aurora-deep: #0b0c10;
--aurora-panel: #15181f;
--aurora-panel-strong: #181b22;
--aurora-glow-cyan: rgba(24, 191, 208, .1);
--aurora-glow-violet: rgba(120, 103, 217, .08);
--font-family-base: 'Plus Jakarta Sans', sans-serif;
--font-family-display: 'Outfit', sans-serif;
--font-family-monospace: 'JetBrains Mono', monospace;
--font-family-cursive: cursive;
--shadow: 0 1px 2px rgba(0, 0, 0, .35);
--shadow-md: 0 3px 10px rgba(0, 0, 0, .26);
--shadow-lg: 0 6px 18px rgba(0, 0, 0, .3);
--shadow-focus: 0 0 0 2px rgba(24, 191, 208, .18);
--radius-sm: 3px;
--radius: 4px;
--radius-md: 5px;
--radius-lg: 6px;
--radius-full: 999px;
--animation-duration-fast: 120ms;
--animation-duration-normal: 220ms;
--animation-duration-slow: 420ms;
--chart-color-1: #17eaf2;
--chart-color-2: #1687ff;
--chart-color-3: #8b5cf6;
--chart-color-4: #d05cff;
--chart-color-5: #2cf2b3;
}
.dark {
--background: #0b0c10;
--foreground: #f6fbff;
--surface: #111319;
--surface-foreground: #eef9ff;
--sidebar: #101216;
--sidebar-foreground: #e8f8ff;
--card: #15181f;
--card-foreground: #f6fbff;
--popup: #181b22;
--popup-foreground: #f6fbff;
--primary: #18bfd0;
--primary-foreground: #031012;
--secondary: #20242d;
--secondary-foreground: #eef7ff;
--accent: #7867d9;
--accent-foreground: #ffffff;
--muted: #1b1f27;
--muted-foreground: #8f99a6;
--success: #3ad7a5;
--success-foreground: #03120d;
--error: #ff5d8f;
--error-foreground: #24040f;
--warning: #ffcb66;
--warning-foreground: #1e1200;
--info: #4ea8ff;
--info-foreground: #031326;
--info-hover: #6fb8ff;
--info-active: #2c92ef;
--border: #2a2f39;
}
@layer base {
:root {
--safe-area-inset-top: env(safe-area-inset-top, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
}
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-family-base);
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
button {
border: none;
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
th, td {
display: table-cell!important;
}
}
.rbc-month-view {
overflow: visible !important;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

+117
View File
@@ -0,0 +1,117 @@
@layer base {
.item {
border-radius: var(--radius);
background-color: var(--card);
color: var(--card-foreground);
overflow: hidden;
margin-bottom: var(--spacing-4);
box-shadow: var(--shadow);
transition: box-shadow var(--animation-duration-normal) ease;
}
.item:hover:not(:has(.trigger:disabled)) {
box-shadow: var(--shadow-md);
}
.item:has(.trigger:disabled) {
opacity: 0.6;
box-shadow: var(--shadow);
}
.header {
display: flex;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--spacing-4) var(--spacing-6);
font-family: var(--font-family-heading);
font-weight: 400;
line-height: 1.5;
letter-spacing: -0.01em;
text-align: left;
background-color: transparent;
color: inherit;
border: none;
cursor: pointer;
transition: opacity var(--animation-duration-fast) ease;
}
.trigger:hover:not(:disabled) {
opacity: 0.8;
}
.trigger:active:not(:disabled) {
opacity: 0.7;
}
.trigger:disabled {
opacity: 0.6;
cursor: not-allowed;
color: var(--muted-foreground);
}
.trigger:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.icon {
width: 1rem;
height: 1rem;
transition: transform var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
flex-shrink: 0;
color: var(--muted-foreground);
}
.trigger:disabled .icon {
color: var(--muted-foreground);
opacity: 0.5;
}
.trigger[data-state="open"] .icon {
transform: rotate(180deg);
}
.content {
overflow: hidden;
background-color: transparent;
color: inherit;
animation: accordion-up var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.content[data-state="open"] {
animation: accordion-down var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.contentInner {
padding: var(--spacing-3) var(--spacing-6) var(--spacing-4);
color: var(--muted-foreground);
font-size: 0.95rem;
line-height: 1.6;
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import styles from "./Accordion.module.css";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={`${styles.item} ${className || ""}`}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className={styles.header}>
<AccordionPrimitive.Trigger
ref={ref}
className={`${styles.trigger} ${className || ""}`}
{...props}
>
{children}
<ChevronDown className={styles.icon} />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={`${styles.content} ${className || ""}`}
{...props}
>
<div className={styles.contentInner}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+138
View File
@@ -0,0 +1,138 @@
.shell {
min-height: 100vh;
background: #0b0c10;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: var(--sidebar-width);
display: flex;
flex-direction: column;
padding: 20px 16px;
overflow-y: auto;
background: #111216;
border-right: 1px solid #252932;
z-index: 30;
font-family: var(--font-family-base);
}
.logoWrap {
display: block;
padding: 2px 4px 22px;
border-bottom: 1px solid #252932;
}
.logo {
display: block;
width: 100%;
max-height: 72px;
object-fit: contain;
object-position: left center;
}
.nav {
display: grid;
gap: 4px;
padding: 18px 0;
}
.navItem {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 0 11px;
color: #8d96a1;
text-decoration: none;
border-left: 2px solid transparent;
font-size: .86rem;
transition: color 120ms, background 120ms, border-color 120ms;
}
.navItem:hover {
color: #dffbff;
background: #181b22;
}
.active {
color: #ffffff;
border-left-color: var(--aurora-cyan);
background: #1b2026;
}
.active svg {
color: var(--aurora-cyan);
}
.section {
margin-top: 6px;
padding-top: 16px;
border-top: 1px solid #252932;
}
.label {
display: block;
font: 500 .62rem/1.2 var(--font-family-monospace);
letter-spacing: .13em;
color: #657c95;
}
.folder {
width: 100%;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
margin-top: 9px;
padding: 10px 9px;
text-align: left;
color: #b8c8d8;
background: #15181f;
border: 1px solid #2a2f39;
border-radius: 3px;
font: .71rem/1.35 var(--font-family-monospace);
cursor: pointer;
}
.folder span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
}
.folder:hover {
border-color: #3b6670;
color: #e6fdff;
}
.footer {
margin-top: auto;
padding-top: 16px;
display: flex;
gap: 8px;
align-items: center;
color: #667b91;
font-size: .72rem;
}
.footer svg {
color: var(--aurora-teal);
}
.content {
min-width: 0;
margin-left: var(--sidebar-width);
}
@media (max-width: 900px) {
.sidebar {
display: none;
}
.content {
margin-left: 0;
}
}
+86
View File
@@ -0,0 +1,86 @@
import { type ReactNode, useEffect, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import {
Activity,
AudioLines,
Clock3,
Database,
Download,
FolderOpen,
Heart,
Library,
Settings2,
ShieldCheck,
} from "lucide-react";
import { getDesktopBridge } from "../helpers/desktopBridge";
import styles from "./AppShell.module.css";
const NAV_ITEMS = [
{ to: "/", label: "Playlist", icon: Library, end: true },
{ to: "/spotify-library", label: "My Spotify", icon: Heart, also: ["/artist/", "/album/"] },
{ to: "/identify", label: "Identify Files", icon: AudioLines },
{ to: "/convert", label: "Convert Audio", icon: Download },
{ to: "/library", label: "Local Library", icon: Database },
{ to: "/analyze", label: "Audio Analyzer", icon: Activity },
{ to: "/history", label: "History", icon: Clock3 },
{ to: "/settings", label: "Settings", icon: Settings2 },
];
export function AppShell({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
const desktop = getDesktopBridge();
const [downloadFolder, setDownloadFolder] = useState<string>();
useEffect(() => {
desktop?.getDownloadFolder().then(setDownloadFolder).catch(() => undefined);
}, [pathname]);
async function chooseDownloadFolder() {
const chosen = await desktop?.chooseDownloadFolder();
if (chosen) setDownloadFolder(chosen);
}
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<NavLink to="/" className={styles.logoWrap} aria-label="Aurora SpotiRip home">
<img className={styles.logo} src="/SpotiRip.png" alt="Aurora SpotiRip" />
</NavLink>
<nav className={styles.nav} aria-label="Main navigation">
{NAV_ITEMS.map(({ to, label, icon: Icon, end, also }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
styles.navItem +
(isActive || also?.some((prefix) => pathname.startsWith(prefix)) ? " " + styles.active : "")
}
>
<Icon size={17} />
<span>{label}</span>
</NavLink>
))}
</nav>
{desktop ? (
<div className={styles.section}>
<span className={styles.label}>DOWNLOADS</span>
<button className={styles.folder} onClick={chooseDownloadFolder} title="Change download folder">
<FolderOpen size={15} />
<span>{downloadFolder || "Choose folder…"}</span>
</button>
</div>
) : null}
<div className={styles.footer}>
<ShieldCheck size={15} />
<span>Local-first workflow</span>
</div>
</aside>
<div className={styles.content}>{children}</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
@layer base {
.autoComplete {
position: relative;
width: 100%;
}
.inputWrapper {
width: 100%;
}
.customInput {
font-family: var(--font-family-base);
font-size: 1rem;
line-height: 1.5;
color: var(--foreground);
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-2) var(--spacing-3);
width: 100%;
transition:
border-color var(--animation-duration-normal) ease,
background-color var(--animation-duration-normal) ease,
box-shadow var(--animation-duration-normal) ease;
outline: none;
height: 2.5rem;
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.02),
0 1px 0 rgba(255, 255, 255, 0.3);
}
.customInput::placeholder {
color: var(--muted-foreground);
opacity: 0.7;
}
.customInput:hover {
border-color: color-mix(in srgb, var(--primary) 80%, transparent);
background-color: color-mix(in srgb, var(--card) 98%, var(--primary) 2%);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.02),
0 2px 4px rgba(0, 0, 0, 0.05);
}
.customInput:focus {
border-color: var(--primary);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent),
0 2px 4px rgba(0, 0, 0, 0.05);
background-color: var(--card);
}
.customInput:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
cursor: not-allowed;
opacity: 0.75;
box-shadow: none;
}
:global(.dark) .customInput {
background-color: color-mix(in srgb, var(--background) 80%, black);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.05);
}
:global(.dark) .customInput:hover {
background-color: color-mix(in srgb, var(--background) 75%, black);
border-color: color-mix(in srgb, var(--primary) 90%, white);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.15);
}
:global(.dark) .customInput:focus {
background-color: color-mix(in srgb, var(--background) 80%, black);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent),
0 2px 4px rgba(0, 0, 0, 0.15);
}
.popoverContent {
width: var(--radix-popover-trigger-width);
max-width: none;
min-width: var(--radix-popover-trigger-width);
background-color: var(--popup);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.commandList {
border: none;
padding: var(--spacing-1);
}
.loadingContainer {
padding: var(--spacing-2);
}
}
+170
View File
@@ -0,0 +1,170 @@
import { CommandGroup, CommandItem, CommandList } from "./Command";
import { Command as CommandPrimitive } from "cmdk";
import {
useState,
useRef,
useCallback,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
import { Skeleton } from "./Skeleton";
import styles from "./AutoComplete.module.css";
export type Option<T = unknown> = {
value: string;
label: ReactNode;
displayText?: string;
metadata?: T;
};
const getDisplayText = <T,>(option: Option<T>): string => {
if (option.displayText) {
return option.displayText;
}
if (typeof option.label === "string") {
return option.label;
}
return option.value;
};
type AutoCompleteProps<T = unknown> = {
options: Option<T>[];
emptyMessage: string;
value?: Option<T>;
onValueChange?: (value: Option<T>) => void;
inputValue: string;
onInputValueChange: (value: string) => void;
isLoading?: boolean;
disabled?: boolean;
placeholder?: string;
allowFreeForm?: boolean;
};
export const AutoComplete = <T = unknown,>({
options,
placeholder,
emptyMessage,
value,
onValueChange,
inputValue,
onInputValueChange,
disabled,
isLoading = false,
allowFreeForm = false,
}: AutoCompleteProps<T>) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isOpen, setOpen] = useState(false);
const [selected, setSelected] = useState<Option<T> | undefined>(value);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (!input) {
return;
}
if (event.key === "Enter" && input.value !== "") {
const optionToSelect = options.find(
(option) => getDisplayText(option) === input.value,
);
if (optionToSelect) {
setSelected(optionToSelect);
onValueChange?.(optionToSelect);
}
}
if (event.key === "Escape") {
input.blur();
setOpen(false);
}
},
[options, onValueChange],
);
const handleBlur = useCallback(() => {
if (!isOpen && !allowFreeForm) {
onInputValueChange(selected ? getDisplayText(selected) : "");
}
}, [selected, isOpen, onInputValueChange, allowFreeForm]);
const handleSelectOption = useCallback(
(selectedOption: Option<T>) => {
onInputValueChange(getDisplayText(selectedOption));
setSelected(selectedOption);
onValueChange?.(selectedOption);
setOpen(false);
setTimeout(() => {
inputRef?.current?.blur();
}, 0);
},
[onValueChange, onInputValueChange],
);
return (
<Popover open={isOpen} onOpenChange={setOpen}>
<CommandPrimitive
onKeyDown={handleKeyDown}
className={styles.autoComplete}
>
<div className={styles.inputWrapper}>
<PopoverTrigger asChild>
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={isLoading ? undefined : onInputValueChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className={styles.customInput}
/>
</PopoverTrigger>
</div>
{!!options.length && (
<PopoverContent
removeBackgroundAndPadding
className={styles.popoverContent}
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<CommandList className={styles.commandList}>
{options.length > 0 && (
<CommandGroup>
{options.map((option) => {
return (
<CommandItem
key={option.value}
value={getDisplayText(option)}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onSelect={() => handleSelectOption(option)}
>
{option.label}
</CommandItem>
);
{
isLoading ? (
<CommandPrimitive.Loading>
<div className={styles.loadingContainer}>
<Skeleton style={{ height: "2rem" }} />
</div>
</CommandPrimitive.Loading>
) : null;
}
})}
</CommandGroup>
)}
</CommandList>
</PopoverContent>
)}
</CommandPrimitive>
</Popover>
);
};
+44
View File
@@ -0,0 +1,44 @@
@layer base {
.avatar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-full);
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
background-color: var(--muted);
border: 2px solid var(--card);
transition: transform var(--animation-duration-normal) ease,
box-shadow var(--animation-duration-normal) ease;
}
.avatar:hover {
transform: scale(1.05);
box-shadow: var(--shadow);
}
.avatarImage {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.avatarFallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-family-base);
font-weight: 500;
font-size: 0.875rem;
line-height: 1;
text-transform: uppercase;
}
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import styles from "./Avatar.module.css";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={`${styles.avatar} ${className ?? ""}`}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={`${styles.avatarImage} ${className ?? ""}`}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={`${styles.avatarFallback} ${className ?? ""}`}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+76
View File
@@ -0,0 +1,76 @@
@layer base {
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
padding: var(--spacing-1) var(--spacing-3);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
transition: background-color var(--animation-duration-fast) ease-in-out;
cursor: default;
user-select: none;
}
.primary {
background-color: var(--primary);
color: var(--primary-foreground);
border: 1px solid;
}
.primary:hover {
background-color: color-mix(in srgb, var(--primary) 80%, transparent);
}
.destructive {
background-color: color-mix(in srgb, var(--error) 15%, var(--surface));
color: var(--error);
border: 1px solid color-mix(in srgb, var(--error) 20%, var(--surface));
}
.destructive:hover {
background-color: color-mix(in srgb, var(--error) 25%, var(--surface));
}
.secondary {
background-color: var(--secondary);
color: var(--secondary-foreground);
border: 1px solid;
}
.secondary:hover {
background-color: color-mix(in srgb, var(--secondary) 80%, transparent);
}
.outline {
background-color: transparent;
color: var(--foreground);
border: 1px solid var(--border);
}
.outline:hover {
background-color: color-mix(in srgb, var(--foreground) 5%, transparent);
}
.success {
background-color: color-mix(in srgb, var(--success) 15%, var(--surface));
color: var(--success);
border: 1px solid color-mix(in srgb, var(--success) 20%, var(--surface));
}
.success:hover {
background-color: color-mix(in srgb, var(--success) 25%, var(--surface));
}
.warning {
background-color: color-mix(in srgb, var(--warning) 15%, var(--surface));
color: var(--warning);
border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--surface));
}
.warning:hover {
background-color: color-mix(in srgb, var(--warning) 25%, var(--surface));
}
}
+27
View File
@@ -0,0 +1,27 @@
import styles from "./Badge.module.css";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
variant?:
| "primary"
| "destructive"
| "outline"
| "secondary"
| "success"
| "warning";
}
export const Badge = ({
variant = "primary",
className,
children,
...props
}: Props) => {
return (
<div
className={`${styles.badge} ${styles[variant]} ${className || ""}`}
{...props}
>
{children}
</div>
);
};
+77
View File
@@ -0,0 +1,77 @@
@layer base {
.nav {
width: 100%;
}
.list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-1);
padding: 0;
margin: 0;
list-style: none;
}
.item {
display: inline-flex;
align-items: center;
}
.link {
display: inline-flex;
align-items: center;
font-size: 0.875rem;
color: var(--muted-foreground);
text-decoration: none;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-sm);
outline: none;
transition:
color var(--animation-duration-fast) ease-in-out,
background-color var(--animation-duration-fast) ease-in-out;
}
.link:hover {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 5%, transparent);
}
.link:focus-visible {
box-shadow: var(--shadow-focus);
}
.page {
display: inline-flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius-sm);
}
.separator {
display: inline-flex;
align-items: center;
color: var(--muted-foreground);
margin-inline: calc(var(--spacing-1) * 0.5);
}
.ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--muted-foreground);
border-radius: var(--radius-sm);
cursor: default;
margin-inline: var(--spacing-1);
}
.ellipsis:hover {
background-color: var(--muted);
color: var(--muted-foreground);
}
}
+108
View File
@@ -0,0 +1,108 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import styles from "./Breadcrumb.module.css";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ className, ...props }, ref) => (
<nav
ref={ref}
aria-label="breadcrumb"
className={`${styles.nav} ${className || ""}`}
{...props}
/>
));
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol ref={ref} className={`${styles.list} ${className || ""}`} {...props} />
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={`${styles.item} ${className || ""}`} {...props} />
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={`${styles.link} ${className || ""}`}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={`${styles.page} ${className || ""}`}
{...props}
/>
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={`${styles.separator} ${className || ""}`}
{...props}
>
<ChevronRight size={14} />
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={`${styles.ellipsis} ${className || ""}`}
{...props}
>
<MoreHorizontal size={14} />
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+239
View File
@@ -0,0 +1,239 @@
@layer base {
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
font-family: var(--font-family-base);
font-weight: 500;
border-radius: 3px;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--animation-duration-fast) ease,
border-color var(--animation-duration-fast) ease,
color var(--animation-duration-fast) ease,
box-shadow var(--animation-duration-fast) ease;
outline: none;
border: 1px solid transparent;
position: relative;
}
.button:focus-visible {
box-shadow:
0 0 0 2px var(--background),
0 0 0 4px var(--primary);
outline: none;
}
.sm {
height: 1.5rem;
padding: 0 var(--spacing-2);
font-size: 0.875rem;
}
.md {
height: 2.5rem;
padding: 0 var(--spacing-3);
font-size: 0.9375rem;
}
.lg {
height: 3rem;
padding: 0 var(--spacing-4);
font-size: 1rem;
border-radius: var(--radius-md);
}
.icon,
.icon-md {
width: 2rem;
height: 2rem;
padding: 0;
}
.icon-sm {
width: 1.5rem;
height: 1.5rem;
padding: 0;
}
.icon-lg {
width: 2.5rem;
height: 2.5rem;
padding: 0;
border-radius: var(--radius-md);
}
.button svg {
flex-shrink: 0;
}
.sm svg,
.icon-sm svg {
width: 1rem;
height: 1rem;
}
.md svg,
.icon svg,
.icon-md svg {
width: 1.25rem;
height: 1.25rem;
}
.lg svg,
.icon-lg svg {
width: 1.5rem;
height: 1.5rem;
}
.primary {
background: #18bfd0;
color: var(--primary-foreground);
border-color: #18bfd0;
box-shadow: none;
--spinner-color: var(--primary-foreground);
}
.primary:hover {
background: #29c9d9;
border-color: #29c9d9;
box-shadow: none;
}
.primary:active {
background-color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
border-color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
}
.secondary {
background-color: var(--secondary);
color: var(--secondary-foreground);
border-color: var(--secondary);
--spinner-color: var(--secondary-foreground);
}
.secondary:hover {
background-color: color-mix(in oklch, var(--secondary) 90%, var(--secondary-foreground));
border-color: color-mix(in oklch, var(--secondary) 90%, var(--secondary-foreground));
}
.secondary:active {
background-color: color-mix(in oklch, var(--secondary) 85%, var(--secondary-foreground));
border-color: color-mix(in oklch, var(--secondary) 85%, var(--secondary-foreground));
}
.destructive {
background-color: var(--error);
color: var(--error-foreground);
border-color: var(--error);
--spinner-color: var(--error-foreground);
}
.destructive:hover {
background-color: color-mix(in oklch, var(--error) 90%, var(--error-foreground));
border-color: color-mix(in oklch, var(--error) 90%, var(--error-foreground));
}
.destructive:active {
background-color: color-mix(in oklch, var(--error) 85%, var(--error-foreground));
border-color: color-mix(in oklch, var(--error) 85%, var(--error-foreground));
}
.destructive:focus-visible {
box-shadow:
0 0 0 2px var(--background),
0 0 0 4px var(--error);
}
.outline {
background-color: #171a20;
border-color: #333943;
color: #d7dce2;
--spinner-color: var(--primary);
}
.outline:hover {
background-color: #1d2128;
border-color: #46505c;
color: #f4feff;
}
.outline:active {
background-color: color-mix(in oklch, var(--primary) 15%, var(--background));
border-color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
}
.ghost {
background-color: transparent;
border-color: transparent;
--spinner-color: var(--primary);
}
.ghost:hover {
background-color: color-mix(in oklch, var(--primary) 12%, var(--background));
color: color-mix(in oklch, var(--primary) 90%, var(--primary-foreground));
}
.ghost:active {
background-color: color-mix(in oklch, var(--primary) 15%, var(--background));
color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
}
.link {
background-color: transparent;
color: var(--primary);
height: auto;
padding: 0;
text-decoration: none;
border: none;
border-radius: 0;
min-height: 2.75rem; display: inline-flex;
align-items: center;
--spinner-color: var(--primary);
}
.link:hover {
text-decoration: underline;
color: color-mix(in oklch, var(--primary) 90%, var(--primary-foreground));
background-color: transparent;
}
.link:active {
color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
background-color: transparent;
}
.link:focus-visible {
box-shadow: 0 0 0 2px var(--primary);
outline: none;
border-radius: 0.25rem;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
:global(.dark) .primary:focus-visible,
:global(.dark) .outline:focus-visible,
:global(.dark) .ghost:focus-visible {
box-shadow:
0 0 0 2px var(--background),
0 0 0 4px var(--primary);
}
:global(.dark) .destructive:focus-visible {
box-shadow:
0 0 0 2px var(--background),
0 0 0 4px var(--error);
}
:global(.dark) .link:focus-visible {
box-shadow: 0 0 0 2px var(--primary);
}
}
+53
View File
@@ -0,0 +1,53 @@
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import styles from "./Button.module.css";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| "primary"
| "outline"
| "ghost"
| "link"
| "secondary"
| "destructive";
size?: "sm" | "md" | "lg" | "icon" | "icon-sm" | "icon-md" | "icon-lg";
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
variant = "primary",
size = "md",
asChild = false,
className,
disabled,
type = "button",
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
type={type}
className={`
${styles.button}
${styles[variant]}
${styles[size]}
${disabled ? styles.disabled : ""}
${className || ""}
`}
disabled={disabled}
{...props}
>
{children}
</Comp>
);
},
);
Button.displayName = "Button";
+217
View File
@@ -0,0 +1,217 @@
@layer base {
.calendar {
min-width: 18rem;
font-family: var(--font-family-base);
background-color: var(--card);
color: var(--card-foreground);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--spacing-4);
position: relative;
}
.nav {
position: absolute;
top: var(--spacing-4);
right: var(--spacing-4);
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.button_previous,
.button_next {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background-color: transparent;
color: var(--card-foreground);
border: none;
cursor: pointer;
transition: background-color var(--animation-duration-fast) ease;
}
.button_previous:hover,
.button_next:hover {
background-color: var(--muted);
}
.button_previous:active,
.button_next:active {
background-color: var(--muted);
transform: scale(0.97);
}
.button_previous svg,
.button_next svg {
width: 1.25rem;
height: 1.25rem;
fill: currentColor;
}
.months {
width: 100%;
}
.month {
width: 100%;
}
.month_grid {
width: 100%;
border-collapse: collapse;
}
.month_caption {
height: 2rem;
display: flex;
align-items: center;
margin-bottom: var(--spacing-2);
}
.caption_label {
font-weight: 600;
font-size: 1rem;
color: var(--card-foreground);
}
.weekdays {
margin-bottom: var(--spacing-1);
}
.weekday {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 500;
color: var(--muted-foreground);
}
.cell {
position: relative;
}
.day {
position: relative;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.day_button {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: transparent;
color: inherit;
font-size: 0.9rem;
font-weight: 400;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
background-color var(--animation-duration-fast) ease,
color var(--animation-duration-fast) ease;
}
.day_button:hover {
background-color: var(--muted);
color: var(--card-foreground);
}
.day_button:active:hover {
background-color: var(--primary);
color: var(--primary-foreground);
transform: scale(0.97);
}
.selected .day_button {
background-color: var(--primary);
color: var(--primary-foreground);
font-weight: 500;
}
.selected .day_button:hover {
background-color: color-mix(in srgb, var(--primary) 90%, black);
color: var(--primary-foreground);
}
.today .day_button {
border: 1px solid var(--primary);
font-weight: 500;
}
.today.selected .day_button {
border-color: var(--primary-foreground);
}
.range_middle .day_button {
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--foreground);
border-radius: 0;
}
.range_end .day_button {
background-color: var(--primary);
color: var(--primary-foreground);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.outside .day_button {
color: var(--muted-foreground);
opacity: 0.6;
}
.disabled .day_button {
opacity: 0.3;
cursor: not-allowed;
background-color: transparent;
color: var(--card-foreground);
}
.disabled .day_button:hover {
background-color: transparent;
}
.footer {
margin-top: var(--spacing-4);
padding-top: var(--spacing-2);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
}
.hidden {
display: none;
}
.chevron {
display: flex;
}
:global(.dark) .range_middle .day_button {
background-color: color-mix(in srgb, var(--primary) 20%, var(--background));
color: var(--foreground);
}
:global(.dark) .day_button:hover {
background-color: var(--muted);
color: var(--card-foreground);
}
:global(.dark) .day_button:active:hover {
background-color: var(--primary);
color: var(--primary-foreground);
}
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import styles from "./Calendar.module.css";
export type CalendarProps = React.ComponentProps<typeof DayPicker> & {
mode: "single" | "range";
};
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={`${styles.calendar} ${className ?? ""}`}
classNames={{
chevron: styles.chevron,
weekdays: styles.weekdays,
weekday: styles.weekday,
today: styles.today,
selected: styles.selected,
range_middle: styles.range_middle,
range_end: styles.range_end,
outside: styles.outside,
nav: styles.nav,
months: styles.months,
month: styles.month,
month_grid: styles.month_grid,
month_caption: styles.month_caption,
hidden: styles.hidden,
footer: styles.footer,
disabled: styles.disabled,
day: styles.day,
day_button: styles.day_button,
cell: styles.cell,
caption_label: styles.caption_label,
button_previous: styles.button_previous,
button_next: styles.button_next,
...classNames,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
+127
View File
@@ -0,0 +1,127 @@
@layer base {
.carousel {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.viewport {
width: 100%;
height: 100%;
}
.container {
display: flex;
height: 100%;
width: 100%;
backface-visibility: hidden;
}
.container.horizontal {
flex-direction: row;
}
.container.vertical {
flex-direction: column;
}
.item {
flex: 0 0 auto;
min-width: 0;
position: relative;
}
.item.horizontal {
width: 100%;
}
.item.vertical {
height: 100%;
}
.previousButtonContainer,
.nextButtonContainer {
position: absolute;
z-index: var(--z-content);
transform: translateY(-50%);
}
.horizontalNav.previousButtonContainer {
left: var(--spacing-2);
top: 50%;
}
.horizontalNav.nextButtonContainer {
right: var(--spacing-2);
top: 50%;
}
.verticalNav.previousButtonContainer {
top: var(--spacing-2);
left: 50%;
transform: translateX(-50%);
}
.verticalNav.nextButtonContainer {
bottom: var(--spacing-2);
left: 50%;
transform: translateX(-50%);
}
.previous,
.next {
background-color: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
border-radius: var(--radius-full);
box-shadow: var(--shadow);
transition: all var(--animation-duration-normal) ease;
}
.previous:hover:not(:disabled),
.next:hover:not(:disabled) {
background-color: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.previous:active:not(:disabled),
.next:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow);
}
.previous:focus-visible,
.next:focus-visible {
box-shadow: var(--shadow-focus);
outline: none;
}
.previous:disabled,
.next:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
cursor: not-allowed;
opacity: 0.6;
}
.icon {
width: 1rem;
height: 1rem;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import * as React from "react";
import useEmblaCarousel from "embla-carousel-react";
import {
type EmblaCarouselType as CarouselApi,
type EmblaOptionsType,
type EmblaPluginType,
} from "embla-carousel";
import {
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { Button } from "./Button";
import styles from "./Carousel.module.css";
type CarouselProps = {
opts?: EmblaOptionsType;
plugins?: EmblaPluginType[];
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
className?: string;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps>(null!);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]);
const scrollNext = React.useCallback(() => api?.scrollNext(), [api]);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={`${styles.carousel} ${className}`}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div className={styles.viewport} ref={carouselRef}>
<div
ref={ref}
className={`${styles.container} ${
orientation === "horizontal" ? styles.horizontal : styles.vertical
} ${className || ""}`}
{...props}
>
{children}
</div>
</div>
);
});
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => {
const { orientation } = React.useContext(CarouselContext) || {};
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={`${styles.item} ${
orientation === "horizontal" ? styles.horizontal : styles.vertical
} ${className || ""}`}
{...props}
>
{children}
</div>
);
});
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const Icon = orientation === "horizontal" ? ChevronLeft : ChevronUp;
return (
<div
className={`${styles.previousButtonContainer} ${
orientation === "horizontal" ? styles.horizontalNav : styles.verticalNav
}`}
>
<Button
ref={ref}
variant={variant}
size={size}
className={`${styles.previous} ${className || ""}`}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<Icon className={styles.icon} />
<span className={styles.srOnly}>Previous slide</span>
</Button>
</div>
);
});
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
const Icon = orientation === "horizontal" ? ChevronRight : ChevronDown;
return (
<div
className={`${styles.nextButtonContainer} ${
orientation === "horizontal" ? styles.horizontalNav : styles.verticalNav
}`}
>
<Button
ref={ref}
variant={variant}
size={size}
className={`${styles.next} ${className || ""}`}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<Icon className={styles.icon} />
<span className={styles.srOnly}>Next slide</span>
</Button>
</div>
);
});
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+224
View File
@@ -0,0 +1,224 @@
@layer base {
.container {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
padding-top: var(--spacing-4);
font-family: var(--font-family-base);
display: flex;
flex-direction: column;
& > * {
flex: 1 1 auto;
min-height: 0;
}
}
.container :global(.recharts-cartesian-axis-tick text) {
fill: var(--muted-foreground);
font-size: 0.75rem;
}
.container :global(.recharts-cartesian-grid line[stroke="#ccc"]) {
stroke: color-mix(in srgb, var(--border) 50%, transparent);
}
.container :global(.recharts-curve.recharts-tooltip-cursor) {
stroke: var(--border);
}
.container :global(.recharts-dot[stroke="#fff"]) {
stroke: transparent;
}
.container :global(.recharts-layer) {
outline: none;
}
.container :global(.recharts-polar-grid[stroke="#ccc"]) {
stroke: var(--border);
}
.container :global(.recharts-radial-bar-background-sector) {
fill: var(--muted);
}
.container :global(.recharts-rectangle.recharts-tooltip-cursor) {
fill: var(--muted);
opacity: 0.3;
}
.container :global(.recharts-reference-line[stroke="#ccc"]) {
stroke: var(--border);
}
.container :global(.recharts-sector[stroke="#fff"]) {
stroke: transparent;
}
.container :global(.recharts-sector) {
outline: none;
}
.container :global(.recharts-surface) {
outline: none;
}
.tooltip {
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: var(--spacing-3);
border: 1px solid var(--border);
max-width: 300px;
min-width: 180px;
z-index: var(--z-overlay);
transition:
opacity var(--animation-duration-normal) ease-in-out,
transform var(--animation-duration-normal) ease-in-out;
transform-origin: center top;
animation: tooltipFadeIn var(--animation-duration-normal) ease-in-out;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tooltipLabel {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: var(--spacing-2);
color: var(--popup-foreground);
padding-bottom: var(--spacing-2);
border-bottom: 1px solid var(--border);
}
.tooltipItems {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.tooltipItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: 0.875rem;
}
.tooltipItemIndicator {
flex-shrink: 0;
}
.tooltipItemIndicator-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background-color: var(--color-bg, var(--chart-color-5));
}
.tooltipItemIndicator-line {
width: 12px;
height: 2px;
background-color: var(--color-bg, var(--chart-color-3));
}
.tooltipItemIndicator-dashed {
width: 12px;
height: 2px;
background: repeating-linear-gradient(
to right,
var(--color-bg, var(--chart-color-4)),
var(--color-bg, var(--chart-color-4)) 2px,
transparent 2px,
transparent 4px
);
}
.tooltipItemIndicator-nested-dashed {
width: 12px;
height: 2px;
background: repeating-linear-gradient(
to right,
var(--color-bg, var(--chart-color-4)),
var(--color-bg, var(--chart-color-4)) 2px,
transparent 2px,
transparent 4px
);
margin-right: var(--spacing-1);
}
.tooltipItemLabel {
display: flex;
flex: 1;
justify-content: space-between;
align-items: center;
gap: var(--spacing-2);
}
.tooltipItemLabel-nested {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-1);
}
.tooltipItemLabel-center {
align-items: center;
}
.tooltipItemLabelContent {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.tooltipItemLabelText {
color: var(--popup-foreground);
font-weight: 500;
}
.tooltipItemValue {
font-weight: 600;
color: var(--popup-foreground);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
justify-content: center;
padding: var(--spacing-3);
}
.legend-top {
margin-bottom: var(--spacing-4);
}
.legend-bottom {
margin-top: var(--spacing-4);
}
.legendItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: 0.875rem;
color: var(--surface-foreground);
}
.legendItemIcon {
width: 12px;
height: 12px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
}
+324
View File
@@ -0,0 +1,324 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import styles from "./Chart.module.css"
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
color?: string;
}
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={`${styles.container} ${className ?? ""}`}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: `
[data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={`${styles.tooltipLabel} ${labelClassName ?? ""}`}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={`${styles.tooltipLabel} ${labelClassName ?? ""}`}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={`${styles.tooltip} ${className ?? ""}`}
>
{!nestLabel ? tooltipLabel : null}
<div className={styles.tooltipItems}>
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={`${styles.tooltipItem} ${className ?? ""}`}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={`${styles.tooltipItemIndicator} ${styles["tooltipItemIndicator-" + (nestLabel && indicator === "dashed" ? "nested-dashed" : indicator)]}`}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={`${styles.tooltipItemLabel} ${nestLabel ? styles["tooltipItemLabel-nested"] : styles["tooltipItemLabel-center"]}`}
>
<div className={styles.tooltipItemLabelContent}>
{nestLabel ? tooltipLabel : null}
<span className={styles.tooltipItemLabelText}>
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className={`${styles.tooltipItemValue}`}>
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={`${styles.legend} ${styles["legend-" + verticalAlign]} ${className ?? ""}`}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={`${styles.legendItem} ${className ?? ""}`}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className={`${styles.legendItemIcon}`}
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
}
+84
View File
@@ -0,0 +1,84 @@
@layer base {
.checkboxWrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.checkbox {
appearance: none;
position: relative;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
background-color: transparent;
cursor: pointer;
margin: 0;
padding: 0;
transition: all var(--animation-duration-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
.checkbox:hover {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent);
}
.checkbox:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-color: var(--primary);
}
.checkbox:checked {
background-color: var(--primary);
border-color: var(--primary);
transform: scale(1);
animation: checkboxPulse var(--animation-duration-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
.checkbox:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--muted);
border-color: var(--muted);
}
.checkmark {
position: absolute;
width: 1rem;
height: 1rem;
color: var(--primary-foreground);
pointer-events: none;
opacity: 0;
transform: scale(0.8);
transition: opacity var(--animation-duration-normal) cubic-bezier(0.16, 1, 0.3, 1),
transform var(--animation-duration-normal) cubic-bezier(0.16, 1, 0.3, 1);
}
.checkbox:checked + .checkmark {
opacity: 1;
transform: scale(1);
}
.checkbox:disabled + .checkmark {
color: var(--muted-foreground);
}
@keyframes checkboxPulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 40%, transparent);
}
70% {
transform: scale(1.02);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 0%, transparent);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { InputHTMLAttributes, forwardRef } from 'react';
import styles from './Checkbox.module.css';
import { Check } from 'lucide-react';
export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, ...props }, ref) => {
return (
<div className={styles.checkboxWrapper}>
<input
{...props}
type="checkbox"
ref={ref}
className={`${styles.checkbox} ${className || ''}`}
/>
<Check className={styles.checkmark} aria-hidden="true" />
</div>
);
}
);
Checkbox.displayName = 'Checkbox';
+41
View File
@@ -0,0 +1,41 @@
@layer base {
@keyframes collapsible-down {
from {
max-height: 0;
}
to {
max-height: var(--radix-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
max-height: var(--radix-collapsible-content-height);
}
to {
max-height: 0;
}
}
.trigger {
cursor: pointer;
display: block;
appearance: none;
border: none;
background: transparent;
color: inherit;
}
.content {
overflow: hidden;
}
.content[data-state="open"] {
animation: collapsible-down var(--animation-duration-fast) ease-out;
}
.content[data-state="closed"] {
animation: collapsible-up var(--animation-duration-fast) ease-out;
}
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
import { ChevronDown } from "lucide-react";
import styles from "./Collapsible.module.css";
const Collapsible = CollapsiblePrimitive.Root;
interface CollapsibleTriggerProps
extends React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Trigger> {
icon?: React.ReactNode;
}
const CollapsibleTrigger = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Trigger>,
CollapsibleTriggerProps
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.Trigger
ref={ref}
className={`${styles.trigger} ${className || ""}`}
{...props}
>
{children}
</CollapsiblePrimitive.Trigger>
));
CollapsibleTrigger.displayName = CollapsiblePrimitive.Trigger.displayName;
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Content>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Content>
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.Content
ref={ref}
className={`${styles.content} ${className || ""}`}
{...props}
>
{children}
</CollapsiblePrimitive.Content>
));
CollapsibleContent.displayName = CollapsiblePrimitive.Content.displayName;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+145
View File
@@ -0,0 +1,145 @@
@layer base {
.command {
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.commandInDialog {
height: 100%;
max-height: 100%;
box-shadow: none;
}
.CommandDialogContent {
padding: 0;
overflow: hidden;
max-width: 32rem;
}
.cmdkInputWrapper {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding: var(--spacing-3) var(--spacing-4);
}
.cmdkInputWrapperSearchIcon {
width: 1rem;
height: 1rem;
color: var(--muted-foreground);
margin-right: var(--spacing-2);
flex-shrink: 0;
}
.cmdkInput {
width: 100%;
height: 1.75rem;
background-color: transparent;
color: inherit;
border: none;
outline: none;
font-family: var(--font-family-base);
font-size: 0.95rem;
}
.cmdkInput::placeholder {
color: var(--muted-foreground);
}
.commandList {
overflow-y: auto;
overflow-x: hidden;
max-height: 30vh;
padding: var(--spacing-2);
}
.commandEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 4rem;
color: var(--muted-foreground);
font-size: 0.875rem;
}
.commandGroup {
overflow: hidden;
padding: var(--spacing-2) 0;
}
.commandGroup [cmdk-group-heading] {
padding: 0 var(--spacing-3);
margin-bottom: var(--spacing-2);
color: var(--muted-foreground);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.commandSeparator {
height: 1px;
background-color: var(--border);
margin: var(--spacing-2) var(--spacing-1);
}
.commandItem {
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
border-radius: var(--radius-sm);
color: var(--popup-foreground);
font-size: 0.875rem;
cursor: pointer;
transition:
background-color var(--animation-duration-fast) ease,
color var(--animation-duration-fast) ease;
user-select: none;
outline: none;
}
.commandItem[data-selected="true"] {
background-color: var(--primary);
color: var(--primary-foreground);
}
.commandItem[data-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.commandItem svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
transition: color var(--animation-duration-fast) ease;
}
.commandItem[data-selected="true"] svg {
color: var(--primary-foreground);
}
.commandShortcut {
display: inline-flex;
align-items: center;
margin-left: auto;
font-size: 0.75rem;
font-family: var(--font-family-monospace);
color: var(--muted-foreground);
padding-left: var(--spacing-4);
}
.commandItem[data-selected="true"] .commandShortcut {
color: var(--primary-foreground);
opacity: 0.8;
}
}
+137
View File
@@ -0,0 +1,137 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { Dialog, DialogContent } from "./Dialog"
import styles from "./Command.module.css"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={`${styles.command} ${className ?? ""}`}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className={styles.CommandDialogContent}>
<Command className={styles.commandInDialog}>
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className={styles.cmdkInputWrapper}>
<Search className={styles.cmdkInputWrapperSearchIcon} />
<CommandPrimitive.Input
ref={ref}
className={`${styles.cmdkInput} ${className ?? ""}`}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={`${styles.commandList} ${className ?? ""}`}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className={styles.commandEmpty}
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={`${styles.commandGroup} ${className ?? ""}`}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={`${styles.commandSeparator} ${className ?? ""}`}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={`${styles.commandItem} ${className ?? ""}`}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={`${styles.commandShortcut} ${className ?? ""}`}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+166
View File
@@ -0,0 +1,166 @@
@layer base {
.content,
.subContent {
min-width: 12rem;
overflow: hidden;
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
padding: var(--spacing-2);
z-index: var(--z-overlay);
}
.content {
animation: scaleIn var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.subContent {
animation: fadeIn var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.item,
.checkboxItem,
.radioItem,
.subTrigger {
position: relative;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: var(--radius-sm);
font-size: 0.9375rem;
padding: var(--spacing-2) var(--spacing-3);
color: var(--popup-foreground);
outline: none;
transition:
background-color var(--animation-duration-fast),
color var(--animation-duration-fast),
box-shadow var(--animation-duration-fast);
}
.item:hover,
.checkboxItem:hover,
.radioItem:hover,
.subTrigger:hover {
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
color: var(--primary);
}
.item:focus-visible,
.checkboxItem:focus-visible,
.radioItem:focus-visible,
.subTrigger:focus-visible {
background-color: var(--primary);
color: var(--primary-foreground);
box-shadow:
0 0 0 1px var(--primary),
var(--shadow-focus);
}
.item[data-disabled],
.checkboxItem[data-disabled],
.radioItem[data-disabled],
.subTrigger[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
.item[data-highlighted],
.checkboxItem[data-highlighted],
.radioItem[data-highlighted],
.subTrigger[data-highlighted] {
background-color: var(--primary);
color: var(--primary-foreground);
}
.checkboxItem,
.radioItem {
padding-left: var(--spacing-8);
}
.checkboxItemIndicator,
.radioItemIndicator {
position: absolute;
left: var(--spacing-3);
display: flex;
align-items: center;
justify-content: center;
}
.checkboxItemIndicator {
height: 1rem;
width: 1rem;
}
.checkIcon {
height: 0.875rem;
width: 0.875rem;
}
.radioItemIndicator {
height: 1rem;
width: 1rem;
}
.radioIndicatorCircle {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background-color: currentColor;
}
.label {
padding: var(--spacing-2) var(--spacing-3);
font-size: 0.875rem;
font-weight: 500;
color: var(--muted-foreground);
}
.separator {
height: 1px;
margin: var(--spacing-2) 0;
background-color: var(--border);
}
.shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.025em;
opacity: 0.6;
}
.chevronRightIcon {
margin-left: auto;
height: 1rem;
width: 1rem;
opacity: 0.75;
}
.inset {
padding-left: var(--spacing-8);
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
+172
View File
@@ -0,0 +1,172 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight } from "lucide-react";
import styles from "./ContextMenu.module.css";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={`${styles.subTrigger} ${className ?? ""} ${inset ? styles.inset : ""}`}
{...props}
>
{children}
<ChevronRight className={styles.chevronRightIcon} />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={`${styles.subContent} ${className ?? ""}`}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={`${styles.content} ${className ?? ""}`}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={`${styles.item} ${className ?? ""} ${inset ? styles.inset : ""}`}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={`${styles.checkboxItem} ${className ?? ""}`}
checked={checked}
{...props}
>
<span className={styles.checkboxItemIndicator}>
<ContextMenuPrimitive.ItemIndicator>
<Check className={`${styles.checkIcon}`} />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={`${styles.radioItem} ${className ?? ""}`}
{...props}
>
<span className={styles.radioItemIndicator}>
<ContextMenuPrimitive.ItemIndicator>
<div className={styles.radioIndicatorCircle} />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={`${styles.label} ${className ?? ""} ${inset ? styles.inset : ""}`}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={`${styles.separator} ${className ?? ""}`}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={`${styles.shortcut} ${className ?? ""}`}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+124
View File
@@ -0,0 +1,124 @@
@layer base {
.overlay {
position: fixed;
inset: 0;
background-color: color-mix(in srgb, black 50%, transparent);
z-index: var(--z-overlay) !important;
animation: overlayShow var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.content {
position: fixed !important;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90vw;
max-height: 85vh;
width: 32rem;
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-overlay) !important;
overflow: auto;
padding: var(--spacing-6);
animation: contentShow var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.close {
position: absolute;
top: var(--spacing-4);
right: var(--spacing-4);
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
background-color: var(--muted);
color: var(--muted-foreground);
border: none;
cursor: pointer;
transition:
background-color var(--animation-duration-fast) ease,
color var(--animation-duration-fast) ease,
transform var(--animation-duration-fast) ease;
}
.close:hover {
background-color: var(--muted-foreground);
color: var(--muted);
transform: scale(1.05);
}
.close:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.closeIcon {
width: 1rem;
height: 1rem;
}
.header {
margin-bottom: var(--spacing-4);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-2);
margin-top: var(--spacing-6);
}
.title {
font-family: var(--font-family-heading);
font-size: 1.25rem;
font-weight: 600;
color: var(--popup-foreground);
line-height: 1.4;
margin-bottom: var(--spacing-2);
}
.description {
font-size: 0.875rem;
color: var(--muted-foreground);
line-height: 1.5;
margin-bottom: var(--spacing-4);
}
.screenReaderText {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentShow {
from {
opacity: 0;
scale: 0.95;
}
to {
opacity: 1;
scale: 1;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import styles from "./Dialog.module.css"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={`${styles.overlay} ${className || ''}`}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
container?: React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Portal
>["container"];
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, container, ...props }, ref) => (
<DialogPortal container={container}>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={`${styles.content} ${className || ''}`}
{...props}
>
{children}
<DialogPrimitive.Close className={styles.close}>
<X className={styles.closeIcon} />
<span className={styles.screenReaderText}>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={`${styles.header} ${className || ''}`}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={`${styles.footer} ${className || ''}`}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={`${styles.title} ${className || ''}`}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={`${styles.description} ${className || ''}`}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+9
View File
@@ -0,0 +1,9 @@
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Download, Loader2 } from "lucide-react";
import { Button } from "./Button";
import type { InputType as DownloadRequest } from "../endpoints/spotify-download_POST.schema";
import type { useSpotifyDownload } from "../helpers/useSpotifyDownload";
import styles from "./DownloadButton.module.css";
type Props = {
downloads: ReturnType<typeof useSpotifyDownload>;
busyKey: string;
label: string;
request: DownloadRequest;
text?: string;
size?: "sm" | "icon-sm";
variant?: "primary" | "outline" | "ghost";
};
export function DownloadButton({ downloads, busyKey, label, request, text, size = "icon-sm", variant = "ghost" }: Props) {
if (!downloads.canDownload) return null;
const busy = downloads.busyKeys.has(busyKey);
const Icon = busy ? Loader2 : Download;
return (
<Button
variant={variant}
size={size}
disabled={busy}
aria-label={text ? undefined : `Download ${label}`}
title={text ? undefined : "Download"}
onClick={(event) => {
event.stopPropagation();
downloads.startDownload(busyKey, label, request);
}}
onKeyDown={(event) => event.stopPropagation()}
>
<Icon size={14} className={busy ? styles.spin : undefined} />
{text}
</Button>
);
}
+155
View File
@@ -0,0 +1,155 @@
@layer base {
.content,
.subContent {
min-width: 12rem;
overflow: hidden;
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: var(--spacing-2);
animation: scaleIn var(--animation-duration-normal) ease-out;
z-index: var(--z-overlay);
}
.subContent {
animation: fadeIn var(--animation-duration-normal) ease-out;
}
.item,
.checkboxItem,
.radioItem,
.subTrigger {
position: relative;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: var(--radius-sm);
font-size: 0.9375rem;
padding: var(--spacing-2) var(--spacing-3);
outline: none;
transition:
background-color var(--animation-duration-fast),
color var(--animation-duration-fast);
}
.item:hover,
.checkboxItem:hover,
.radioItem:hover,
.subTrigger:hover {
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
color: var(--primary);
}
.item:focus-visible,
.checkboxItem:focus-visible,
.radioItem:focus-visible,
.subTrigger:focus-visible {
background-color: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
.item[data-disabled],
.checkboxItem[data-disabled],
.radioItem[data-disabled],
.subTrigger[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
.item[data-highlighted],
.checkboxItem[data-highlighted],
.radioItem[data-highlighted],
.subTrigger[data-highlighted] {
background-color: var(--primary);
color: var(--primary-foreground);
}
.checkboxItem,
.radioItem {
padding-left: var(--spacing-8);
}
.checkboxIndicator,
.radioIndicator {
position: absolute;
left: var(--spacing-3);
display: flex;
align-items: center;
justify-content: center;
}
.checkboxIndicator {
height: 1rem;
width: 1rem;
}
.checkboxIndicator svg {
height: 0.875rem;
width: 0.875rem;
}
.radioIndicator {
height: 1rem;
width: 1rem;
}
.radioIndicatorCircle {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background-color: currentColor;
}
.label {
padding: var(--spacing-2) var(--spacing-3);
font-size: 0.875rem;
font-weight: 500;
color: var(--muted-foreground);
}
.separator {
height: 1px;
margin: var(--spacing-2) 0;
background-color: var(--border);
}
.shortcut {
margin-left: auto;
font-size: 0.75rem;
letter-spacing: 0.025em;
opacity: 0.6;
}
.subTriggerIcon {
margin-left: auto;
height: 1rem;
width: 1rem;
opacity: 0.75;
}
.inset {
padding-left: var(--spacing-8);
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.97);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight } from "lucide-react";
import styles from "./DropdownMenu.module.css";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={`${styles.subTrigger} ${inset ? styles.inset : ""} ${className || ""}`}
{...props}
>
{children}
<ChevronRight className={styles.subTriggerIcon} />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={`${styles.subContent} ${className || ""}`}
{...props}
/>
));
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={`${styles.content} ${className || ""}`}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = "DropdownMenuContent";
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={`${styles.item} ${inset ? styles.inset : ""} ${className || ""}`}
{...props}
/>
));
DropdownMenuItem.displayName = "DropdownMenuItem";
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={`${styles.checkboxItem} ${className || ""}`}
checked={checked}
{...props}
>
<span className={styles.checkboxIndicator}>
<DropdownMenuPrimitive.ItemIndicator>
<Check />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem";
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={`${styles.radioItem} ${className || ""}`}
{...props}
>
<span className={styles.radioIndicator}>
<DropdownMenuPrimitive.ItemIndicator>
<div className={styles.radioIndicatorCircle} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem";
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={`${styles.label} ${inset ? styles.inset : ""} ${className || ""}`}
{...props}
/>
));
DropdownMenuLabel.displayName = "DropdownMenuLabel";
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={`${styles.separator} ${className || ""}`}
{...props}
/>
));
DropdownMenuSeparator.displayName = "DropdownMenuSeparator";
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={`${styles.shortcut} ${className || ""}`} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+92
View File
@@ -0,0 +1,92 @@
.container {
width: 100%;
}
.dropzone {
position: relative;
min-height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px 20px;
color: var(--surface-foreground);
text-align: center;
cursor: pointer;
background: #0f1116;
border: 1px dashed #353b45;
border-radius: 3px;
transition: border-color 120ms ease, background 120ms ease;
}
.dropzone input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.dropzone:hover {
border-color: #4b6268;
background: #12151a;
}
.dropzone.dragging {
border-color: var(--primary);
background: #131a1e;
}
.dropzone.disabled {
cursor: not-allowed;
opacity: .5;
pointer-events: none;
}
.icon {
display: inline-flex;
margin-bottom: 12px;
color: #697581;
}
.dropzone:hover .icon,
.dropzone.dragging .icon {
color: var(--primary);
}
.title {
margin-bottom: 5px;
color: #dce1e6;
font-size: .82rem;
font-weight: 600;
}
.subtitle {
color: #697480;
font-size: .69rem;
line-height: 1.45;
}
.error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
padding: 8px 10px;
color: #efa0b7;
background: #1d1218;
border: 1px solid #49303a;
border-radius: 3px;
font-size: .7rem;
}
.error button {
margin-left: auto;
color: var(--error);
flex-shrink: 0;
}
+163
View File
@@ -0,0 +1,163 @@
import React, { useCallback, useState } from 'react';
import { Upload, X } from 'lucide-react';
import { Button } from './Button';
import styles from './FileDropzone.module.css';
export interface FileDropzoneProps {
className?: string;
accept?: string;
maxFiles?: number;
maxSize?: number;
onFilesSelected?: (files: File[]) => void;
disabled?: boolean;
icon?: React.ReactNode;
title?: React.ReactNode;
subtitle?: React.ReactNode;
}
export const FileDropzone: React.FC<FileDropzoneProps> = ({
className = '',
accept,
maxFiles = 1,
maxSize,
onFilesSelected,
disabled = false,
icon = <Upload size={48} />,
title = "Click to upload or drag and drop",
subtitle,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
if (e.type === "dragenter" || e.type === "dragover") {
setIsDragging(true);
} else if (e.type === "dragleave") {
setIsDragging(false);
}
}, [disabled]);
const validateFiles = useCallback((fileList: FileList | null): File[] => {
if (!fileList) return [];
const files = Array.from(fileList);
if (files.length > maxFiles) {
setError(`Maximum ${maxFiles} file${maxFiles === 1 ? '' : 's'} allowed`);
return [];
}
if (accept) {
const acceptedTypes = accept.split(',').map(type => type.trim());
const invalidFiles = files.filter(file =>
!acceptedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
return file.type.match(new RegExp(type.replace('*', '.*')));
})
);
if (invalidFiles.length > 0) {
setError(`Invalid file type. Accepted: ${accept}`);
return [];
}
}
if (maxSize) {
const oversizedFiles = files.filter(file => file.size > maxSize);
if (oversizedFiles.length > 0) {
setError(`File size exceeds ${Math.round(maxSize/1024/1024)}MB limit`);
return [];
}
}
setError(null);
return files;
}, [accept, maxFiles, maxSize]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
setIsDragging(false);
const files = validateFiles(e.dataTransfer.files);
if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
}, [disabled, validateFiles, onFilesSelected]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = validateFiles(e.target.files);
if (files.length > 0 && onFilesSelected) {
onFilesSelected(files);
}
e.target.value = '';
}, [disabled, validateFiles, onFilesSelected]);
const clearError = useCallback(() => {
setError(null);
}, []);
const defaultSubtitle = (accept || maxFiles > 1) && (
<>
{accept && `Accepted types: ${accept}`}
{maxFiles > 1 && ` (Up to ${maxFiles} files)`}
</>
);
return (
<div className={`${styles.container} ${className ?? ""}`}>
<label
className={`
${styles.dropzone}
${isDragging ? styles.dragging : ''}
${disabled ? styles.disabled : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
onChange={handleFileInput}
accept={accept}
multiple={maxFiles > 1}
disabled={disabled}
aria-label="File upload"
/>
<span className={styles.icon}>{icon}</span>
<span className={styles.title}>{title}</span>
{(subtitle || defaultSubtitle) && (
<span className={styles.subtitle}>{subtitle || defaultSubtitle}</span>
)}
</label>
{error && (
<div className={styles.error} role="alert">
<span>{error}</span>
<Button
variant="ghost"
size="icon-sm"
onClick={clearError}
aria-label="Clear error"
>
<X size={16} />
</Button>
</div>
)}
</div>
);
};
+55
View File
@@ -0,0 +1,55 @@
@layer base {
.formItem {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
margin-bottom: var(--spacing-4);
}
.formLabel {
font-family: var(--font-family-base);
font-weight: 500;
font-size: 0.875rem;
line-height: 1.5;
color: var(--foreground);
transition: color var(--animation-duration-fast) ease;
}
.formLabel.error {
color: var(--error);
}
.formControl.error:not(button) {
border-color: var(--error);
box-shadow: 0 0 0 1px var(--error);
}
.formDescription {
font-size: 0.75rem;
line-height: 1.5;
color: var(--muted-foreground);
margin-top: var(--spacing-1);
font-family: var(--font-family-base);
}
.formMessage {
font-size: 0.75rem;
line-height: 1.5;
font-weight: 500;
color: var(--error);
animation: slideIn var(--animation-duration-normal) ease;
margin-top: var(--spacing-1);
font-family: var(--font-family-base);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
+437
View File
@@ -0,0 +1,437 @@
import React, { Dispatch, SetStateAction, useRef, useEffect } from "react";
import { Slot } from "@radix-ui/react-slot";
import { z, ZodError, ZodIssue, ZodIssueCode } from "zod";
import styles from "./Form.module.css";
export function useForm<T extends z.ZodType<any, any>>({
defaultValues = {},
schema,
}: {
defaultValues?: Partial<z.infer<T>>;
schema: T;
}): {
values: z.infer<T>;
defaultValues: Partial<z.infer<T>>;
setValues: Dispatch<SetStateAction<z.infer<T>>>;
errors: FormErrors;
validateField: (path: string, options?: { shallow?: boolean }) => void;
validateForm: () => boolean;
setFieldError: (path: string, errorMessage: string) => void;
handleSubmit: (
onSubmit: (data: z.infer<T>) => void,
) => (e: React.FormEvent) => void;
} {
const [values, setValues] = React.useState<Record<string, any>>(
defaultValues as Record<string, any>,
);
const [errors, setErrors] = React.useState<FormErrors>({});
const setFieldError = React.useCallback(
(path: string, errorMessage: string) => {
setErrors((prev) => {
return setValueByPath(prev, path, errorMessage);
});
},
[],
);
const isExtraPropertyError = (issue: ZodIssue): boolean => {
return issue.code === ZodIssueCode.unrecognized_keys;
};
const validateForm = React.useCallback((): boolean => {
if (!schema) return true;
try {
makeStrict(schema).parse(values);
setErrors({});
return true;
} catch (err) {
if (err instanceof ZodError) {
const extraPropertyErrors = err.errors.filter(isExtraPropertyError);
const validationErrors = err.errors.filter(
(issue) => !isExtraPropertyError(issue),
);
if (extraPropertyErrors.length > 0) {
throw new Error(
"Extra properties detected in form values:" +
extraPropertyErrors.map((e) => e.message).join(", ") +
". Either update the schema or remove these values",
);
}
const tree: any = {};
validationErrors.forEach((issue: ZodIssue) => {
const path = issue.path.join(".");
const errorMessage =
typeof issue.message === "string"
? issue.message
: String(issue.message);
Object.assign(tree, {});
const tmp = setValueByPath(tree, path, errorMessage);
Object.assign(tree, tmp);
});
setErrors(tree);
if (validationErrors.length === 0 && extraPropertyErrors.length > 0) {
throw new Error(
`Form contains extra properties: ${extraPropertyErrors
.map((e) => e.message)
.join(", ")}`,
);
}
}
console.error("Form validation failed", err);
return false;
}
}, [schema, values]);
const validateField = React.useCallback(
(path: string, options?: { shallow?: boolean }) => {
if (!schema) return;
const result = schema.safeParse(values);
setErrors((prev: FormErrors) => {
let next = { ...prev };
if (options?.shallow) {
if (getValueByPath(next, path) !== undefined) {
next = deleteValueByPath(next, path, { shallow: true });
}
} else {
next = deleteValueByPath(next, path);
}
if (!result.success) {
result.error.errors.forEach((issue) => {
const ip = issue.path.join(".");
const shouldIncludeError = options?.shallow
? ip === path
: ip === path || ip.startsWith(path + ".");
if (shouldIncludeError) {
const errorMessage =
typeof issue.message === "string"
? issue.message
: String(issue.message);
next = setValueByPath(next, ip, errorMessage);
}
});
}
return next;
});
},
[schema, values],
);
type Data = z.infer<T>;
const handleSubmit = React.useCallback(
(onSubmit: (data: Data) => void) => {
return (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit(values as Data);
}
};
},
[validateForm, values],
);
const oldValueRef = useRef(values);
useEffect(() => {
const diffKeys: string[] = [];
for (const key of Object.keys(values)) {
if (
values[key] !== oldValueRef.current[key] &&
Array.isArray(values[key]) &&
Array.isArray(oldValueRef.current[key]) &&
values[key].length !== oldValueRef.current[key].length
) {
diffKeys.push(key);
}
}
oldValueRef.current = values;
for (const k of diffKeys) {
validateField(k, { shallow: true });
}
}, [values, validateField]);
return {
values,
errors,
setValues,
validateField,
validateForm,
setFieldError,
handleSubmit,
defaultValues,
};
}
export function Form<T>(
props: {
children: React.ReactNode;
} & FormContextValue<T>,
) {
const {
children,
values,
errors,
setValues,
validateField,
validateForm,
setFieldError,
} = props;
return (
<FormContext.Provider
value={{
values,
errors,
setValues,
validateField,
validateForm,
setFieldError,
}}
>
{children}
</FormContext.Provider>
);
}
export const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { name: string }
>(({ name, className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id, name }}>
<div
ref={ref}
className={`${styles.formItem} ${className || ""}`}
{...props}
/>
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
export const FormLabel = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => {
const { formItemId, error } = useFormField();
return (
<label
ref={ref}
className={`${styles.formLabel} ${error ? styles.error : ""} ${className || ""}`}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
export const FormControl = React.forwardRef<
any,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ className, ...props }, ref) => {
const { formItemId, error, formMessageId, onBlur } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
className={`${styles.formControl} ${error ? styles.error : ""} ${className || ""}`}
aria-invalid={!!error}
aria-describedby={error ? formMessageId : undefined}
onBlur={onBlur}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
export const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={`${styles.formDescription} ${className || ""}`}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
export const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { formMessageId, error } = useFormField();
const body = error ? error.message : children;
if (!body) return null;
return (
<p
ref={ref}
id={formMessageId}
className={`${styles.formMessage} ${className || ""}`}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
type FormErrors = {
[key: string]: string | FormErrors;
};
type FormContextValue<T> = {
values: T;
errors: FormErrors;
setValues: React.Dispatch<React.SetStateAction<T>>;
validateField: (path: string, options?: { shallow?: boolean }) => void;
validateForm: () => boolean;
setFieldError: (path: string, errorMessage: string) => void;
};
const FormContext = React.createContext<FormContextValue<any> | undefined>(
undefined,
);
function useFormContext(): FormContextValue<any> {
const ctx = React.useContext(FormContext);
if (!ctx) throw new Error("useFormContext must be inside <Form>");
return ctx;
}
type FieldCtx = { id: string; name: string };
const FormItemContext = React.createContext<FieldCtx | null>(null);
function useFormField() {
const form = useFormContext();
const ctx = React.useContext(FormItemContext);
if (!ctx)
throw new Error(
"<FormLabel> and <FormControl> must live inside <FormItem>",
);
const { id, name } = ctx;
const errorValue = getValueByPath(form.errors, name);
const errorMsg = typeof errorValue === "string" ? errorValue : undefined;
return {
id,
name,
error: errorMsg ? { message: errorMsg } : undefined,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
onBlur: () => form.validateField(name),
validateShallow: () => form.validateField(name, { shallow: true }),
};
}
function getValueByPath(obj: any, path: string): any {
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current == null) return undefined;
if (/^\d+$/.test(key) && Array.isArray(current)) {
current = current[+key];
} else {
current = current[key];
}
}
return current;
}
function setValueByPath(obj: any, path: string, value: any): any {
const keys = path.split(".");
const lastKey = keys.pop()!;
const newObj = { ...obj };
let pointer = newObj;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const nextKey = keys[i + 1];
if (pointer[key] == null) {
pointer[key] = /^\d+$/.test(nextKey) ? [] : {};
} else if (Array.isArray(pointer[key])) {
pointer[key] = [...pointer[key]];
} else if (typeof pointer[key] === "object") {
pointer[key] = { ...pointer[key] };
}
pointer = pointer[key];
}
if (/^\d+$/.test(lastKey) && Array.isArray(pointer)) {
pointer[+lastKey] = value;
} else {
pointer[lastKey] = value;
}
return newObj;
}
function deleteValueByPath(
obj: any,
path: string,
options?: { shallow?: boolean },
): any {
const keys = path.split(".");
const last = keys.pop()!;
let pointer = obj;
const parents: Array<{ parent: any; key: string }> = [];
for (const key of keys) {
if (pointer[key] == null) {
return obj;
}
parents.push({ parent: pointer, key });
pointer = pointer[key];
}
if (pointer && typeof pointer === "object" && last in pointer) {
if (options?.shallow) {
if (typeof pointer[last] === "string") {
delete pointer[last];
} else if (
typeof pointer[last] === "object" &&
!Array.isArray(pointer[last])
) {
if ("message" in pointer[last]) {
delete pointer[last].message;
}
}
} else {
delete pointer[last];
}
}
if (!options?.shallow) {
for (let i = parents.length - 1; i >= 0; i--) {
const { parent, key } = parents[i];
const val = parent[key];
const isEmptyObj =
val && !Array.isArray(val) && Object.keys(val).length === 0;
const isEmptyArr = Array.isArray(val) && val.length === 0;
if (isEmptyObj || isEmptyArr) {
delete parent[key];
}
}
}
return obj;
}
function makeStrict(schema: z.ZodSchema) {
if (schema instanceof z.ZodObject) {
return schema.strict();
}
return schema;
}
+80
View File
@@ -0,0 +1,80 @@
@layer base {
.content {
max-width: 24rem;
padding: var(--spacing-4);
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius-md);
font-size: 0.9375rem;
line-height: 1.5;
box-shadow: var(--shadow-lg);
font-family: var(--font-family-base);
z-index: var(--z-overlay);
overflow-wrap: break-word;
word-break: keep-all;
animation-duration: var(--animation-duration-normal);
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.content[data-state="open"][data-side="top"] {
animation-name: slideDownAndFade;
}
.content[data-state="open"][data-side="bottom"] {
animation-name: slideUpAndFade;
}
.content[data-state="open"][data-side="left"] {
animation-name: slideRightAndFade;
}
.content[data-state="open"][data-side="right"] {
animation-name: slideLeftAndFade;
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import styles from "./HoverCard.module.css"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={`${styles.content} ${className ?? ""}`}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
+71
View File
@@ -0,0 +1,71 @@
@layer base {
.input {
font-family: var(--font-family-base);
font-size: 1rem;
line-height: 1.5;
color: var(--foreground);
background-color: #101217;
border: 1px solid #303641;
border-radius: 3px;
padding: var(--spacing-2) var(--spacing-3);
width: 100%;
transition:
border-color var(--animation-duration-normal) ease,
background-color var(--animation-duration-normal) ease,
box-shadow var(--animation-duration-normal) ease,
transform var(--animation-duration-fast) ease;
outline: none;
height: 2.5rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .02);
}
.input::placeholder {
color: var(--muted-foreground);
opacity: 0.7;
}
.input:hover {
border-color: #46505c;
background-color: #12151a;
box-shadow: inset 0 1px 0 rgba(255,255,255,.025);
}
.input:focus {
border-color: var(--primary);
box-shadow:
0 0 0 2px rgba(23,234,242,.1),
0 0 18px rgba(23,234,242,.06);
background-color: #12151a;
}
.input:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
cursor: not-allowed;
opacity: 0.75;
box-shadow: none;
transform: none;
}
:global(.dark) .input {
background-color: color-mix(in srgb, var(--background) 80%, black);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.05);
}
:global(.dark) .input:hover {
background-color: color-mix(in srgb, var(--background) 75%, black);
border-color: color-mix(in srgb, var(--primary) 90%, white);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 2px 4px rgba(0, 0, 0, 0.15);
}
:global(.dark) .input:focus {
background-color: color-mix(in srgb, var(--background) 80%, black);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent),
0 2px 4px rgba(0, 0, 0, 0.15);
}
}
+18
View File
@@ -0,0 +1,18 @@
import React, { forwardRef, InputHTMLAttributes } from "react";
import styles from "./Input.module.css";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={`${styles.input} ${className || ""}`}
{...props}
/>
);
}
);
Input.displayName = "Input";
+122
View File
@@ -0,0 +1,122 @@
.panel {
position: fixed;
right: 22px;
bottom: 104px;
z-index: 24;
width: min(390px, calc(100vw - 100px));
height: min(520px, calc(100vh - 150px));
display: flex;
flex-direction: column;
background: #111319;
border: 1px solid #2f3540;
box-shadow: 0 8px 28px rgba(0,0,0,.38);
}
.panel header {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px 10px 14px;
background: #15181f;
border-bottom: 1px solid #2a2f39;
}
.panel header span,
.panel header strong,
.panel header small {
display: block;
}
.panel header span {
color: #667481;
font: 500 .58rem/1 var(--font-family-monospace);
letter-spacing: .12em;
}
.panel header strong {
margin-top: 4px;
font-size: .8rem;
}
.panel header small {
margin-top: 2px;
color: #77828e;
font-size: .65rem;
}
.body {
flex: 1;
overflow: auto;
padding: 18px;
scroll-behavior: smooth;
}
.synced {
display: grid;
gap: 2px;
}
.synced p {
margin: 0;
padding: 8px 6px;
color: #707a85;
font-size: .94rem;
line-height: 1.45;
transition: color 140ms ease, transform 140ms ease;
transform-origin: left center;
}
.synced p.active {
color: #f4f6f8;
transform: scale(1.03);
}
.plain {
white-space: pre-wrap;
color: #c7cdd4;
font-size: .82rem;
line-height: 1.75;
}
.empty,
.error {
min-height: 160px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
color: #6f7a86;
text-align: center;
font-size: .72rem;
}
.error {
color: #efa0b7;
}
@media (max-width: 900px) {
.panel {
right: 8px;
left: 8px;
width: auto;
bottom: 142px;
height: min(430px, calc(100vh - 170px));
border-radius: 4px;
z-index: 40;
}
.panel header {
min-height: 56px;
}
.body {
padding: 14px;
}
.synced p {
padding: 7px 4px;
font-size: .88rem;
}
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useMemo, useRef } from "react";
import { Music2, X } from "lucide-react";
import { Button } from "./Button";
import styles from "./LyricsPanel.module.css";
type LyricsPanelProps = {
title?: string;
artist?: string;
plain?: string;
synced?: string;
instrumental?: boolean;
positionMs: number;
loading?: boolean;
error?: string;
onClose: () => void;
};
type SyncedLine = {
timeMs: number;
text: string;
};
function parseSynced(value?: string) {
if (!value) return [] as SyncedLine[];
return value
.split(/\r?\n/)
.map((line) => {
const match = line.match(/^\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\](.*)$/);
if (!match) return undefined;
const minutes = Number(match[1]);
const seconds = Number(match[2]);
const fraction = match[3] || "0";
const millis = fraction.length === 3
? Number(fraction)
: fraction.length === 2
? Number(fraction) * 10
: Number(fraction) * 100;
return {
timeMs: (minutes * 60 + seconds) * 1000 + millis,
text: match[4].trim(),
};
})
.filter((line): line is SyncedLine => Boolean(line));
}
export function LyricsPanel({
title,
artist,
plain,
synced,
instrumental,
positionMs,
loading,
error,
onClose,
}: LyricsPanelProps) {
const lines = useMemo(() => parseSynced(synced), [synced]);
const activeIndex = useMemo(() => {
let active = -1;
for (let index = 0; index < lines.length; index += 1) {
if (lines[index].timeMs <= positionMs + 120) active = index;
else break;
}
return active;
}, [lines, positionMs]);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeIndex < 0 || !containerRef.current) return;
const active = containerRef.current.querySelector(
`[data-line="${activeIndex}"]`,
);
active?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [activeIndex]);
return (
<aside className={styles.panel} aria-label="Lyrics">
<header>
<div>
<span>LYRICS</span>
<strong>{title || "Now playing"}</strong>
<small>{artist}</small>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} aria-label="Close lyrics">
<X size={16} />
</Button>
</header>
<div className={styles.body} ref={containerRef}>
{loading ? <div className={styles.empty}>Loading lyrics…</div> : null}
{error ? <div className={styles.error}>{error}</div> : null}
{!loading && !error && instrumental ? (
<div className={styles.empty}><Music2 size={22} /> Instrumental track</div>
) : null}
{!loading && !error && lines.length ? (
<div className={styles.synced}>
{lines.map((line, index) => (
<p
key={`${line.timeMs}-${index}`}
data-line={index}
className={index === activeIndex ? styles.active : undefined}
>
{line.text || "♪"}
</p>
))}
</div>
) : null}
{!loading && !error && !lines.length && plain ? (
<div className={styles.plain}>{plain}</div>
) : null}
{!loading && !error && !instrumental && !lines.length && !plain ? (
<div className={styles.empty}>No lyrics found for this track.</div>
) : null}
</div>
</aside>
);
}
+51
View File
@@ -0,0 +1,51 @@
.nav{
display:none;
}
@media(max-width:900px){
.nav{
position:fixed;
left:0;
right:0;
bottom:0;
z-index:60;
height:68px;
display:grid;
grid-template-columns:repeat(5,1fr);
align-items:stretch;
padding:6px 8px max(6px,env(safe-area-inset-bottom));
background:#0f1116;
border-top:1px solid #2b3038;
box-shadow:0 -8px 24px rgba(0,0,0,.28);
}
.item{
appearance:none;
border:0;
background:transparent;
color:#6f7984;
text-decoration:none;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
gap:4px;
min-width:0;
padding:4px 2px;
font:600 .58rem/1 var(--font-family-base);
cursor:pointer;
}
.item svg{flex:0 0 auto}
.item span{
width:100%;
overflow:hidden;
white-space:nowrap;
text-overflow:ellipsis;
text-align:center;
}
.item.active{
color:#79e6ef;
}
.menu{
min-width:190px;
margin-bottom:4px;
}
}
+83
View File
@@ -0,0 +1,83 @@
import { Link, useLocation } from "react-router-dom";
import {
Activity,
AudioLines,
Database,
Download,
Ellipsis,
Heart,
History,
Home,
Settings2,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "./DropdownMenu";
import styles from "./MobileNav.module.css";
function activeClass(current: string, target: string) {
return current === target ? styles.active : "";
}
export function MobileNav() {
const location = useLocation();
const path = location.pathname;
const moreActive = ["/library", "/analyze", "/history", "/settings"].includes(path);
return (
<nav className={styles.nav} aria-label="Mobile navigation">
<Link className={styles.item + " " + activeClass(path, "/")} to="/">
<Home size={20} />
<span>Home</span>
</Link>
<Link className={styles.item + " " + activeClass(path, "/identify")} to="/identify">
<AudioLines size={20} />
<span>Identify</span>
</Link>
<Link className={styles.item + " " + activeClass(path, "/convert")} to="/convert">
<Download size={20} />
<span>Convert</span>
</Link>
<Link
className={
styles.item +
" " +
(path === "/spotify-library" || path.startsWith("/artist/") || path.startsWith("/album/")
? styles.active
: "")
}
to="/spotify-library"
>
<Heart size={20} />
<span>Spotify</span>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className={styles.item + " " + (moreActive ? styles.active : "")} type="button">
<Ellipsis size={20} />
<span>More</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" sideOffset={10} className={styles.menu}>
<DropdownMenuItem asChild>
<Link to="/library"><Database size={16} /> Local Library</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/analyze"><Activity size={16} /> Audio Analyzer</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/history"><History size={16} /> History</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings"><Settings2 size={16} /> Settings</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
);
}
+116
View File
@@ -0,0 +1,116 @@
@layer base {
.root {
width: 100%;
margin: var(--spacing-2) 0;
}
.content {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--spacing-1);
list-style: none;
margin: 0;
padding: 0;
}
.item {
display: inline-flex;
align-items: center;
justify-content: center;
}
.link {
min-width: 2.25rem;
height: 2.25rem;
padding: 0 var(--spacing-2);
font-size: 0.875rem;
border: 1px solid var(--border);
background-color: transparent;
color: var(--foreground);
}
.link:hover {
background-color: var(--muted);
color: var(--foreground);
}
.link:active {
background-color: color-mix(in srgb, var(--muted) 80%, white);
}
.link:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.active {
background-color: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.active:hover {
background-color: color-mix(in srgb, var(--primary) 90%, black);
border-color: color-mix(in srgb, var(--primary) 90%, black);
color: var(--primary-foreground);
}
.active:active {
background-color: color-mix(in srgb, var(--primary) 80%, black);
border-color: color-mix(in srgb, var(--primary) 80%, black);
}
.icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0 var(--spacing-1);
color: var(--surface-foreground);
}
.link span {
display: inline-flex;
align-items: center;
}
.link span + .icon {
margin-left: var(--spacing-1);
}
.icon + span {
margin-left: var(--spacing-1);
}
@media (max-width: 640px) {
.link span:not(.icon) {
display: none;
}
.link {
min-width: 2rem;
height: 2rem;
padding: 0;
}
.ellipsis {
height: 2rem;
}
}
:global(.dark) .link:hover {
background-color: var(--muted);
}
:global(.dark) .link:active {
background-color: color-mix(in srgb, var(--muted) 80%, black);
}
}
+106
View File
@@ -0,0 +1,106 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import styles from "./Pagination.module.css"
import buttonStyles from "./Button.module.css"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={`${styles.root} ${className ?? ""}`}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={`${styles.content} ${className ?? ""}`}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={`${styles.item} ${className ?? ""}`} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={`${buttonStyles.button} ${styles.link} ${isActive ? styles.active : ""} ${className ?? ""}`}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
className={`${styles.link} ${className ?? ""}`}
{...props}
>
<ChevronLeft className={styles.icon} />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
className={`${styles.link} ${className ?? ""}`}
{...props}
>
<span>Next</span>
<ChevronRight className={styles.icon} />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={`${styles.ellipsis} ${className ?? ""}`}
{...props}
>
<MoreHorizontal className={styles.icon} />
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+53
View File
@@ -0,0 +1,53 @@
@layer base {
.content {
position: relative;
z-index: var(--z-overlay);
min-width: 12rem;
max-width: 24rem;
pointer-events: all;
transform-origin: var(--radix-popover-content-transform-origin);
animation: contentFadeIn var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
box-shadow: var(--shadow-md);
border-radius: var(--radius);
outline: none;
color: var(--popup-foreground);
}
.withBackgroundAndPadding {
background-color: var(--popup);
padding: var(--spacing-4);
}
.content:focus-visible {
box-shadow: var(--shadow-focus);
}
@keyframes contentFadeIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
.content[data-state="closed"] {
animation: contentFadeOut var(--animation-duration-fast)
cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes contentFadeOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import styles from "./Popover.module.css";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
removeBackgroundAndPadding?: boolean;
}
>(
(
{
className,
removeBackgroundAndPadding = false,
align = "center",
sideOffset = 4,
...props
},
ref,
) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={`${styles.content} ${removeBackgroundAndPadding ? "" : styles.withBackgroundAndPadding} ${className ?? ""}`}
{...props}
/>
</PopoverPrimitive.Portal>
),
);
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
+21
View File
@@ -0,0 +1,21 @@
@layer base {
.progress {
width: 100%;
height: 0.5rem;
background-color: var(--muted);
border-radius: var(--radius-full);
overflow: hidden;
box-shadow: var(--shadow);
position: relative;
}
.indicator {
position: absolute;
width: 100%;
height: 100%;
background-color: var(--primary);
border-radius: var(--radius-full);
transform: translateX(-100%);
transition: transform var(--animation-duration-normal) ease-out;
}
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import styles from "./Progress.module.css"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={`${styles.progress} ${className ?? ""}`}
{...props}
>
<ProgressPrimitive.Indicator
className={styles.indicator}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+105
View File
@@ -0,0 +1,105 @@
@layer base {
.group {
display: grid;
gap: var(--spacing-2);
}
.item {
position: relative;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border);
border-radius: var(--radius-full);
background-color: transparent;
cursor: pointer;
margin: 0;
padding: 0;
transition: all var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
appearance: none;
flex-shrink: 0;
}
.item:hover {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 15%, transparent);
}
.item:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-color: var(--primary);
}
.item[data-state="checked"] {
border-color: var(--primary);
transform: scale(1);
animation: radioPulse var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.item:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: var(--muted);
}
.indicator {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
padding: 0;
}
.indicatorInner {
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background-color: var(--primary);
transform: scale(0);
transition: transform var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.item[data-state="checked"] .indicatorInner {
transform: scale(1);
animation: dotAppear var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.item:disabled .indicatorInner {
background-color: var(--muted);
}
@keyframes radioPulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 40%, transparent);
}
70% {
transform: scale(1.02);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 0%, transparent);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 0%, transparent);
}
}
@keyframes dotAppear {
0% {
transform: scale(0);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
"use client"
import styles from './RadioGroup.module.css';
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={`${styles.group} ${className || ''}`}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={`${styles.item} ${className || ''}`}
{...props}
>
<RadioGroupPrimitive.Indicator className={styles.indicator}>
<div className={styles.indicatorInner} />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
+72
View File
@@ -0,0 +1,72 @@
.panelGroup {
display: flex;
width: 100%;
height: 100%;
background-color: var(--surface);
border-radius: var(--radius);
overflow: hidden;
}
.panel {
display: flex;
flex-direction: column;
background-color: var(--card);
border-radius: var(--radius);
overflow: auto;
padding: var(--spacing-4);
}
.resizeHandle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
transition: background-color var(--animation-duration-fast) ease;
}
.resizeHandle[data-orientation="horizontal"] {
width: 0.25rem;
margin: 0 0.25rem;
cursor: col-resize;
}
.resizeHandle[data-orientation="vertical"] {
height: 0.25rem;
margin: 0.25rem 0;
cursor: row-resize;
}
.resizeHandle:hover {
background-color: var(--muted);
}
.resizeHandle:active {
background-color: var(--primary);
}
.resizeHandleGrip {
display: flex;
justify-content: center;
align-items: center;
width: 4px;
height: 4px;
border-radius: var(--radius-sm);
background-color: var(--surface);
color: var(--muted-foreground);
transition: background-color var(--animation-duration-fast) ease;
}
.resizeHandle:hover .resizeHandleGrip {
background-color: var(--muted);
color: var(--foreground);
}
.resizeHandle:active .resizeHandleGrip {
background-color: var(--primary);
color: var(--primary-foreground);
}
.gripIcon {
color: inherit;
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import React from "react";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import styles from "./Resizable.module.css";
export const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={`${styles.panelGroup} ${className || ""}`}
{...props}
/>
);
export const ResizablePanel = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) => (
<ResizablePrimitive.Panel
className={`${styles.panel} ${className || ""}`}
{...props}
/>
);
export const ResizableHandle = ({
withHandle = true,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={`${styles.resizeHandle} ${className || ""}`}
{...props}
>
{withHandle && (
<div className={styles.resizeHandleGrip}>
<GripVertical size={12} strokeWidth={1.5} className={styles.gripIcon} />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
+32
View File
@@ -0,0 +1,32 @@
import { useMemo, useEffect } from "react";
import { useLocation } from "react-router-dom";
export const ScrollToHashElement = () => {
let location = useLocation();
let hashElement = useMemo(() => {
let hash = location.hash;
const removeHashCharacter = (str: string) => {
const result = str.slice(1);
return result;
};
if (hash) {
let element = document.getElementById(removeHashCharacter(hash));
return element;
} else {
return null;
}
}, [location]);
useEffect(() => {
if (hashElement) {
hashElement.scrollIntoView({
behavior: "smooth",
inline: "nearest",
});
}
}, [hashElement]);
return null;
};
+113
View File
@@ -0,0 +1,113 @@
@layer base {
.selectTrigger {
display: flex;
align-items: center;
justify-content: space-between;
height: 2.5rem;
padding: var(--spacing-2) var(--spacing-3);
cursor: pointer;
overflow: hidden;
}
.selectTrigger > *:not(svg) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selectTrigger[data-placeholder] {
color: var(--muted-foreground);
}
.selectIcon {
width: .9rem;
height: .9rem;
margin-left: var(--spacing-2);
color: var(--muted-foreground);
}
.selectContent {
z-index: var(--z-overlay);
min-width: 12rem;
max-width: 26rem;
max-height: var(--radix-select-content-available-height);
overflow: hidden;
color: var(--popup-foreground);
background: #181b22;
border: 1px solid #333943;
border-radius: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .28);
}
.selectViewport {
padding: 3px;
}
.scrollButton {
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #7b8692;
background: #181b22;
border: 0;
cursor: pointer;
}
.scrollIcon {
width: .9rem;
height: .9rem;
}
.selectLabel {
padding: 7px 9px;
color: #707b87;
font-size: .7rem;
font-weight: 600;
}
.selectItem {
position: relative;
min-height: 32px;
display: flex;
align-items: center;
padding: 6px 30px 6px 9px;
color: #d3d9df;
border-radius: 2px;
outline: none;
cursor: pointer;
user-select: none;
font-size: .74rem;
}
.selectItem:hover,
.selectItem:focus-visible {
color: #fff;
background: #252a33;
}
.selectItem[data-disabled] {
color: #66717d;
opacity: .55;
pointer-events: none;
}
.selectItemIndicator {
position: absolute;
right: 8px;
display: inline-flex;
align-items: center;
}
.checkIcon {
width: .85rem;
height: .85rem;
color: var(--primary);
}
.selectSeparator {
height: 1px;
margin: 3px;
background: #2a2f39;
}
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import inputStyles from "./Input.module.css";
import styles from "./Select.module.css";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ children, className, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={`${inputStyles.input} ${styles.selectTrigger} ${className || ""}`}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className={styles.selectIcon} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={`${styles.scrollButton} ${className || ""}`}
{...props}
>
<ChevronUp className={styles.scrollIcon} />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={`${styles.scrollButton} ${className || ""}`}
{...props}
>
<ChevronDown className={styles.scrollIcon} />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ children, position = "popper", className, ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={`${styles.selectContent} ${className || ""}`}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport className={styles.selectViewport}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={`${styles.selectLabel} ${className || ""}`}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ children, className, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={`${styles.selectItem} ${className || ""}`}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className={styles.selectItemIndicator}>
<Check className={styles.checkIcon} />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={`${styles.selectSeparator} ${className || ""}`}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+28
View File
@@ -0,0 +1,28 @@
@layer base {
.separator {
position: relative;
width: 100%;
border: none;
background: linear-gradient(
to right,
transparent,
var(--border) 10%,
var(--border) 90%,
transparent
);
height: 1px;
opacity: 0.6;
}
.separator.vertical {
width: 1px;
height: 100%;
background: linear-gradient(
to bottom,
transparent,
var(--border) 10%,
var(--border) 90%,
transparent
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import styles from "./Separator.module.css";
interface SeparatorProps {
orientation?: "horizontal" | "vertical";
className?: string;
decorative?: boolean;
}
export const Separator = ({
orientation = "horizontal",
className = "",
decorative = true,
}: SeparatorProps) => {
return (
<SeparatorPrimitive.Root
className={`${styles.separator} ${styles[orientation]} ${className ?? ""}`}
orientation={orientation}
decorative={decorative}
/>
);
};
+190
View File
@@ -0,0 +1,190 @@
@layer base {
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
background-color: color-mix(in srgb, black 50%, transparent);
backdrop-filter: blur(2px);
animation: overlayShow var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.content {
position: fixed;
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
background-color: var(--popup);
color: var(--popup-foreground);
box-shadow: var(--shadow-lg);
transition: transform var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
overflow-y: auto;
}
.top {
top: 0;
left: 0;
right: 0;
height: 80vh;
max-height: 500px;
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
animation: slideDownAndFade var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.bottom {
bottom: 0;
left: 0;
right: 0;
height: 80vh;
max-height: 500px;
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
animation: slideUpAndFade var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.left {
top: 0;
left: 0;
bottom: 0;
width: 100%;
max-width: 400px;
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
animation: slideRightAndFade var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.right {
top: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 400px;
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
animation: slideLeftAndFade var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
}
.close {
position: absolute;
top: var(--spacing-4);
right: var(--spacing-4);
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
background-color: var(--muted);
color: var(--muted-foreground);
border: none;
cursor: pointer;
transition:
background-color var(--animation-duration-fast) ease,
transform var(--animation-duration-fast) ease;
}
.close:hover {
background-color: var(--muted-foreground);
color: var(--muted);
transform: scale(1.05);
}
.close:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.closeIcon {
width: 1rem;
height: 1rem;
}
.header {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-6);
padding-right: var(--spacing-12);
border-bottom: 1px solid var(--border);
}
.footer {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
margin-top: auto;
padding: var(--spacing-6);
border-top: 1px solid var(--border);
}
.title {
font-family: var(--font-family-heading);
font-size: 1.25rem;
font-weight: 600;
color: var(--popup-foreground);
}
.description {
font-size: 0.875rem;
color: var(--muted-foreground);
line-height: 1.5;
}
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUpAndFade {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes slideDownAndFade {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideRightAndFade {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@media (max-width: 640px) {
.left,
.right {
max-width: 100%;
}
}
}
+113
View File
@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import styles from "./Sheet.module.css"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={`${styles.overlay} ${className ?? ""}`}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
side?: "top" | "bottom" | "left" | "right";
container?: React.ComponentPropsWithoutRef<
typeof SheetPrimitive.Portal
>["container"];
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, container, ...props }, ref) => (
<SheetPortal container={container}>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={`${styles.content} ${styles[side]} ${className ?? ""}`}
{...props}
>
{children}
<SheetPrimitive.Close className={styles.close}>
<X className={styles.closeIcon} />
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={`${styles.header} ${className ?? ""}`}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={`${styles.footer} ${className ?? ""}`}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={`${styles.title} ${className ?? ""}`}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={`${styles.description} ${className ?? ""}`}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+46
View File
@@ -0,0 +1,46 @@
@layer base {
.skeleton {
position: relative;
overflow: hidden;
background-color: var(--muted);
border-radius: var(--radius);
width: 100%;
height: 1rem;
}
.skeleton::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
:global(.dark) .skeleton {
background-color: var(--muted);
}
:global(.dark) .skeleton::after {
background-image: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import styles from "./Skeleton.module.css"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={`${styles.skeleton} ${className ?? ""}`}
{...props}
/>
)
}
export { Skeleton }
+61
View File
@@ -0,0 +1,61 @@
@layer base {
.slider {
position: relative;
display: flex;
align-items: center;
min-width: 12rem;
height: 1.5rem;
touch-action: none;
user-select: none;
cursor: pointer;
}
.track {
position: absolute;
height: 0.375rem;
width: 100%;
background-color: var(--muted);
border-radius: var(--radius-full);
transition: background-color var(--animation-duration-fast) ease;
}
.range {
position: absolute;
height: 100%;
background-color: var(--primary);
border-radius: var(--radius-full);
transition: background-color var(--animation-duration-fast) ease;
}
.thumb {
display: block;
width: 1rem;
height: 1rem;
background-color: var(--primary);
border-radius: var(--radius-full);
border: 2px solid var(--background);
box-shadow: var(--shadow);
transition:
transform var(--animation-duration-fast) ease,
box-shadow var(--animation-duration-fast) ease;
}
.slider:hover .track {
background-color: var(--muted-foreground);
}
.slider:hover .thumb,
.slider:focus-visible .thumb {
transform: scale(1.1);
box-shadow: var(--shadow-md);
}
.slider:focus-visible {
outline: none;
}
.slider:focus-visible .track {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import styles from "./Slider.module.css";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => {
const isRange =
(Array.isArray(props.defaultValue) && props.defaultValue.length > 1) ||
(Array.isArray(props.value) && props.value.length > 1);
return (
<SliderPrimitive.Root
ref={ref}
className={`${styles.slider} ${className ?? ""}`}
{...props}
>
<SliderPrimitive.Track className={styles.track}>
<SliderPrimitive.Range className={styles.range} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className={styles.thumb} />
{isRange && <SliderPrimitive.Thumb className={styles.thumb} />}
</SliderPrimitive.Root>
);
});
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+179
View File
@@ -0,0 +1,179 @@
@layer base {
.toaster {
--viewport-padding: var(--spacing-4);
--toast-width: 360px;
--toast-border-radius: var(--radius);
--toast-font-family: var(--font-family-base);
--toast-animation-duration: var(--animation-duration-normal);
}
.toast {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-4);
background-color: var(--card);
color: var(--card-foreground);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
border: 1px solid var(--border);
width: var(--toast-width);
position: relative;
animation: slideIn var(--toast-animation-duration) cubic-bezier(0.16, 1, 0.3, 1);
transition: transform 0.2s ease, opacity 0.2s ease;
}
.toast:focus {
outline: none;
box-shadow: var(--shadow-focus);
}
.toast:hover {
transform: translateY(-2px);
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.title {
font-weight: 600;
font-size: 1rem;
color: var(--card-foreground);
line-height: 1.4;
}
.description {
font-size: 0.875rem;
color: var(--muted-foreground);
line-height: 1.4;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
color: var(--primary);
}
.actionButton {
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--primary);
color: var(--primary-foreground);
border: none;
border-radius: var(--radius-sm);
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--animation-duration-fast) ease;
margin-left: var(--spacing-2);
white-space: nowrap;
}
.actionButton:hover {
background-color: color-mix(in srgb, var(--primary) 90%, black);
}
.actionButton:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.actionButton:active {
background-color: color-mix(in srgb, var(--primary) 80%, black);
}
.cancelButton {
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--muted);
color: var(--muted-foreground);
border: none;
border-radius: var(--radius-sm);
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--animation-duration-fast) ease;
white-space: nowrap;
}
.cancelButton:hover {
background-color: color-mix(in srgb, var(--muted) 80%, white);
}
.cancelButton:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}
.cancelButton:active {
background-color: color-mix(in srgb, var(--muted) 75%, white);
}
.closeButton {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-2);
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius-sm);
background-color: transparent;
color: var(--muted-foreground);
border: none;
cursor: pointer;
opacity: 0.7;
transition: opacity var(--animation-duration-fast) ease,
background-color var(--animation-duration-fast) ease;
}
.closeButton:hover {
opacity: 1;
background-color: var(--muted);
}
.closeButton:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
opacity: 1;
}
:global(.dark) .toast {
border-color: var(--border);
}
:global(.dark) .actionButton:hover {
background-color: color-mix(in srgb, var(--primary) 90%, white);
}
:global(.dark) .actionButton:active {
background-color: color-mix(in srgb, var(--primary) 80%, white);
}
:global(.dark) .cancelButton:hover {
background-color: color-mix(in srgb, var(--muted) 80%, black);
}
:global(.dark) .cancelButton:active {
background-color: color-mix(in srgb, var(--muted) 70%, black);
}
@keyframes slideIn {
from {
transform: translateX(calc(100% + var(--viewport-padding)));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Toaster as Sonner } from "sonner";
import styles from "./SonnerToaster.module.css";
type ToasterProps = React.ComponentProps<typeof Sonner>;
export const SonnerToaster = ({ className, ...props }: ToasterProps) => {
return (
<Sonner
className={`${styles.toaster} ${className ?? ""}`}
toastOptions={{
unstyled: true,
classNames: {
toast: styles.toast,
content: styles.content,
title: styles.title,
actionButton: styles.actionButton,
cancelButton: styles.cancelButton,
closeButton: styles.closeButton,
description: styles.description,
icon: styles.icon,
},
}}
{...props}
/>
);
};
+56
View File
@@ -0,0 +1,56 @@
@layer base {
.spinner {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: currentColor;
}
.spinnerInner {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: var(--spinner-color, var(--primary));
animation: spin var(--animation-duration-slow) linear infinite;
}
.sm {
width: 1rem;
height: 1rem;
}
.sm .spinnerInner {
border-width: 2px;
}
.md {
width: 1.5rem;
height: 1.5rem;
}
.md .spinnerInner {
border-width: 2.5px;
}
.lg {
width: 2.5rem;
height: 2.5rem;
}
.lg .spinnerInner {
border-width: 3px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import styles from "./Spinner.module.css";
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "sm" | "md" | "lg";
}
export const Spinner: React.FC<SpinnerProps> = ({
size = "md",
className,
...props
}) => {
return (
<div
className={`${styles.spinner} ${styles[size]} ${className || ""}`}
{...props}
role="status"
>
<div className={styles.spinnerInner}></div>
</div>
);
};
+209
View File
@@ -0,0 +1,209 @@
.player {
position: sticky;
bottom: var(--spacing-4);
z-index: 30;
width: min(1180px, calc(100% - 2rem));
margin: var(--spacing-6) auto 0;
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(320px, 1.25fr) minmax(240px, 1fr);
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-4);
background: #14171d;
border: 1px solid #2f3540;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
backdrop-filter: blur(22px);
}
.nowPlaying,
.transport,
.controls,
.playerMeta,
.timeline {
display: flex;
align-items: center;
}
.nowPlaying {
gap: var(--spacing-3);
min-width: 0;
}
.cover {
width: 44px;
height: 44px;
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 3px;
background: #1e222a;
color: var(--primary);
border: 1px solid var(--border);
overflow: hidden;
}
.cover img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
background: #191414;
}
.trackText {
min-width: 0;
}
.trackText strong,
.trackText span {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trackText strong {
font-size: .88rem;
}
.trackText span,
.albumText {
margin-top: 2px;
color: var(--muted-foreground);
font-size: .72rem;
}
.spotifyLink {
display: inline-flex;
align-items: center;
gap: 4px;
color: #9aa4af;
text-decoration: none;
font-size: .65rem;
}
.spotifyLink:hover {
color: #fff;
}
.transport {
flex-direction: column;
gap: var(--spacing-2);
}
.controls {
gap: var(--spacing-1);
}
.timeline {
width: 100%;
gap: var(--spacing-2);
color: var(--muted-foreground);
font: .66rem/1 var(--font-family-monospace);
}
.timelineSlider {
flex: 1;
}
.playerMeta {
justify-content: flex-end;
gap: var(--spacing-2);
}
.albumText {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.volumeSlider {
width: 78px;
}
@media (max-width: 860px) {
.player {
grid-template-columns: 1fr auto;
}
.transport {
order: 3;
grid-column: 1 / -1;
}
.albumText,
.playerMeta > svg,
.volumeSlider {
display: none;
}
}
@media (max-width: 900px) {
.player {
bottom: 0;
width: 100%;
margin: 0;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 4px;
padding: 6px 7px;
border-radius: 3px;
box-shadow: none;
backdrop-filter: none;
}
.cover {
width: 38px;
height: 38px;
}
.nowPlaying {
gap: 8px;
}
.trackText strong {
font-size: .74rem;
}
.trackText span {
max-width: 150px;
font-size: .62rem;
}
.transport {
order: initial;
grid-column: auto;
flex-direction: row;
gap: 0;
}
.timeline {
display: none;
}
.controls {
gap: 0;
}
.playerMeta {
display: flex;
gap: 0;
}
.playerMeta > a,
.playerMeta > svg,
.albumText,
.volumeSlider {
display: none;
}
.playerMeta button {
font-size: 0;
padding-inline: var(--spacing-3);
}
.playerMeta button svg {
margin: 0;
}
}
+156
View File
@@ -0,0 +1,156 @@
import { Captions, ExternalLink, Pause, Play, Radio, SkipBack, SkipForward, Volume2 } from "lucide-react";
import { Button } from "./Button";
import { Slider } from "./Slider";
import styles from "./SpotifyPlayerBar.module.css";
type SpotifyPlayerBarProps = {
className?: string;
connected?: boolean;
ready?: boolean;
trackTitle?: string;
artist?: string;
album?: string;
imageUrl?: string;
externalUrl?: string;
isPlaying?: boolean;
positionMs?: number;
durationMs?: number;
volume?: number;
error?: string;
connectHref?: string;
onTogglePlay?: () => void;
onPrevious?: () => void;
onNext?: () => void;
onSeek?: (positionMs: number) => void;
onVolume?: (volume: number) => void;
lyricsOpen?: boolean;
onToggleLyrics?: () => void;
};
function formatTime(ms: number) {
if (!Number.isFinite(ms) || ms <= 0) return "0:00";
const total = Math.floor(ms / 1000);
const minutes = Math.floor(total / 60);
return `${minutes}:${String(total % 60).padStart(2, "0")}`;
}
export function SpotifyPlayerBar({
className,
connected = false,
ready = false,
trackTitle,
artist,
album = "Aurora SpotiRip",
imageUrl,
externalUrl,
isPlaying = false,
positionMs = 0,
durationMs = 0,
volume = 0.7,
error,
connectHref = "/_api/spotify-auth",
onTogglePlay,
onPrevious,
onNext,
onSeek,
onVolume,
lyricsOpen = false,
onToggleLyrics,
}: SpotifyPlayerBarProps) {
const usable = connected && ready;
const title = trackTitle || (connected ? "Nothing playing" : "Spotify playback");
const subtitle =
error ||
artist ||
(!connected
? "Connect your account to activate playback"
: ready
? "Press play on any song"
: "Starting Spotify playback…");
return (
<section className={`${styles.player} ${className || ""}`} aria-label="Spotify player">
<div className={styles.nowPlaying}>
<div className={styles.cover}>
{imageUrl ? <img src={imageUrl} alt="" /> : <Radio size={20} />}
</div>
<div className={styles.trackText}>
<strong>{title}</strong>
<span>{subtitle}</span>
</div>
</div>
<div className={styles.transport}>
<div className={styles.controls}>
<Button variant="ghost" size="icon-sm" disabled={!usable} onClick={onPrevious} aria-label="Previous track">
<SkipBack size={17} />
</Button>
<Button
size="icon"
disabled={!usable}
onClick={onTogglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} fill="currentColor" />}
</Button>
<Button variant="ghost" size="icon-sm" disabled={!usable} onClick={onNext} aria-label="Next track">
<SkipForward size={17} />
</Button>
</div>
<div className={styles.timeline}>
<span>{formatTime(positionMs)}</span>
<Slider
className={styles.timelineSlider}
min={0}
max={Math.max(durationMs, 1)}
step={1000}
value={[Math.min(positionMs, Math.max(durationMs, 1))]}
disabled={!usable || durationMs <= 0}
onValueCommit={(value) => onSeek?.(value[0] || 0)}
/>
<span>{formatTime(durationMs)}</span>
</div>
</div>
<div className={styles.playerMeta}>
<div className={styles.albumText}>{album}</div>
{connected && externalUrl ? (
<a
className={styles.spotifyLink}
href={externalUrl}
target="_blank"
rel="noreferrer"
>
Spotify <ExternalLink size={12} />
</a>
) : null}
{connected ? (
<>
<Button
variant={lyricsOpen ? "outline" : "ghost"}
size="icon-sm"
onClick={onToggleLyrics}
aria-label="Toggle lyrics"
>
<Captions size={16} />
</Button>
<Volume2 size={17} />
<Slider
className={styles.volumeSlider}
min={0}
max={1}
step={0.05}
value={[volume]}
onValueChange={(value) => onVolume?.(value[0] ?? volume)}
/>
</>
) : (
<Button size="sm" asChild>
<a href={connectHref}>
Connect Spotify <ExternalLink size={14} />
</a>
</Button>
)}
</div>
</section>
);
}
+54
View File
@@ -0,0 +1,54 @@
@layer base {
.root {
all: unset;
position: relative;
display: inline-flex;
align-items: center;
width: 42px;
height: 24px;
background-color: var(--muted);
border-radius: var(--radius-full);
transition: background-color var(--animation-duration-normal) ease;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.root:hover {
background-color: var(--muted-foreground);
}
.root:focus-visible {
box-shadow: var(--shadow-focus);
}
.root[data-state="checked"] {
background-color: var(--primary);
}
.root[data-state="checked"]:hover {
background-color: color-mix(in srgb, var(--primary), black 10%);
}
.thumb {
display: block;
width: 20px;
height: 20px;
background-color: var(--card);
border-radius: var(--radius-full);
box-shadow: 0 1px 2px color-mix(in srgb, black 10%, transparent);
transition: transform var(--animation-duration-normal)
cubic-bezier(0.16, 1, 0.3, 1);
transform: translateX(2px);
will-change: transform;
}
.root[data-state="checked"] .thumb {
transform: translateX(20px);
}
.root[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
+23
View File
@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import styles from "./Switch.module.css"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={`${styles.root} ${className ?? ""}`}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={`${styles.thumb} ${className ?? ""}`}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+107
View File
@@ -0,0 +1,107 @@
@layer base {
.tabsList {
display: flex;
overflow-x: auto;
scrollbar-width: none; -ms-overflow-style: none; margin: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tabsList::-webkit-scrollbar {
display: none; }
.tabsTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-family-base);
font-size: 0.9rem;
font-weight: 500;
color: var(--muted-foreground);
background-color: transparent;
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: 0;
cursor: pointer;
position: relative;
transition: color var(--animation-duration-normal)
cubic-bezier(0.22, 1, 0.36, 1);
white-space: nowrap;
}
.tabsTrigger:hover {
color: var(--foreground);
}
.tabsTrigger[data-state="active"] {
color: var(--primary);
font-weight: 600;
}
.tabsTrigger[data-state="active"]::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: var(--primary);
transform-origin: center;
transition: transform var(--animation-duration-normal)
cubic-bezier(0.22, 1, 0.36, 1);
transform: scaleX(1);
}
.tabsTrigger:not([data-state="active"]):hover::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background-color: color-mix(
in srgb,
var(--muted-foreground) 30%,
transparent
);
transform-origin: center;
transform: scaleX(0.6);
transition:
transform var(--animation-duration-normal) cubic-bezier(0.22, 1, 0.36, 1),
background-color var(--animation-duration-normal)
cubic-bezier(0.22, 1, 0.36, 1);
}
.tabsTrigger:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius-sm);
}
.tabsTrigger[disabled] {
opacity: 0.5;
cursor: not-allowed;
color: var(--muted-foreground);
}
.tabsContent {
color: var(--foreground);
outline: none;
}
.tabsContent[hidden] {
display: none !important;
}
:global(.dark) .tabsTrigger[data-state="active"]::after {
background-color: var(--primary);
}
:global(.dark) .tabsTrigger:not([data-state="active"]):hover::after {
background-color: color-mix(
in srgb,
var(--muted-foreground) 40%,
transparent
);
}
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import styles from "./Tabs.module.css";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={`${styles.tabsList} ${className || ""}`}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={`${styles.tabsTrigger} ${className || ""}`}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={`${styles.tabsContent} ${className || ""}`}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+32
View File
@@ -0,0 +1,32 @@
@layer base {
.textarea {
padding: var(--spacing-2);
width: 100%;
min-height: 6rem;
resize: vertical;
}
.textarea::placeholder {
color: var(--muted-foreground);
}
.noResize {
resize: none;
}
.clear {
background-color: transparent;
border: none;
padding: 0;
box-shadow: none;
border-radius: 0;
}
.clear:hover,
.clear:focus {
background-color: transparent;
border: none;
box-shadow: none;
}
}
+27
View File
@@ -0,0 +1,27 @@
import React, { forwardRef, TextareaHTMLAttributes } from "react";
import inputStyles from "./Input.module.css";
import styles from "./Textarea.module.css";
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
disableResize?: boolean;
variant?: "default" | "clear";
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
(
{ className, disableResize = false, variant = "default", ...props },
ref
) => {
const resizeClass = disableResize ? styles.noResize : "";
return (
<textarea
ref={ref}
className={`${inputStyles.input} ${styles.textarea} ${resizeClass} ${styles[variant]} ${className || ""}`}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
+38
View File
@@ -0,0 +1,38 @@
.container {
display: inline-flex;
position: relative;
}
.themeButton {
position: relative;
transition: transform var(--animation-duration-normal) ease;
}
.themeButton:hover {
transform: scale(1.05);
}
.icon {
color: var(--foreground);
transition: transform var(--animation-duration-normal) ease;
}
.menuIcon {
margin-right: var(--spacing-2);
color: currentColor;
}
.activeItem {
font-weight: 500;
color: var(--primary);
background-color: color-mix(in srgb, var(--primary), transparent 90%) !important;
}
.checkmark {
margin-left: auto;
font-weight: bold;
}
:global(.dark) .icon {
color: var(--foreground);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import React from "react";
import { Sun, Moon, SunMoon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./DropdownMenu";
import { Button } from "./Button";
import { useThemeMode, ThemeMode } from "../helpers/themeMode";
import styles from "./ThemeModeSwitch.module.css";
export interface ThemeModeSwitchProps {
className?: string;
}
export const ThemeModeSwitch = ({ className }: ThemeModeSwitchProps) => {
const { mode, switchToLightMode, switchToDarkMode, switchToAutoMode } =
useThemeMode();
const applyThemeMode = (newMode: ThemeMode) => {
switch (newMode) {
case "light":
switchToLightMode();
break;
case "dark":
switchToDarkMode();
break;
case "auto":
switchToAutoMode();
break;
}
};
const getThemeIcon = () => {
switch (mode) {
case "light":
return <Sun className={styles.icon} />;
case "dark":
return <Moon className={styles.icon} />;
case "auto":
return <SunMoon className={styles.icon} />;
default:
return <Sun className={styles.icon} />;
}
};
return (
<div className={`${styles.container} ${className || ""}`}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-md"
aria-label={`Current theme: ${mode}. Click to change theme`}
className={styles.themeButton}
>
{getThemeIcon()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className={mode === "light" ? styles.activeItem : ""}
onClick={() => applyThemeMode("light")}
>
<Sun size={16} className={styles.menuIcon} />
Light
{mode === "light" && <span className={styles.checkmark}>✓</span>}
</DropdownMenuItem>
<DropdownMenuItem
className={mode === "dark" ? styles.activeItem : ""}
onClick={() => applyThemeMode("dark")}
>
<Moon size={16} className={styles.menuIcon} />
Dark
{mode === "dark" && <span className={styles.checkmark}>✓</span>}
</DropdownMenuItem>
<DropdownMenuItem
className={mode === "auto" ? styles.activeItem : ""}
onClick={() => applyThemeMode("auto")}
>
<SunMoon size={16} className={styles.menuIcon} />
Auto
{mode === "auto" && <span className={styles.checkmark}>✓</span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
+145
View File
@@ -0,0 +1,145 @@
@layer base {
.toggle {
user-select: none;
}
.toggle:focus-visible {
box-shadow: var(--shadow-focus);
}
.toggle-size-sm {
height: 2rem;
min-width: 2rem;
padding: 0 var(--spacing-1);
font-size: 0.875rem;
border-radius: var(--radius-sm);
}
.toggle-size-md {
height: 2.5rem;
min-width: 2.5rem;
padding: 0 var(--spacing-2);
font-size: 0.9375rem;
}
.toggle-size-lg {
height: 3rem;
min-width: 3rem;
padding: 0 var(--spacing-3);
font-size: 1rem;
border-radius: var(--radius-md);
}
.toggle-size-sm svg {
width: 1rem;
height: 1rem;
}
.toggle-size-md svg {
width: 1.25rem;
height: 1.25rem;
}
.toggle-size-lg svg {
width: 1.5rem;
height: 1.5rem;
}
.toggle-variant-default {
background-color: transparent;
color: var(--foreground);
}
.toggle-variant-default:hover {
background-color: var(--muted);
}
.toggle-variant-default:active {
background-color: color-mix(in srgb, var(--muted) 80%, white);
}
.toggle-variant-default[data-state="on"] {
background-color: var(--primary);
color: var(--primary-foreground);
}
.toggle-variant-default[data-state="on"]:hover {
background-color: color-mix(in srgb, var(--primary) 90%, black);
}
.toggle-variant-default[data-state="on"]:active {
background-color: color-mix(in srgb, var(--primary) 80%, black);
}
.toggle-variant-outline {
background-color: transparent;
border-color: var(--border);
color: var(--foreground);
}
.toggle-variant-outline:hover {
background-color: var(--muted);
}
.toggle-variant-outline:active {
background-color: color-mix(in srgb, var(--muted) 80%, white);
}
.toggle-variant-outline[data-state="on"] {
background-color: var(--muted);
border-color: var(--primary);
color: var(--foreground);
}
.toggle-variant-outline[data-state="on"]:hover {
background-color: color-mix(in srgb, var(--muted) 80%, white);
}
.toggle-variant-outline[data-state="on"]:active {
background-color: color-mix(in srgb, var(--muted) 75%, white);
}
.toggle[disabled] {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
:global(.dark) .toggle-variant-default {
color: var(--foreground);
}
:global(.dark) .toggle-variant-default:hover {
background-color: var(--muted);
}
:global(.dark) .toggle-variant-default:active {
background-color: color-mix(in srgb, var(--muted) 80%, black);
}
:global(.dark) .toggle-variant-outline {
border-color: var(--border);
color: var(--foreground);
}
:global(.dark) .toggle-variant-outline:hover {
background-color: var(--muted);
}
:global(.dark) .toggle-variant-outline:active {
background-color: color-mix(in srgb, var(--muted) 80%, black);
}
:global(.dark) .toggle-variant-outline[data-state="on"] {
background-color: var(--muted);
border-color: var(--primary);
}
:global(.dark) .toggle-variant-outline[data-state="on"]:hover {
background-color: color-mix(in srgb, var(--muted) 80%, black);
}
:global(.dark) .toggle-variant-outline[data-state="on"]:active {
background-color: color-mix(in srgb, var(--muted) 70%, black);
}
}
+23
View File
@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import styles from "./Toggle.module.css"
import buttonStyles from "./Button.module.css"
export const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
{
variant?: "default" | "outline";
size?: "md" | "sm" | "lg";
}
>(({ className, variant = "default", size = "md", ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={`${buttonStyles.button} ${styles.toggle} ${styles["toggle-variant-" + variant]} ${styles["toggle-size-" + size]} ${className ?? ""}`}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
+39
View File
@@ -0,0 +1,39 @@
@layer base {
.toggleGroup {
display: inline-flex;
flex-wrap: wrap;
gap: var(--spacing-1);
align-items: center;
position: relative;
width: fit-content;
}
.toggleGroup[data-orientation="horizontal"] {
flex-direction: row;
}
.toggleGroup[data-orientation="vertical"] {
flex-direction: column;
align-items: stretch;
}
.toggleGroup[data-type="single"] {
position: relative;
}
.toggleGroup[data-type="multiple"] {
position: relative;
}
.toggleGroup:has(.toggle-size-sm) {
gap: var(--spacing-1);
}
.toggleGroup:has(.toggle-size-md) {
gap: var(--spacing-2);
}
.toggleGroup:has(.toggle-size-lg) {
gap: var(--spacing-3);
}
}
+56
View File
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import toggleStyles from "./Toggle.module.css"
import styles from "./ToggleGroup.module.css"
type VariantProps = {
variant?: "default" | "outline";
size?: "sm" | "md" | "lg" | "icon" | "icon-sm" | "icon-md" | "icon-lg";
}
const ToggleGroupContext = React.createContext<VariantProps>({
size: "md",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps
>(({ className, variant = "default", size = "md", children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={`${styles.toggleGroup} ${className ?? ""}`}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps
>(({ className, children, variant, size, ...props }, ref) => {
const { variant: groupVariant, size: groupSize } = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={`${toggleStyles.toggle} ${toggleStyles["toggle-variant-" + (variant ?? groupVariant)]} ${toggleStyles["toggle-size-" + (size ?? groupSize)]} ${className ?? ""}`}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
+81
View File
@@ -0,0 +1,81 @@
@layer base {
.content {
max-width: 20rem;
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--popup);
color: var(--popup-foreground);
border-radius: var(--radius);
font-size: 0.875rem;
line-height: 1.4;
box-shadow: var(--shadow-md);
font-family: var(--font-family-base);
border: 1px solid var(--border);
z-index: var(--z-overlay);
overflow-wrap: break-word;
word-break: keep-all;
animation-duration: var(--animation-duration-normal);
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity;
}
.content[data-state="delayed-open"][data-side="top"] {
animation-name: slideDownAndFade;
}
.content[data-state="delayed-open"][data-side="bottom"] {
animation-name: slideUpAndFade;
}
.content[data-state="delayed-open"][data-side="left"] {
animation-name: slideRightAndFade;
}
.content[data-state="delayed-open"][data-side="right"] {
animation-name: slideLeftAndFade;
}
@keyframes slideUpAndFade {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDownAndFade {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideLeftAndFade {
from {
opacity: 0;
transform: translateX(4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideRightAndFade {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import styles from "./Tooltip.module.css";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={`${styles.content} ${className || ""}`}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+32
View File
@@ -0,0 +1,32 @@
import { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeModeProvider } from "../helpers/themeMode";
import { TooltipProvider } from "./Tooltip";
import { SonnerToaster } from "./SonnerToaster";
import { ScrollToHashElement } from "./ScrollToHashElement";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
export const GlobalContextProviders = ({
children,
}: {
children: ReactNode;
}) => {
return (
<QueryClientProvider client={queryClient}>
<ThemeModeProvider>
<ScrollToHashElement />
<TooltipProvider>
{children}
<SonnerToaster />
</TooltipProvider>
</ThemeModeProvider>
</QueryClientProvider>
);
};
+68
View File
@@ -0,0 +1,68 @@
# Building the Windows installer
## 1. Install the tools (once)
Open **PowerShell** and run:
```powershell
winget install Git.Git OpenJS.NodeJS.LTS Python.Python.3.12
```
Close PowerShell, open a new one, then:
```powershell
npm install -g pnpm
```
## 2. Get the code
```powershell
cd $HOME
git clone <your repository URL> aurora-spotirip
cd aurora-spotirip
```
To update an existing copy later: `git pull`.
## 3. Create the castLabs signing account (once)
Spotify only plays inside apps signed by castLabs on Windows. The account is free.
```powershell
py -3 -m venv venv
venv\Scripts\python.exe -m pip install castlabs-evs
venv\Scripts\python.exe -m castlabs_evs.account signup
```
Follow the prompts and confirm the e-mail they send. On later builds the script signs you in again automatically (`castlabs_evs.account reauth`).
## 4. Build
```powershell
pnpm dist:win
```
The first build takes a while (it downloads Python packages, ffmpeg and castLabs Electron). The installer ends up in `release\Aurora SpotiRip Setup <version>.exe`.
## 5. Install and set up
1. Run the installer. Windows shows **"Windows protected your PC"** because the installer is not code-signed: click **More info → Run anyway**.
2. Create `%APPDATA%\Aurora SpotiRip\env.json` with the Spotify keys:
```json
{
"SPOTIFY_CLIENT_ID": "...",
"SPOTIFY_CLIENT_SECRET": "...",
"AUDD_API_TOKEN": ""
}
```
3. In the Spotify Developer Dashboard, add the Windows user's Spotify account under **User Management**. In-app playback needs Spotify Premium.
4. Start Aurora SpotiRip and click **Connect Spotify**.
The redirect URI `http://127.0.0.1:43117/spotify/callback` is the same on every computer, so nothing else changes in the dashboard.
## Troubleshooting
- **Songs play on another device instead of the PC:** the castLabs signing step did not run; rebuild after `venv\Scripts\python.exe -m castlabs_evs.account reauth`.
- **Antivirus removes `spotify_downloader.exe`:** some antivirus programs flag PyInstaller programs by mistake. Restore it and add an exception for the Aurora SpotiRip install folder.
+1
View File
@@ -0,0 +1 @@
Flat Web 2.0 music application: dark charcoal surfaces, strong content hierarchy, album-art focus, compact navigation, thin gray separators, small-radius controls, and restrained cyan/violet accents pulled from the SpotiRip logo. Avoid glassmorphism, decorative gradients, glowing borders, oversized cards, huge hero copy, and floating SaaS-style pills. Favor a classic desktop music-library feel inspired by mature web music players: left navigation, top search/import controls, album and playlist areas, dense track lists, and a persistent player. Use Outfit for headings/branding, Plus Jakarta Sans for UI copy, and JetBrains Mono only for paths/durations/status.
+32
View File
@@ -0,0 +1,32 @@
Aurora SpotiRip production status
Completed
- Spotify authorization-code OAuth with CSRF state validation and refresh-token support.
- Production callback route at /spotify/callback.
- Current-account connection/disconnection state.
- Current user's owned/collaborative playlist picker.
- Spotify playlist URL parsing and February-2026 /playlists/{id}/items API support.
- Real playlist metadata, artwork, tracks, pagination, filtering, and Spotify attribution links.
- Web Playback SDK device setup plus play/pause/previous/next/seek/volume and live now-playing metadata.
- AudD music recognition using short browser-generated fingerprint snippets instead of full-file uploads.
- Editable/manual metadata fallback for recognition misses.
- ID3 title, artist, album, year, track and artwork writing.
- Safe duplicate filename handling; originals are never overwritten.
- Persistent File System Access API output-directory handle in IndexedDB with download fallback.
- Local audio conversion to MP3 using Mediabunny + @mediabunny/mp3-encoder.
- Standalone /convert workflow with 128/192/256/320 kbps options.
- /settings defaults for filename templates, bitrate, artwork, and automatic match selection.
- Flat Web 2.0 visual redesign and dead-control cleanup.
- Typecheck clean.
- Server-side spotdl downloads: single tracks as MP3, playlists as a zip named after the playlist, sent to the visitor's browser and deleted from the server afterwards.
- Browser-tested WAV → MP3 conversion and ID3 metadata round-trip.
- AudD backend connectivity tested; unrecognized audio returns a clean no-match result.
Remaining before launch
- Final responsive/browser smoke test on all routes after the mobile redesign.
- Final server/browser log review.
- Real Spotify OAuth/playback verification after the self-hosted production server is live.
- Deploy the app on the self-hosted server (see docs/self-hosting.md).
- Configure the self-hosted runtime with SPOTIRIP_BASE_URL, SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET and AUDD_API_TOKEN.
- Keep Spotify redirect URI set to https://spotirip.auroradragon.dev/spotify/callback.
- Replace the open download access (SPOTIRIP_DOWNLOAD_USERS="*") with site logins once user accounts exist.
+72
View File
@@ -0,0 +1,72 @@
Aurora SpotiRip self-host deployment
Production URL
- https://spotirip.auroradragon.dev
Server requirements
- Node.js 22+ and pnpm (`npm install -g pnpm`).
- Python 3.10+ with the venv module (Debian/Ubuntu: `sudo apt install python3-venv`).
- ffmpeg (`sudo apt install ffmpeg`, or `venv/bin/spotdl --download-ffmpeg`).
- A long-running Node process (server.ts). Downloads run as background jobs held in memory,
so a serverless runtime will not work.
Setup (run inside the app folder)
1. python3 -m venv venv
2. venv/bin/pip install spotdl
3. pnpm install
4. Fill in env.json (see below).
5. pnpm vite build
6. pnpm tsx server.ts (listens on http://localhost:3333)
env.json
- SPOTIRIP_BASE_URL=https://spotirip.auroradragon.dev
- SPOTIFY_CLIENT_ID=<Spotify Developer Dashboard client id>
- SPOTIFY_CLIENT_SECRET=<Spotify Developer Dashboard client secret>
- AUDD_API_TOKEN=<AudD API token>
- SPOTIRIP_DOWNLOAD_USERS="*" lets every connected Spotify account download. Set a comma-separated
list of Spotify user ids to restrict it. The check lives in isDownloadAllowed()
(helpers/downloadJobs.tsx); swap it for site logins later.
- SPOTIRIP_PYTHON=venv/bin/python (path to the venv's Python, relative to the app folder)
- SPOTIRIP_DOWNLOADER=spotify_downloader.py (optional; path to the downloader script)
Spotify Developer Dashboard
- Website: https://spotirip.auroradragon.dev
- Redirect URI: https://spotirip.auroradragon.dev/spotify/callback
- In Development Mode Spotify only lets allow-listed accounts (max 25) sign in. Other visitors need
the app to be granted Extended Quota Mode.
How downloads work
- The download button POSTs the track link(s) to /_api/spotify-download. Playlists are sent as their
individual track links, so private playlists work.
- The server runs spotify_downloader.py (spotdl) into a temporary folder; the page polls
/_api/spotify-download-status for progress.
- When finished, the browser is sent to /_api/spotify-download-file, which returns the MP3 (single track)
or a zip named after the playlist as an attachment, then deletes the server copy.
- Audio comes from YouTube Music. Songs that fail there are retried with a regular YouTube search,
then Bandcamp (FALLBACK_PROVIDERS in spotify_downloader.py). Songs that still fail are skipped and
named in the server log ("could not download ...").
- yt-dlp needs a JavaScript runtime for YouTube; the server passes its own Node binary automatically.
- Unclaimed downloads are deleted after 30 minutes. Limits: 1 running download per user, 2 overall
(MAX_RUNNING_JOBS in helpers/downloadJobs.tsx).
Reverse proxy
- Proxy the public domain to port 3333 (Nginx, Apache, Caddy, ...).
- Preserve Set-Cookie headers. Spotify session cookies are Secure, HttpOnly and SameSite=Lax.
- Allow large responses (playlist zips can be hundreds of MB); with Nginx set
`proxy_max_temp_file_size 0;` or `proxy_buffering off;` for /_api/spotify-download-file.
- Serve the SPA with history fallback so /identify, /library, /spotify-library, /artist/:id, /album/:id
and /spotify/callback resolve (server.ts already does this).
Browser requirements for local-library features
- File System Access API features work best in Chromium-based browsers over HTTPS.
- When direct folder writing is unavailable, SpotiRip falls back to normal browser downloads.
Production verification after deployment
1. Open https://spotirip.auroradragon.dev.
2. Connect Spotify and approve the requested scopes.
3. Verify the callback returns to /spotify/callback and then to the home page.
4. Load a playlist; download one track (MP3) and the whole playlist (zip).
5. Test playlist import, My Spotify, Web Playback SDK playback, album/artist browsing and lyrics.
6. Test Identify with a real local music file.
7. Test Convert, Analyzer, Local Library scan and organizer.
8. Confirm mobile bottom navigation and compact player on a phone.
+103
View File
@@ -0,0 +1,103 @@
import { createServer } from "node:http";
import { existsSync, readFileSync, statSync } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
import { Hono } from "hono";
import { getRequestListener } from "@hono/node-server";
import * as identify from "../endpoints/identify_POST";
import * as lyrics from "../endpoints/lyrics_POST";
import * as metadataEnrich from "../endpoints/metadata-enrich_POST";
import * as spotifyAlbum from "../endpoints/spotify-album_GET";
import * as spotifyArtistAlbums from "../endpoints/spotify-artist-albums_GET";
import * as spotifyAuth from "../endpoints/spotify-auth_GET";
import * as spotifyDownload from "../endpoints/spotify-download_POST";
import * as spotifyDownloadFile from "../endpoints/spotify-download-file_GET";
import * as spotifyDownloadStatus from "../endpoints/spotify-download-status_GET";
import * as spotifyExchange from "../endpoints/spotify-exchange_POST";
import * as spotifyLibrary from "../endpoints/spotify-library_GET";
import * as spotifyLogout from "../endpoints/spotify-logout_POST";
import * as spotifyPlaylist from "../endpoints/spotify-playlist_POST";
import * as spotifyPlaylists from "../endpoints/spotify-playlists_GET";
import * as spotifySession from "../endpoints/spotify-session_GET";
const routes: Array<["get" | "post", string, { handle: (request: Request) => Promise<Response> }]> = [
["post", "identify", identify],
["get", "spotify-auth", spotifyAuth],
["post", "spotify-exchange", spotifyExchange],
["get", "spotify-session", spotifySession],
["post", "spotify-logout", spotifyLogout],
["post", "spotify-playlist", spotifyPlaylist],
["get", "spotify-playlists", spotifyPlaylists],
["post", "metadata-enrich", metadataEnrich],
["post", "lyrics", lyrics],
["get", "spotify-library", spotifyLibrary],
["get", "spotify-artist-albums", spotifyArtistAlbums],
["get", "spotify-album", spotifyAlbum],
["post", "spotify-download", spotifyDownload],
["get", "spotify-download-status", spotifyDownloadStatus],
["get", "spotify-download-file", spotifyDownloadFile],
];
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff2": "font/woff2",
".json": "application/json",
".wasm": "application/wasm",
};
function serveFile(path: string) {
return new Response(readFileSync(path), {
headers: {
"Content-Type": CONTENT_TYPES[extname(path)] || "application/octet-stream",
"Cache-Control": extname(path) === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
},
});
}
type ServerHooks = {
onSpotifyLogin?: (setCookies: string[]) => void;
};
export function startApiServer(port: number, distDir: string, hooks: ServerHooks = {}) {
const app = new Hono();
app.use("/_api/*", async (c, next) => {
await next();
console.log(`${new Date().toISOString()} ${c.req.method} ${c.req.path} -> ${c.res.status}`);
});
app.use("/spotify/*", async (c, next) => {
await next();
console.log(`${new Date().toISOString()} ${c.req.method} ${c.req.path}${new URL(c.req.url).search.replace(/code=[^&]+/, "code=…")} -> ${c.res.status}`);
});
for (const [method, name, endpoint] of routes) {
app[method](`/_api/${name}`, async (c) => {
try {
const response = await endpoint.handle(c.req.raw);
if (name === "spotify-exchange" && response.ok) hooks.onSpotifyLogin?.(response.headers.getSetCookie());
return response;
} catch (error) {
console.error(error);
return c.text("Endpoint error: " + (error instanceof Error ? error.message : String(error)), 500);
}
});
}
app.get("*", (c) => {
if (c.req.path.startsWith("/_api/")) return c.notFound();
const candidate = normalize(join(distDir, decodeURIComponent(c.req.path)));
if (candidate.startsWith(distDir + sep) && existsSync(candidate) && statSync(candidate).isFile()) {
return serveFile(candidate);
}
return serveFile(join(distDir, "index.html"));
});
return new Promise<void>((resolve, reject) => {
const server = createServer(getRequestListener(app.fetch));
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve());
});
}
+1
View File
@@ -0,0 +1 @@
export const PORT = 43117;
+33
View File
@@ -0,0 +1,33 @@
import { app } from "electron";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { PORT } from "./constants";
app.setName("Aurora SpotiRip");
export const ENV_FILE = join(app.getPath("userData"), "env.json");
for (const file of [ENV_FILE, join(app.getAppPath(), "env.json")]) {
if (!existsSync(file)) continue;
const values = JSON.parse(readFileSync(file, "utf8")) as Record<string, string>;
for (const [key, value] of Object.entries(values)) {
if (process.env[key] === undefined) process.env[key] = value;
}
}
process.env.SPOTIRIP_DESKTOP = "1";
process.env.SPOTIRIP_BASE_URL = `http://127.0.0.1:${PORT}`;
process.env.SPOTIRIP_DOWNLOAD_USERS = "*";
const exe = process.platform === "win32" ? ".exe" : "";
if (app.isPackaged) {
process.env.SPOTIRIP_DOWNLOADER_BIN = join(process.resourcesPath, "downloader", "spotify_downloader" + exe);
process.env.SPOTIRIP_FFMPEG = join(process.resourcesPath, "ffmpeg", "ffmpeg" + exe);
} else {
process.env.SPOTIRIP_PYTHON ??=
process.platform === "win32"
? join(app.getAppPath(), "venv", "Scripts", "python.exe")
: join(app.getAppPath(), "venv", "bin", "python");
process.env.SPOTIRIP_DOWNLOADER ??= join(app.getAppPath(), "spotify_downloader.py");
}
+142
View File
@@ -0,0 +1,142 @@
import "./env";
import { app, BrowserWindow, components, dialog, ipcMain, session, shell } from "electron";
import { join } from "node:path";
import { startApiServer } from "./apiServer";
import { PORT } from "./constants";
import { applySettings, loadSettings, saveSettings } from "./settings";
const APP_URL = `http://127.0.0.1:${PORT}`;
const LOGIN_HOSTS = ["spotify.com", "google.com", "facebook.com", "apple.com"];
let mainWindow: BrowserWindow | undefined;
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(start);
}
async function start() {
app.userAgentFallback = app.userAgentFallback.replace(/ (Electron|aurora-spotirip|Aurora SpotiRip)\/\S+/g, "");
applySettings();
registerIpc();
try {
await startApiServer(PORT, join(app.getAppPath(), "dist"), { onSpotifyLogin: adoptSpotifyLogin });
} catch (error) {
const inUse = (error as NodeJS.ErrnoException).code === "EADDRINUSE";
dialog.showErrorBox(
"Aurora SpotiRip could not start",
inUse
? `Port ${PORT} is already in use by another program. Close it and start Aurora SpotiRip again.`
: String(error),
);
app.quit();
return;
}
try {
await components.whenReady();
} catch (error) {
console.error("Widevine is unavailable; in-app playback will use Spotify Connect instead.", error);
}
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1360,
height: 880,
minWidth: 960,
minHeight: 600,
backgroundColor: "#0f1318",
title: "Aurora SpotiRip",
icon: join(app.getAppPath(), "build", "icon.png"),
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
mainWindow.webContents.on("will-navigate", (event, url) => {
const { origin, hostname, pathname } = new URL(url);
if (origin === APP_URL && pathname === "/_api/spotify-auth") {
event.preventDefault();
shell.openExternal(url);
return;
}
if (origin === APP_URL || LOGIN_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))) return;
event.preventDefault();
shell.openExternal(url);
});
mainWindow.webContents.on("console-message", (event) => {
if (event.level === "warning" || event.level === "error") {
console.log(`[page ${event.level}] ${event.message}`);
}
});
mainWindow.loadURL(APP_URL);
mainWindow.on("closed", () => {
mainWindow = undefined;
});
}
async function adoptSpotifyLogin(setCookies: string[]) {
for (const header of setCookies) {
const [pair, ...attributes] = header.split(";").map((part) => part.trim());
const index = pair.indexOf("=");
const name = pair.slice(0, index);
const value = decodeURIComponent(pair.slice(index + 1));
const maxAge = Number(attributes.find((attr) => /^max-age=/i.test(attr))?.split("=")[1]);
if (!name || name === "spotirip_oauth_state") continue;
await session.defaultSession.cookies.set({
url: APP_URL,
name,
value,
path: "/",
secure: true,
httpOnly: attributes.some((attr) => /^httponly$/i.test(attr)),
sameSite: "lax",
...(Number.isFinite(maxAge) ? { expirationDate: Date.now() / 1000 + maxAge } : {}),
});
}
if (!mainWindow) createWindow();
else {
mainWindow.loadURL(APP_URL);
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
}
function registerIpc() {
ipcMain.handle("settings:get-download-folder", () => loadSettings().downloadFolder);
ipcMain.handle("settings:choose-download-folder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Choose download folder",
defaultPath: loadSettings().downloadFolder,
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths[0]) return undefined;
return saveSettings({ downloadFolder: result.filePaths[0] }).downloadFolder;
});
ipcMain.handle("shell:show-in-folder", (_event, path: string) => shell.showItemInFolder(path));
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

Some files were not shown because too many files have changed in this diff Show More