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:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
dist/
|
||||||
|
dist-electron/
|
||||||
|
release/
|
||||||
|
build/linux/
|
||||||
|
build/windows/
|
||||||
|
build/mac/
|
||||||
|
|
||||||
|
env.json
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 319 KiB |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const PORT = 43117;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user