🎉 创世提交
This commit is contained in:
141
src/App.css
Normal file
141
src/App.css
Normal file
@@ -0,0 +1,141 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--info: 204 94% 94%;
|
||||
--info-foreground: 199 89% 48%;
|
||||
|
||||
--success: 149 80% 90%;
|
||||
--success-foreground: 160 84% 39%;
|
||||
|
||||
--warning: 48 96% 89%;
|
||||
--warning-foreground: 25 95% 53%;
|
||||
|
||||
--error: 0 93% 94%;
|
||||
--error-foreground: 0 84% 60%;
|
||||
|
||||
--ring: 240 5.9% 10%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark,
|
||||
[data-kb-theme="dark"] {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--info: 204 94% 94%;
|
||||
--info-foreground: 199 89% 48%;
|
||||
|
||||
--success: 149 80% 90%;
|
||||
--success-foreground: 160 84% 39%;
|
||||
|
||||
--warning: 48 96% 89%;
|
||||
--warning-foreground: 25 95% 53%;
|
||||
|
||||
--error: 0 93% 94%;
|
||||
--error-foreground: 0 84% 60%;
|
||||
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.step {
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.step:before {
|
||||
@apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
|
||||
@apply ml-[-50px] mt-[-4px];
|
||||
content: counter(step);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
@apply px-4;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
border: 4px solid transparent;
|
||||
background-clip: content-box;
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
43
src/App.tsx
Normal file
43
src/App.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Component, ParentProps, createSignal } from "solid-js";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import "./App.css";
|
||||
import { SidebarProvider, useIsMobile, useSidebar } from "./components/ui/sidebar";
|
||||
import TitleBar from "./components/TitleBar";
|
||||
import { Toaster } from "./components/ui/toast";
|
||||
|
||||
const App: Component = (props: ParentProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [shouldRefresh, setShouldRefresh] = createSignal(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setShouldRefresh(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="app-layout">
|
||||
<SidebarProvider>
|
||||
{/* 根据刷新状态重新渲染 Sidebar */}
|
||||
{shouldRefresh() ? (
|
||||
<Sidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
<div class="flex flex-col w-full">
|
||||
<Toaster />
|
||||
<TitleBar onRefresh={handleRefresh} isSidebarOpen={!isMobile() && useSidebar().open()} />
|
||||
{shouldRefresh() ? (
|
||||
<main class="main-content w-full h-[calc(100vh-48px)] mt-12 overflow-y-auto" id="main-content">
|
||||
{props.children}
|
||||
</main>
|
||||
) : (
|
||||
<main class="main-content w-full h-[calc(100vh-48px)] mt-12 overflow-y-auto" id="main-content">
|
||||
{props.children}
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
41
src/components/AppCard/index.tsx
Normal file
41
src/components/AppCard/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
export interface AppCardProps {
|
||||
category: string;
|
||||
pkgname: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const AppCard: Component<AppCardProps> = (props) => {
|
||||
return (
|
||||
<A
|
||||
href={`/app/${props.category}/${props.pkgname}`}
|
||||
class="block p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-12 h-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||
{props.icon ? (
|
||||
<img src={props.icon} alt={props.name} class="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div class="text-2xl font-bold text-muted-foreground">
|
||||
{props.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-medium text-base truncate">{props.name}</h3>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{props.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</A>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppCard;
|
||||
52
src/components/AppList/index.tsx
Normal file
52
src/components/AppList/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Component, For } from 'solid-js';
|
||||
import { AppItem } from '@/types/app';
|
||||
import AppCard from '../AppCard';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface AppListProps {
|
||||
apps: AppItem[];
|
||||
loading: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const AppList: Component<AppListProps> = (props) => {
|
||||
return (
|
||||
<div class="grid auto-rows-auto grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 pb-6">
|
||||
{props.loading ? (
|
||||
<For each={Array(8).fill(0)}>
|
||||
{() => (
|
||||
<div class="p-4 rounded-lg border border-border/40 bg-card">
|
||||
<div class="flex items-start gap-3">
|
||||
<Skeleton width={48} height={48} radius={8} />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Skeleton height={20} width={96} />
|
||||
<Skeleton height={16} width={48} />
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<Skeleton height={16} width={100} class="mt-2" />
|
||||
<Skeleton height={16} width={100} class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
) : (
|
||||
<For each={props.apps}>
|
||||
{(app) => (
|
||||
<AppCard
|
||||
category={props.category || app.category || ''}
|
||||
pkgname={app.pkgname}
|
||||
name={app.name}
|
||||
description={app.more}
|
||||
icon={app.icon}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppList;
|
||||
53
src/components/HomeCarousel/index.tsx
Normal file
53
src/components/HomeCarousel/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import BaseCarousel from "@/components/ui/base-carousel";
|
||||
import { HomeLink } from '@/types/home';
|
||||
|
||||
interface HomeCarouselProps {
|
||||
slides?: HomeLink[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const HomeCarousel: Component<HomeCarouselProps> = (props) => {
|
||||
const renderItem = (slide: NonNullable<HomeCarouselProps['slides']>[number]) => (
|
||||
<a
|
||||
href={slide.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-center w-full h-[120px] relative bg-cover bg-center bg-no-repeat block"
|
||||
style={{"background-image": `url(${slide.imgUrl})`}}
|
||||
>
|
||||
{/* 添加一个半透明的遮罩层使文字更易读 */}
|
||||
<div class="absolute inset-0 bg-black/50" />
|
||||
<div class="relative z-10 p-4 flex flex-col justify-center h-full">
|
||||
<h3 class="text-xl font-semibold mb-2 break-words text-white">{slide.name}</h3>
|
||||
<p class="text-sm text-gray-200 break-words">{slide.more}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<div class="text-center w-full h-full relative">
|
||||
<div class="absolute inset-0">
|
||||
<Skeleton class="w-full h-full" />
|
||||
</div>
|
||||
<div class="relative z-10 p-4 flex flex-col justify-center h-full">
|
||||
<div class="mb-2">
|
||||
<Skeleton height={24} width={60} class="mx-auto" />
|
||||
</div>
|
||||
<Skeleton height={16} width={80} class="mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCarousel
|
||||
items={props.slides}
|
||||
loading={props.loading}
|
||||
renderItem={renderItem}
|
||||
renderSkeleton={renderSkeleton}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeCarousel;
|
||||
40
src/components/ScreenshotCarousel/index.tsx
Normal file
40
src/components/ScreenshotCarousel/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import BaseCarousel from "@/components/ui/base-carousel";
|
||||
|
||||
interface ScreenshotCarouselProps {
|
||||
screenshots?: {
|
||||
url: string;
|
||||
title: string;
|
||||
}[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const ScreenshotCarousel: Component<ScreenshotCarouselProps> = (props) => {
|
||||
const renderItem = (screenshot: NonNullable<ScreenshotCarouselProps['screenshots']>[0]) => (
|
||||
<div class="w-full h-full aspect-[16/9]">
|
||||
<img
|
||||
src={screenshot.url}
|
||||
alt={screenshot.title}
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSkeleton = () => (
|
||||
<div class="w-full h-full aspect-[16/9]">
|
||||
<Skeleton class="w-full h-full rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCarousel
|
||||
items={props.screenshots}
|
||||
loading={props.loading}
|
||||
renderItem={renderItem}
|
||||
renderSkeleton={renderSkeleton}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenshotCarousel;
|
||||
114
src/components/Sidebar/index.tsx
Normal file
114
src/components/Sidebar/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Component, For } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail
|
||||
} from '../ui/sidebar';
|
||||
import "./style.css"
|
||||
import { Compass, Heart, RefreshCw, Settings, Waves } from 'lucide-solid';
|
||||
import { useCategoriesStore } from '@/features/categories/store';
|
||||
import { getIconComponent } from '@/lib/icon';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: '探索发现',
|
||||
url: '/',
|
||||
icon: () => <Compass size={20} />
|
||||
},
|
||||
{
|
||||
title: '我的收藏',
|
||||
url: '/',
|
||||
icon: () => <Heart size={20} />
|
||||
},
|
||||
{
|
||||
title: '下载列表',
|
||||
url: '/',
|
||||
icon: () => <Waves size={20} />
|
||||
}
|
||||
];
|
||||
|
||||
const footerItems = [
|
||||
{
|
||||
title: '检查更新',
|
||||
url: '/update',
|
||||
icon: () => <RefreshCw size={20} />
|
||||
},
|
||||
{
|
||||
title: '应用设置',
|
||||
url: '/settings',
|
||||
icon: () => <Settings size={20} />
|
||||
}
|
||||
];
|
||||
|
||||
const AppSidebar: Component = () => {
|
||||
const { categories } = useCategoriesStore();
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Spark Store</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<For each={menuItems}>
|
||||
{(item) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as={A} href={item.url}>
|
||||
{item.icon ? item.icon() : null}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</For>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<SidebarGroupLabel>应用分类</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<For each={categories()}>
|
||||
{(category) => {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as={A} href={`/categories/${category.id}`}>
|
||||
{getIconComponent(category.icon)({
|
||||
size: 20,
|
||||
iconNode: []
|
||||
})}
|
||||
<span>{category.name_zh_cn}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
<SidebarGroupLabel>高级选项</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<For each={footerItems}>
|
||||
{(item) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton as={A} href={item.url}>
|
||||
{item.icon ? item.icon() : null}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</For>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
28
src/components/Sidebar/style.css
Normal file
28
src/components/Sidebar/style.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 85%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark,
|
||||
[data-kb-theme="dark"] {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
85
src/components/TitleBar/index.tsx
Normal file
85
src/components/TitleBar/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Component, Show } from "solid-js";
|
||||
import { Maximize2, Minus, X, ArrowLeft, RotateCw, Search } from "lucide-solid";
|
||||
import { useTitleBarStore } from "./store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SidebarTrigger } from "../ui/sidebar";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/text-field";
|
||||
|
||||
interface TitleBarProps {
|
||||
onRefresh?: () => void;
|
||||
isSidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
const TitleBar: Component<TitleBarProps> = (props) => {
|
||||
const { goBack, canGoBack, refresh } = useTitleBarStore();
|
||||
const navigate = useNavigate();
|
||||
let searchInput: HTMLInputElement | undefined;
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchInput?.value) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchInput.value)}`);
|
||||
searchInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`h-12 border-b flex items-center justify-between px-2 bg-background fixed top-0 right-0 z-50 ${
|
||||
props.isSidebarOpen ? "left-[var(--sidebar-width)]" : "left-0"
|
||||
}`}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SidebarTrigger class="h-8 px-2" />
|
||||
<Show when={canGoBack()}>
|
||||
<Button variant="ghost" size="icon" class="h-8 px-2" onClick={goBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 px-2"
|
||||
onClick={() => refresh(props.onRefresh)}
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
</Button>
|
||||
<TextField>
|
||||
<TextFieldInput
|
||||
ref={searchInput}
|
||||
placeholder="搜索应用..."
|
||||
class="h-8"
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
</TextField>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 px-2"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" class="h-8 px-2">
|
||||
<Minus size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" class="h-8 px-2">
|
||||
<Maximize2 size={16} />
|
||||
</Button>
|
||||
<Button variant="ghost" class="h-8 px-2">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
54
src/components/TitleBar/store.ts
Normal file
54
src/components/TitleBar/store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useNavigate, useLocation, useBeforeLeave } from '@solidjs/router';
|
||||
|
||||
export const useTitleBarStore = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
let lastScrollPosition = 0;
|
||||
|
||||
const canGoBack = () => {
|
||||
return location.pathname.startsWith('/app');
|
||||
};
|
||||
|
||||
const saveScrollPosition = () => {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
lastScrollPosition = mainContent.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreScrollPosition = () => {
|
||||
if (lastScrollPosition) {
|
||||
const mainContent = document.getElementById('main-content');
|
||||
if (mainContent) {
|
||||
mainContent.scrollTop = lastScrollPosition;
|
||||
lastScrollPosition = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
navigate(-1);
|
||||
// 增加延迟时间以确保页面完全加载
|
||||
setTimeout(restoreScrollPosition, 50);
|
||||
};
|
||||
|
||||
const refresh = (onRefresh?: () => void) => {
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用useBeforeLeave在路由离开前保存滚动位置
|
||||
useBeforeLeave((_e) => {
|
||||
if(!location.pathname.startsWith('/app'))
|
||||
{
|
||||
saveScrollPosition();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
canGoBack,
|
||||
goBack,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
95
src/components/ui/base-carousel.tsx
Normal file
95
src/components/ui/base-carousel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Component, JSX, Show, createSignal } from 'solid-js';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious
|
||||
} from '@/components/ui/carousel';
|
||||
import Autoplay from "embla-carousel-autoplay"
|
||||
|
||||
interface BaseCarouselProps {
|
||||
loading?: boolean;
|
||||
renderItem: (item: any) => JSX.Element;
|
||||
renderSkeleton: () => JSX.Element;
|
||||
items?: any[];
|
||||
}
|
||||
|
||||
const BaseCarousel: Component<BaseCarouselProps> = (props) => {
|
||||
return (
|
||||
<div class="relative w-full group">
|
||||
<Carousel
|
||||
opts={{
|
||||
align: 'start',
|
||||
loop: true
|
||||
}}
|
||||
plugins={[
|
||||
Autoplay({
|
||||
delay: 2000
|
||||
})
|
||||
]}
|
||||
class="w-full"
|
||||
>
|
||||
<CarouselContent class="-ml-1">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<>
|
||||
{Array(3).fill(0).map(() => (
|
||||
<CarouselItem class="pl-1 basis-full sm:basis-1/2 lg:basis-1/3">
|
||||
<Card>
|
||||
<CardContent class="flex items-center justify-center p-2 sm:p-4 lg:p-6">
|
||||
{props.renderSkeleton()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.items?.map((item) => {
|
||||
// 如果item包含url属性,说明是图片类型的轮播项
|
||||
if (item.url) {
|
||||
const [isLoaded, setIsLoaded] = createSignal(false);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.naturalWidth > 0) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
img.src = item.url;
|
||||
|
||||
return (
|
||||
<Show when={isLoaded() || (img.complete && img.naturalWidth > 0)}>
|
||||
<CarouselItem class="pl-1 basis-full sm:basis-1/2 lg:basis-1/3">
|
||||
<Card class='overflow-hidden'>
|
||||
<CardContent class="flex items-center justify-center p-0">
|
||||
{props.renderItem(item)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CarouselItem class="pl-1 basis-full sm:basis-1/2 lg:basis-1/3">
|
||||
<Card class='overflow-hidden'>
|
||||
<CardContent class="flex items-center justify-center p-0">
|
||||
{props.renderItem(item)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</Show>
|
||||
</CarouselContent>
|
||||
<CarouselPrevious class="left-3 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" />
|
||||
<CarouselNext class="right-3 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background" />
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseCarousel;
|
||||
53
src/components/ui/button.tsx
Normal file
53
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as ButtonPrimitive from "@kobalte/core/button"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 px-3 text-xs",
|
||||
lg: "h-11 px-8",
|
||||
icon: "size-10"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type ButtonProps<T extends ValidComponent = "button"> = ButtonPrimitive.ButtonRootProps<T> &
|
||||
VariantProps<typeof buttonVariants> & { class?: string | undefined; children?: JSX.Element }
|
||||
|
||||
const Button = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, ButtonProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"])
|
||||
return (
|
||||
<ButtonPrimitive.Root
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { ButtonProps }
|
||||
export { Button, buttonVariants }
|
||||
43
src/components/ui/card.tsx
Normal file
43
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Component, ComponentProps } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn("rounded-lg border bg-card text-card-foreground shadow-sm", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CardHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("flex flex-col space-y-1.5 p-6", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardTitle: Component<ComponentProps<"h3">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<h3 class={cn("text-lg font-semibold leading-none tracking-tight", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const CardDescription: Component<ComponentProps<"p">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <p class={cn("text-sm text-muted-foreground", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("p-6 pt-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const CardFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div class={cn("flex items-center p-6 pt-0", local.class)} {...others} />
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
263
src/components/ui/carousel.tsx
Normal file
263
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { Accessor, Component, ComponentProps, VoidProps } from "solid-js"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
mergeProps,
|
||||
splitProps,
|
||||
useContext
|
||||
} from "solid-js"
|
||||
|
||||
import type { CreateEmblaCarouselType } from "embla-carousel-solid"
|
||||
import createEmblaCarousel from "embla-carousel-solid"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ButtonProps } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export type CarouselApi = CreateEmblaCarouselType[1]
|
||||
|
||||
type UseCarouselParameters = Parameters<typeof createEmblaCarousel>
|
||||
type CarouselOptions = NonNullable<UseCarouselParameters[0]>
|
||||
type CarouselPlugin = NonNullable<UseCarouselParameters[1]>
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: ReturnType<CarouselOptions>
|
||||
plugins?: ReturnType<CarouselPlugin>
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof createEmblaCarousel>[0]
|
||||
api: ReturnType<typeof createEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: Accessor<boolean>
|
||||
canScrollNext: Accessor<boolean>
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = createContext<Accessor<CarouselContextProps> | null>(null)
|
||||
|
||||
const useCarousel = () => {
|
||||
const context = useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context()
|
||||
}
|
||||
|
||||
const Carousel: Component<CarouselProps & ComponentProps<"div">> = (rawProps) => {
|
||||
const props = mergeProps<(CarouselProps & ComponentProps<"div">)[]>(
|
||||
{ orientation: "horizontal" },
|
||||
rawProps
|
||||
)
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"orientation",
|
||||
"opts",
|
||||
"setApi",
|
||||
"plugins",
|
||||
"class",
|
||||
"children"
|
||||
])
|
||||
|
||||
const [carouselRef, api] = createEmblaCarousel(
|
||||
() => ({
|
||||
...local.opts,
|
||||
axis: local.orientation === "horizontal" ? "x" : "y"
|
||||
}),
|
||||
() => (local.plugins === undefined ? [] : local.plugins)
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = createSignal(false)
|
||||
const [canScrollNext, setCanScrollNext] = createSignal(false)
|
||||
|
||||
const onSelect = (api: NonNullable<ReturnType<CarouselApi>>) => {
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}
|
||||
|
||||
const scrollPrev = () => {
|
||||
api()?.scrollPrev()
|
||||
}
|
||||
|
||||
const scrollNext = () => {
|
||||
api()?.scrollNext()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!api() || !local.setApi) {
|
||||
return
|
||||
}
|
||||
local.setApi(api)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!api()) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api()!)
|
||||
api()!.on("reInit", onSelect)
|
||||
api()!.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api()?.off("select", onSelect)
|
||||
}
|
||||
})
|
||||
|
||||
const value = createMemo(
|
||||
() =>
|
||||
({
|
||||
carouselRef,
|
||||
api,
|
||||
opts: local.opts,
|
||||
orientation: local.orientation || (local.opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext
|
||||
}) satisfies CarouselContextProps
|
||||
)
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider value={value}>
|
||||
<div
|
||||
onKeyDown={handleKeyDown}
|
||||
class={cn("relative", local.class)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const CarouselContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} class="overflow-hidden">
|
||||
<div
|
||||
class={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CarouselItem: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
class={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type CarouselButtonProps = VoidProps<ButtonProps>
|
||||
|
||||
const CarouselPrevious: Component<CarouselButtonProps> = (rawProps) => {
|
||||
const props = mergeProps<CarouselButtonProps[]>({ variant: "outline", size: "icon" }, rawProps)
|
||||
const [local, others] = splitProps(props, ["class", "variant", "size"])
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={local.variant}
|
||||
size={local.size}
|
||||
class={cn(
|
||||
"absolute size-8 touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
local.class
|
||||
)}
|
||||
disabled={!canScrollPrev()}
|
||||
onClick={scrollPrev}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M5 12l14 0" />
|
||||
<path d="M5 12l6 6" />
|
||||
<path d="M5 12l6 -6" />
|
||||
</svg>
|
||||
<span class="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const CarouselNext: Component<CarouselButtonProps> = (rawProps) => {
|
||||
const props = mergeProps<CarouselButtonProps[]>({ variant: "outline", size: "icon" }, rawProps)
|
||||
const [local, others] = splitProps(props, ["class", "variant", "size"])
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={local.variant}
|
||||
size={local.size}
|
||||
class={cn(
|
||||
"absolute size-8 touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
local.class
|
||||
)}
|
||||
disabled={!canScrollNext()}
|
||||
onClick={scrollNext}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M5 12l14 0" />
|
||||
<path d="M13 18l6 -6" />
|
||||
<path d="M13 6l6 6" />
|
||||
</svg>
|
||||
<span class="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
|
||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@kobalte/core/collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.Trigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.Content
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
260
src/components/ui/dropdown-menu.tsx
Normal file
260
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as DropdownMenuPrimitive from "@kobalte/core/dropdown-menu"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenu: Component<DropdownMenuPrimitive.DropdownMenuRootProps> = (props) => {
|
||||
return <DropdownMenuPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuContentProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuContentProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuContentProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
class={cn(
|
||||
"z-50 min-w-32 origin-[var(--kb-menu-content-transform-origin)] animate-content-hide overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:animate-content-show",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuItemProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuItem = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuItemProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuItemProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuShortcut: Component<ComponentProps<"span">> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class"])
|
||||
return <span class={cn("ml-auto text-xs tracking-widest opacity-60", props.class)} {...rest} />
|
||||
}
|
||||
|
||||
const DropdownMenuLabel: Component<ComponentProps<"div"> & { inset?: boolean }> = (props) => {
|
||||
const [, rest] = splitProps(props, ["class", "inset"])
|
||||
return (
|
||||
<div
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", props.inset && "pl-8", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps<T extends ValidComponent = "hr"> =
|
||||
DropdownMenuPrimitive.DropdownMenuSeparatorProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuSeparator = <T extends ValidComponent = "hr">(
|
||||
props: PolymorphicProps<T, DropdownMenuSeparatorProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuSeparatorProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
class={cn("-mx-1 my-1 h-px bg-muted", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuSubTriggerProps<T> & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuSubTriggerProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuSubTriggerProps, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
class={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{props.children}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="ml-auto size-4"
|
||||
>
|
||||
<path d="M9 6l6 6l-6 6" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuSubContentProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuSubContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuSubContentProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuSubContentProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
class={cn(
|
||||
"z-50 min-w-32 origin-[var(--kb-menu-content-transform-origin)] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuCheckboxItemProps<T> & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DropdownMenuCheckboxItem = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuCheckboxItemProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuCheckboxItemProps, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M5 12l5 5l10 -10" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuGroupLabelProps<T extends ValidComponent = "span"> =
|
||||
DropdownMenuPrimitive.DropdownMenuGroupLabelProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const DropdownMenuGroupLabel = <T extends ValidComponent = "span">(
|
||||
props: PolymorphicProps<T, DropdownMenuGroupLabelProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuGroupLabelProps, ["class"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.GroupLabel
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", props.class)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps<T extends ValidComponent = "div"> =
|
||||
DropdownMenuPrimitive.DropdownMenuRadioItemProps<T> & {
|
||||
class?: string | undefined
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
const DropdownMenuRadioItem = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DropdownMenuRadioItemProps<T>>
|
||||
) => {
|
||||
const [, rest] = splitProps(props as DropdownMenuRadioItemProps, ["class", "children"])
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
class={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
props.class
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-2 fill-current"
|
||||
>
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
</svg>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{props.children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuGroupLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem
|
||||
}
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as SeparatorPrimitive from "@kobalte/core/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SeparatorRootProps<T extends ValidComponent = "hr"> =
|
||||
SeparatorPrimitive.SeparatorRootProps<T> & { class?: string | undefined }
|
||||
|
||||
const Separator = <T extends ValidComponent = "hr">(
|
||||
props: PolymorphicProps<T, SeparatorRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SeparatorRootProps, ["class", "orientation"])
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
orientation={local.orientation ?? "horizontal"}
|
||||
class={cn(
|
||||
"shrink-0 bg-border",
|
||||
local.orientation === "vertical" ? "h-full w-px" : "h-px w-full",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
172
src/components/ui/sheet.tsx
Normal file
172
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import * as SheetPrimitive from "@kobalte/core/dialog"
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
const SheetClose = SheetPrimitive.CloseButton
|
||||
|
||||
const portalVariants = cva("fixed inset-0 z-50 flex", {
|
||||
variants: {
|
||||
position: {
|
||||
top: "items-start",
|
||||
bottom: "items-end",
|
||||
left: "justify-start",
|
||||
right: "justify-end"
|
||||
}
|
||||
},
|
||||
defaultVariants: { position: "right" }
|
||||
})
|
||||
|
||||
type PortalProps = SheetPrimitive.DialogPortalProps & VariantProps<typeof portalVariants>
|
||||
|
||||
const SheetPortal: Component<PortalProps> = (props) => {
|
||||
const [local, others] = splitProps(props, ["position", "children"])
|
||||
return (
|
||||
<SheetPrimitive.Portal {...others}>
|
||||
<div class={portalVariants({ position: local.position })}>{local.children}</div>
|
||||
</SheetPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogOverlayProps<T extends ValidComponent = "div"> = SheetPrimitive.DialogOverlayProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const SheetOverlay = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DialogOverlayProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogOverlayProps, ["class"])
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
class={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[expanded=]:animate-in data-[closed=]:animate-out data-[closed=]:fade-out-0 data-[expanded=]:fade-in-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[closed=]:duration-300 data-[expanded=]:duration-500 data-[expanded=]:animate-in data-[closed=]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
top: "inset-x-0 top-0 border-b data-[closed=]:slide-out-to-top data-[expanded=]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[closed=]:slide-out-to-bottom data-[expanded=]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[closed=]:slide-out-to-left data-[expanded]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[closed=]:slide-out-to-right data-[expanded=]:slide-in-from-right sm:max-w-sm"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
position: "right"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type DialogContentProps<T extends ValidComponent = "div"> = SheetPrimitive.DialogContentProps<T> &
|
||||
VariantProps<typeof sheetVariants> & { class?: string | undefined; children?: JSX.Element }
|
||||
|
||||
const SheetContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, DialogContentProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogContentProps, ["position", "class", "children"])
|
||||
return (
|
||||
<SheetPortal position={local.position}>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
class={cn(
|
||||
sheetVariants({ position: local.position }),
|
||||
local.class,
|
||||
"max-h-screen overflow-y-auto"
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
<SheetPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.CloseButton>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const SheetHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div class={cn("flex flex-col space-y-2 text-center sm:text-left", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const SheetFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogTitleProps<T extends ValidComponent = "h2"> = SheetPrimitive.DialogTitleProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const SheetTitle = <T extends ValidComponent = "h2">(
|
||||
props: PolymorphicProps<T, DialogTitleProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogTitleProps, ["class"])
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
class={cn("text-lg font-semibold text-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogDescriptionProps<T extends ValidComponent = "p"> =
|
||||
SheetPrimitive.DialogDescriptionProps<T> & { class?: string | undefined }
|
||||
|
||||
const SheetDescription = <T extends ValidComponent = "p">(
|
||||
props: PolymorphicProps<T, DialogDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as DialogDescriptionProps, ["class"])
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
class={cn("text-sm text-muted-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
691
src/components/ui/sidebar.tsx
Normal file
691
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,691 @@
|
||||
import type { Accessor, Component, ComponentProps, JSX, ValidComponent } from "solid-js"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
Match,
|
||||
mergeProps,
|
||||
onCleanup,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
useContext
|
||||
} from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core"
|
||||
import { Polymorphic } from "@kobalte/core"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ButtonProps } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { TextField, TextFieldInput } from "@/components/ui/text-field"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "10rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "10rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContext = {
|
||||
state: Accessor<"expanded" | "collapsed">
|
||||
open: Accessor<boolean>
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: Accessor<boolean>
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: Accessor<boolean>
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContext | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a Sidebar.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useIsMobile(fallback = false) {
|
||||
const [isMobile, setIsMobile] = createSignal(fallback)
|
||||
|
||||
createEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
onCleanup(() => mql.removeEventListener("change", onChange))
|
||||
})
|
||||
|
||||
return isMobile
|
||||
}
|
||||
|
||||
type SidebarProviderProps = Omit<ComponentProps<"div">, "style"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
style?: JSX.CSSProperties
|
||||
}
|
||||
|
||||
const SidebarProvider: Component<SidebarProviderProps> = (rawProps) => {
|
||||
const props = mergeProps({ defaultOpen: true }, rawProps)
|
||||
const [local, others] = splitProps(props, [
|
||||
"defaultOpen",
|
||||
"open",
|
||||
"onOpenChange",
|
||||
"class",
|
||||
"style",
|
||||
"children"
|
||||
])
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = createSignal(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use open and onOpenChange for control from outside the component.
|
||||
const [_open, _setOpen] = createSignal(local.defaultOpen)
|
||||
const open = () => local.open ?? _open()
|
||||
const setOpen = (value: boolean | ((value: boolean) => boolean)) => {
|
||||
if (local.onOpenChange) {
|
||||
return local.onOpenChange?.(typeof value === "function" ? value(open()) : value)
|
||||
}
|
||||
_setOpen(value)
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open()}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
}
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = () => {
|
||||
return isMobile() ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
createEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => window.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = () => (open() ? "expanded" : "collapsed")
|
||||
|
||||
const contextValue = {
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
style={{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...local.style
|
||||
}}
|
||||
class={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full text-sidebar-foreground has-[[data-variant=inset]]:bg-sidebar",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarProps = ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
|
||||
const Sidebar: Component<SidebarProps> = (rawProps) => {
|
||||
const props = mergeProps<SidebarProps[]>(
|
||||
{
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas"
|
||||
},
|
||||
rawProps
|
||||
)
|
||||
const [local, others] = splitProps(props, ["side", "variant", "collapsible", "class", "children"])
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={local.collapsible === "none"}>
|
||||
<div
|
||||
class={cn(
|
||||
"test flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isMobile()}>
|
||||
<Sheet open={openMobile()} onOpenChange={setOpenMobile} {...others}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
|
||||
}}
|
||||
position={local.side}
|
||||
>
|
||||
<div class="flex size-full flex-col">{local.children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Match>
|
||||
<Match when={!isMobile()}>
|
||||
<div
|
||||
class="group peer hidden md:block"
|
||||
data-state={state()}
|
||||
data-collapsible={state() === "collapsed" ? local.collapsible : ""}
|
||||
data-variant={local.variant}
|
||||
data-side={local.side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
class={cn(
|
||||
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
local.variant === "floating" || local.variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
class={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
local.side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
local.variant === "floating" || local.variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{local.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarTriggerProps<T extends ValidComponent = "button"> = ButtonProps<T> & {
|
||||
onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
const SidebarTrigger = <T extends ValidComponent = "button">(props: SidebarTriggerProps<T>) => {
|
||||
const [local, others] = splitProps(props as SidebarTriggerProps, ["class", "onClick"])
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn("size-7", local.class)}
|
||||
onClick={(event: MouseEvent) => {
|
||||
local.onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarRail: Component<ComponentProps<"button">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
class={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarInset: Component<ComponentProps<"main">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<main
|
||||
class={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarInputProps<T extends ValidComponent = "input"> = ComponentProps<
|
||||
typeof TextFieldInput<T>
|
||||
>
|
||||
|
||||
const SidebarInput = <T extends ValidComponent = "input">(props: SidebarInputProps<T>) => {
|
||||
const [local, others] = splitProps(props as SidebarInputProps, ["class"])
|
||||
return (
|
||||
<TextField>
|
||||
<TextFieldInput
|
||||
data-sidebar="input"
|
||||
class={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div data-sidebar="header" class={cn("flex flex-col gap-2 p-2", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div data-sidebar="footer" class={cn("flex flex-col gap-2 p-2", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarSeparatorProps<T extends ValidComponent = "hr"> = ComponentProps<typeof Separator<T>>
|
||||
|
||||
const SidebarSeparator = <T extends ValidComponent = "hr">(props: SidebarSeparatorProps<T>) => {
|
||||
const [local, others] = splitProps(props as SidebarSeparatorProps, ["class"])
|
||||
return (
|
||||
<Separator
|
||||
data-sidebar="separator"
|
||||
class={cn("mx-2 w-auto bg-sidebar-border", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
data-sidebar="content"
|
||||
class={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarGroup: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
data-sidebar="group"
|
||||
class={cn("relative flex w-full min-w-0 flex-col p-2", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarGroupLabelProps<T extends ValidComponent = "div"> = ComponentProps<T>
|
||||
|
||||
const SidebarGroupLabel = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, SidebarGroupLabelProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SidebarGroupLabelProps, ["class"])
|
||||
|
||||
return (
|
||||
<Polymorphic<SidebarGroupLabelProps>
|
||||
as="div"
|
||||
data-sidebar="group-label"
|
||||
class={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarGroupActionProps<T extends ValidComponent = "button"> = ComponentProps<T>
|
||||
|
||||
const SidebarGroupAction = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, SidebarGroupActionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SidebarGroupActionProps, ["class"])
|
||||
return (
|
||||
<Polymorphic<SidebarGroupActionProps>
|
||||
as="button"
|
||||
data-sidebar="group-action"
|
||||
class={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarGroupContent: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return <div data-sidebar="group-content" class={cn("w-full text-sm", local.class)} {...others} />
|
||||
}
|
||||
|
||||
const SidebarMenu: Component<ComponentProps<"ul">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<ul
|
||||
data-sidebar="menu"
|
||||
class={cn("flex w-full min-w-0 flex-col gap-1", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuItem: Component<ComponentProps<"li">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<li data-sidebar="menu-item" class={cn("group/menu-item relative", local.class)} {...others} />
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]"
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type SidebarMenuButtonProps<T extends ValidComponent = "button"> = ComponentProps<T> &
|
||||
VariantProps<typeof sidebarMenuButtonVariants> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const SidebarMenuButton = <T extends ValidComponent = "button">(
|
||||
rawProps: PolymorphicProps<T, SidebarMenuButtonProps<T>>
|
||||
) => {
|
||||
const props = mergeProps({ isActive: false, variant: "default", size: "default" }, rawProps)
|
||||
const [local, others] = splitProps(props as SidebarMenuButtonProps, [
|
||||
"isActive",
|
||||
"tooltip",
|
||||
"variant",
|
||||
"size",
|
||||
"class"
|
||||
])
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Polymorphic<SidebarMenuButtonProps>
|
||||
as="button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={local.size}
|
||||
data-active={local.isActive}
|
||||
class={cn(
|
||||
sidebarMenuButtonVariants({ variant: local.variant, size: local.size }),
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={local.tooltip} fallback={button}>
|
||||
<Tooltip placement="right">
|
||||
<TooltipTrigger class="w-full">{button}</TooltipTrigger>
|
||||
<TooltipContent hidden={state() !== "collapsed" || isMobile()}>
|
||||
{local.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarMenuActionProps<T extends ValidComponent = "button"> = ComponentProps<T> & {
|
||||
showOnHover?: boolean
|
||||
}
|
||||
|
||||
const SidebarMenuAction = <T extends ValidComponent = "button">(
|
||||
rawProps: PolymorphicProps<T, SidebarMenuActionProps<T>>
|
||||
) => {
|
||||
const props = mergeProps({ showOnHover: false }, rawProps)
|
||||
const [local, others] = splitProps(props as SidebarMenuActionProps, ["class", "showOnHover"])
|
||||
|
||||
return (
|
||||
<Polymorphic<SidebarMenuActionProps>
|
||||
as="button"
|
||||
data-sidebar="menu-action"
|
||||
class={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuBadge: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-badge"
|
||||
class={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SidebarMenuSkeletonProps = ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const SidebarMenuSkeleton: Component<SidebarMenuSkeletonProps> = (rawProps) => {
|
||||
const props = mergeProps({ showIcon: false }, rawProps)
|
||||
const [local, others] = splitProps(props, ["class", "showIcon"])
|
||||
|
||||
// Random width between 50 to 90%.
|
||||
const width = createMemo(() => `${Math.floor(Math.random() * 40) + 50}%`)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-sidebar="menu-skeleton"
|
||||
class={cn("flex h-8 items-center gap-2 rounded-md px-2", local.class)}
|
||||
{...others}
|
||||
>
|
||||
{local.showIcon && <Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||
<Skeleton
|
||||
class="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={{
|
||||
"--skeleton-width": width()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuSub: Component<ComponentProps<"ul">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<ul
|
||||
data-sidebar="menu-sub"
|
||||
class={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SidebarMenuSubItem: Component<ComponentProps<"li">> = (props) => <li {...props} />
|
||||
|
||||
type SidebarMenuSubButtonProps<T extends ValidComponent = "a"> = ComponentProps<T> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const SidebarMenuSubButton = <T extends ValidComponent = "a">(
|
||||
rawProps: PolymorphicProps<T, SidebarMenuSubButtonProps<T>>
|
||||
) => {
|
||||
const props = mergeProps({ size: "md" }, rawProps)
|
||||
const [local, others] = splitProps(props as SidebarMenuSubButtonProps, [
|
||||
"size",
|
||||
"isActive",
|
||||
"class"
|
||||
])
|
||||
|
||||
return (
|
||||
<Polymorphic<SidebarMenuSubButtonProps>
|
||||
as="a"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={local.size}
|
||||
data-active={local.isActive}
|
||||
class={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
local.size === "sm" && "text-xs",
|
||||
local.size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar
|
||||
}
|
||||
24
src/components/ui/skeleton.tsx
Normal file
24
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as SkeletonPrimitive from "@kobalte/core/skeleton"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SkeletonRootProps<T extends ValidComponent = "div"> =
|
||||
SkeletonPrimitive.SkeletonRootProps<T> & { class?: string | undefined }
|
||||
|
||||
const Skeleton = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, SkeletonRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as SkeletonRootProps, ["class"])
|
||||
return (
|
||||
<SkeletonPrimitive.Root
|
||||
class={cn("bg-primary/10 data-[animate='true']:animate-pulse", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
152
src/components/ui/text-field.tsx
Normal file
152
src/components/ui/text-field.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { mergeProps, splitProps } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core"
|
||||
import * as TextFieldPrimitive from "@kobalte/core/text-field"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type TextFieldRootProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldRootProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextField = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldRootProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldRootProps, ["class"])
|
||||
return <TextFieldPrimitive.Root class={cn("flex flex-col gap-1", local.class)} {...others} />
|
||||
}
|
||||
|
||||
type TextFieldInputProps<T extends ValidComponent = "input"> =
|
||||
TextFieldPrimitive.TextFieldInputProps<T> & {
|
||||
class?: string | undefined
|
||||
type?:
|
||||
| "button"
|
||||
| "checkbox"
|
||||
| "color"
|
||||
| "date"
|
||||
| "datetime-local"
|
||||
| "email"
|
||||
| "file"
|
||||
| "hidden"
|
||||
| "image"
|
||||
| "month"
|
||||
| "number"
|
||||
| "password"
|
||||
| "radio"
|
||||
| "range"
|
||||
| "reset"
|
||||
| "search"
|
||||
| "submit"
|
||||
| "tel"
|
||||
| "text"
|
||||
| "time"
|
||||
| "url"
|
||||
| "week"
|
||||
}
|
||||
|
||||
const TextFieldInput = <T extends ValidComponent = "input">(
|
||||
rawProps: PolymorphicProps<T, TextFieldInputProps<T>>
|
||||
) => {
|
||||
const props = mergeProps<TextFieldInputProps<T>[]>({ type: "text" }, rawProps)
|
||||
const [local, others] = splitProps(props as TextFieldInputProps, ["type", "class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Input
|
||||
type={local.type}
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[invalid]:border-error-foreground data-[invalid]:text-error-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextFieldTextAreaProps<T extends ValidComponent = "textarea"> =
|
||||
TextFieldPrimitive.TextFieldTextAreaProps<T> & { class?: string | undefined }
|
||||
|
||||
const TextFieldTextArea = <T extends ValidComponent = "textarea">(
|
||||
props: PolymorphicProps<T, TextFieldTextAreaProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldTextAreaProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.TextArea
|
||||
class={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
label: "data-[invalid]:text-destructive",
|
||||
description: "font-normal text-muted-foreground",
|
||||
error: "text-xs text-destructive"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "label"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type TextFieldLabelProps<T extends ValidComponent = "label"> =
|
||||
TextFieldPrimitive.TextFieldLabelProps<T> & { class?: string | undefined }
|
||||
|
||||
const TextFieldLabel = <T extends ValidComponent = "label">(
|
||||
props: PolymorphicProps<T, TextFieldLabelProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldLabelProps, ["class"])
|
||||
return <TextFieldPrimitive.Label class={cn(labelVariants(), local.class)} {...others} />
|
||||
}
|
||||
|
||||
type TextFieldDescriptionProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldDescriptionProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextFieldDescription = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldDescriptionProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Description
|
||||
class={cn(labelVariants({ variant: "description" }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TextFieldErrorMessageProps<T extends ValidComponent = "div"> =
|
||||
TextFieldPrimitive.TextFieldErrorMessageProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const TextFieldErrorMessage = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TextFieldErrorMessageProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TextFieldErrorMessageProps, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.ErrorMessage
|
||||
class={cn(labelVariants({ variant: "error" }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
TextField,
|
||||
TextFieldInput,
|
||||
TextFieldTextArea,
|
||||
TextFieldLabel,
|
||||
TextFieldDescription,
|
||||
TextFieldErrorMessage
|
||||
}
|
||||
163
src/components/ui/toast.tsx
Normal file
163
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { JSX, ValidComponent } from "solid-js"
|
||||
import { Match, splitProps, Switch } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as ToastPrimitive from "@kobalte/core/toast"
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--kb-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--kb-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[opened]:animate-in data-[closed]:animate-out data-[swipe=end]:animate-out data-[closed]:fade-out-80 data-[closed]:slide-out-to-right-full data-[opened]:slide-in-from-top-full data-[opened]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "success border-success-foreground bg-success text-success-foreground",
|
||||
warning: "warning border-warning-foreground bg-warning text-warning-foreground",
|
||||
error: "error border-error-foreground bg-error text-error-foreground"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
type ToastVariant = NonNullable<VariantProps<typeof toastVariants>["variant"]>
|
||||
|
||||
type ToastListProps<T extends ValidComponent = "ol"> = ToastPrimitive.ToastListProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const Toaster = <T extends ValidComponent = "ol">(
|
||||
props: PolymorphicProps<T, ToastListProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastListProps, ["class"])
|
||||
return (
|
||||
<Portal>
|
||||
<ToastPrimitive.Region>
|
||||
<ToastPrimitive.List
|
||||
class={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</ToastPrimitive.Region>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastRootProps<T extends ValidComponent = "li"> = ToastPrimitive.ToastRootProps<T> &
|
||||
VariantProps<typeof toastVariants> & { class?: string | undefined }
|
||||
|
||||
const Toast = <T extends ValidComponent = "li">(props: PolymorphicProps<T, ToastRootProps<T>>) => {
|
||||
const [local, others] = splitProps(props as ToastRootProps, ["class", "variant"])
|
||||
return (
|
||||
<ToastPrimitive.Root
|
||||
class={cn(toastVariants({ variant: local.variant }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastCloseButtonProps<T extends ValidComponent = "button"> =
|
||||
ToastPrimitive.ToastCloseButtonProps<T> & { class?: string | undefined }
|
||||
|
||||
const ToastClose = <T extends ValidComponent = "button">(
|
||||
props: PolymorphicProps<T, ToastCloseButtonProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastCloseButtonProps, ["class"])
|
||||
return (
|
||||
<ToastPrimitive.CloseButton
|
||||
class={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-destructive-foreground group-[.error]:text-error-foreground group-[.success]:text-success-foreground group-[.warning]:text-warning-foreground",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
>
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</ToastPrimitive.CloseButton>
|
||||
)
|
||||
}
|
||||
|
||||
type ToastTitleProps<T extends ValidComponent = "div"> = ToastPrimitive.ToastTitleProps<T> & {
|
||||
class?: string | undefined
|
||||
}
|
||||
|
||||
const ToastTitle = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, ToastTitleProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastTitleProps, ["class"])
|
||||
return <ToastPrimitive.Title class={cn("text-sm font-semibold", local.class)} {...others} />
|
||||
}
|
||||
|
||||
type ToastDescriptionProps<T extends ValidComponent = "div"> =
|
||||
ToastPrimitive.ToastDescriptionProps<T> & { class?: string | undefined }
|
||||
|
||||
const ToastDescription = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, ToastDescriptionProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as ToastDescriptionProps, ["class"])
|
||||
return <ToastPrimitive.Description class={cn("text-sm opacity-90", local.class)} {...others} />
|
||||
}
|
||||
|
||||
function showToast(props: {
|
||||
title?: JSX.Element
|
||||
description?: JSX.Element
|
||||
variant?: ToastVariant
|
||||
duration?: number
|
||||
}) {
|
||||
ToastPrimitive.toaster.show((data) => (
|
||||
<Toast toastId={data.toastId} variant={props.variant} duration={props.duration}>
|
||||
<div class="grid gap-1">
|
||||
{props.title && <ToastTitle>{props.title}</ToastTitle>}
|
||||
{props.description && <ToastDescription>{props.description}</ToastDescription>}
|
||||
</div>
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))
|
||||
}
|
||||
|
||||
function showToastPromise<T, U>(
|
||||
promise: Promise<T> | (() => Promise<T>),
|
||||
options: {
|
||||
loading?: JSX.Element
|
||||
success?: (data: T) => JSX.Element
|
||||
error?: (error: U) => JSX.Element
|
||||
duration?: number
|
||||
}
|
||||
) {
|
||||
const variant: { [key in ToastPrimitive.ToastPromiseState]: ToastVariant } = {
|
||||
pending: "default",
|
||||
fulfilled: "success",
|
||||
rejected: "error"
|
||||
}
|
||||
return ToastPrimitive.toaster.promise<T, U>(promise, (props) => (
|
||||
<Toast toastId={props.toastId} variant={variant[props.state]} duration={options.duration}>
|
||||
<Switch>
|
||||
<Match when={props.state === "pending"}>{options.loading}</Match>
|
||||
<Match when={props.state === "fulfilled"}>{options.success?.(props.data!)}</Match>
|
||||
<Match when={props.state === "rejected"}>{options.error?.(props.error!)}</Match>
|
||||
</Switch>
|
||||
</Toast>
|
||||
))
|
||||
}
|
||||
|
||||
export { Toaster, Toast, ToastClose, ToastTitle, ToastDescription, showToast, showToastPromise }
|
||||
35
src/components/ui/tooltip.tsx
Normal file
35
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { splitProps, type Component } from "solid-js"
|
||||
|
||||
import type { PolymorphicProps } from "@kobalte/core/polymorphic"
|
||||
import * as TooltipPrimitive from "@kobalte/core/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const Tooltip: Component<TooltipPrimitive.TooltipRootProps> = (props) => {
|
||||
return <TooltipPrimitive.Root gutter={4} {...props} />
|
||||
}
|
||||
|
||||
type TooltipContentProps<T extends ValidComponent = "div"> =
|
||||
TooltipPrimitive.TooltipContentProps<T> & { class?: string | undefined }
|
||||
|
||||
const TooltipContent = <T extends ValidComponent = "div">(
|
||||
props: PolymorphicProps<T, TooltipContentProps<T>>
|
||||
) => {
|
||||
const [local, others] = splitProps(props as TooltipContentProps, ["class"])
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
class={cn(
|
||||
"z-50 origin-[var(--kb-popover-content-transform-origin)] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent }
|
||||
108
src/features/app-detail/AppDetail.css
Normal file
108
src/features/app-detail/AppDetail.css
Normal file
@@ -0,0 +1,108 @@
|
||||
.app-detail {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-detail .bg-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-detail .bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
filter: blur(100px) brightness(1.2);
|
||||
}
|
||||
|
||||
.app-detail .info-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
position: relative;
|
||||
padding: 40px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.app-detail .bg-container:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsla(var(--background) / 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.app-detail .app-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-detail .app-name {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.app-detail .app-title-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-detail .divider {
|
||||
height: 18px;
|
||||
background-color: hsl(var(--border));
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.app-detail .social-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-detail .install-btn {
|
||||
width: 200px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.app-detail .description {
|
||||
display: block;
|
||||
max-height: 4.5em;
|
||||
line-height: 1.5em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: max-height 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.app-detail .trunk::before {
|
||||
content: '';
|
||||
float: right;
|
||||
width: 0px;
|
||||
height: 100%;
|
||||
margin-bottom: -20px;
|
||||
}
|
||||
|
||||
.app-detail .read-more-btn {
|
||||
float: right;
|
||||
clear: both;
|
||||
font-size: 14px;
|
||||
height: 20px;
|
||||
width: 40px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
152
src/features/app-detail/AppDetail.tsx
Normal file
152
src/features/app-detail/AppDetail.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useAppDetailStore } from './store';
|
||||
import './AppDetail.css';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import ScreenshotCarousel from '@/components/ScreenshotCarousel';
|
||||
import { generateShareLinks, copy } from '@/lib/share';
|
||||
import { showToast } from "@/components/ui/toast"
|
||||
|
||||
const AppDetail: Component = () => {
|
||||
const params = useParams();
|
||||
const { app, loading } = useAppDetailStore(params.category, params.pkgname);
|
||||
|
||||
return (
|
||||
<div class="w-full h-full">
|
||||
{loading() ? (
|
||||
<div class="app-detail">
|
||||
<div class="info-container">
|
||||
<div class="app-title-container">
|
||||
<div class="flex-row-start gap-20px">
|
||||
<div class="app-icon bg-muted flex items-center justify-center overflow-hidden">
|
||||
<Skeleton width={64} height={64} radius={8} />
|
||||
</div>
|
||||
<div class="py-2" />
|
||||
<Skeleton height={32} width={200} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Skeleton height={40} width={96} radius={6} />
|
||||
<Skeleton height={40} width={96} radius={6} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div class="py-2">
|
||||
<Skeleton height={24} width={80} class="mb-2" />
|
||||
<Skeleton height={16} width={100} class="mt-2" />
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<Skeleton height={24} width={80} class="mb-2" />
|
||||
<div class="social-container text-sm text-muted-foreground">
|
||||
<Skeleton height={16} width={120} />
|
||||
<div class="divider" />
|
||||
<Skeleton height={16} width={120} />
|
||||
<div class="divider" />
|
||||
<Skeleton height={16} width={120} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="app-detail">
|
||||
<div class="bg-container">
|
||||
<div
|
||||
class="bg"
|
||||
style={{
|
||||
'background-image': `url(${app()?.Icon})`,
|
||||
'background-size': 'cover',
|
||||
'background-position': 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="info-container">
|
||||
<div class="app-title-container">
|
||||
<div class="flex-row-start gap-20px">
|
||||
<div class="app-icon bg-muted flex items-center justify-center overflow-hidden">
|
||||
{app()?.Icon ? (
|
||||
<img src={app()?.Icon} alt={app()?.Name} class="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div class="text-2xl font-bold text-muted-foreground">
|
||||
{app()?.Name?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="py-2" />
|
||||
<h2 class="app-name text-2xl font-bold pt-2">{app()?.Name}</h2>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button size="lg">安装</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="outline" size="lg">分享</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>分享应用</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
try {
|
||||
const links = await generateShareLinks(params.category, params.pkgname);
|
||||
await copy(links.spkLink);
|
||||
showToast({ description: 'SPK链接已复制到剪贴板', variant: "success" });
|
||||
} catch (error) {
|
||||
showToast({ description: '复制SPK链接失败', variant: "error" });
|
||||
}
|
||||
}}>复制SPK链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
try {
|
||||
const links = await generateShareLinks(params.category, params.pkgname);
|
||||
await copy(links.shareLink);
|
||||
showToast({ description: '分享链接已复制到剪贴板', variant: "success" });
|
||||
} catch (error) {
|
||||
showToast({ description: '复制分享链接失败', variant: "error" });
|
||||
}
|
||||
}}>复制分享链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={async () => {
|
||||
try {
|
||||
const links = await generateShareLinks(params.category, params.pkgname);
|
||||
await copy(links.shareIframe);
|
||||
showToast({ description: '嵌入代码已复制到剪贴板', variant: "success" });
|
||||
} catch (error) {
|
||||
showToast({ description: '复制嵌入代码失败', variant: "error" });
|
||||
}
|
||||
}}>复制嵌入代码</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 w-full">
|
||||
<div class="py-2">
|
||||
<h3 class="text-lg font-semibold">应用描述</h3>
|
||||
<p class="description text-muted-foreground">{app()?.More}</p>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<h3 class="text-lg font-semibold">应用信息</h3>
|
||||
<div class="social-container text-sm text-muted-foreground">
|
||||
<p>大小: {app()?.Size}</p>
|
||||
<div class="divider" />
|
||||
<p>分类: {app()?.Category}</p>
|
||||
<div class="divider" />
|
||||
<p>更新时间: {app()?.Update}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2 w-full">
|
||||
<h3 class="text-lg font-semibold">应用截图</h3>
|
||||
<ScreenshotCarousel
|
||||
screenshots={app()?.Screenshots?.map(url => ({
|
||||
url,
|
||||
title: app()?.Name || ''
|
||||
}))}
|
||||
loading={loading()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDetail;
|
||||
35
src/features/app-detail/store.ts
Normal file
35
src/features/app-detail/store.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getAppInfo } from '@/lib/api/app';
|
||||
import { getImgServerUrl } from '@/lib/api/server';
|
||||
import { AppDetail } from '@/types/app';
|
||||
import { createResource } from 'solid-js';
|
||||
|
||||
|
||||
const fetchAppDetail = async (category: string, pkgname: string): Promise<AppDetail> => {
|
||||
try {
|
||||
const appInfo = await getAppInfo(category, pkgname);
|
||||
|
||||
// 生成5张截图的链接
|
||||
const screenshots = Array.from({ length: 5 }, (_, index) =>
|
||||
`${getImgServerUrl()}/${appInfo.Category}/${appInfo.Pkgname}/screen_${index}.png`
|
||||
);
|
||||
|
||||
return {
|
||||
...appInfo,
|
||||
Screenshots: screenshots
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`获取应用详情失败: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAppDetailStore = (category: string, pkgname: string) => {
|
||||
const [app, { refetch }] = createResource<AppDetail>(() => fetchAppDetail(category, pkgname));
|
||||
const loading = () => app.loading;
|
||||
|
||||
return {
|
||||
app,
|
||||
loading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
38
src/features/categories/Categories.tsx
Normal file
38
src/features/categories/Categories.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { fetchCategoryApps, useCategoriesStore } from './store';
|
||||
import AppList from '@/components/AppList';
|
||||
|
||||
const Categories: Component = () => {
|
||||
const params = useParams();
|
||||
const [categoryApps, setCategoryApps] = createSignal<any[]>([]);
|
||||
const [loadingApps, setLoadingApps] = createSignal(true);
|
||||
const { categories } = useCategoriesStore();
|
||||
|
||||
createEffect(async () => {
|
||||
if (params.id) {
|
||||
setLoadingApps(true);
|
||||
try {
|
||||
const apps = await fetchCategoryApps(params.id);
|
||||
setCategoryApps(apps);
|
||||
} finally {
|
||||
setLoadingApps(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 w-full h-full">
|
||||
<h1 class="text-2xl font-bold mb-6">
|
||||
{categories()?.find((category) => category.id === params.id)?.name_zh_cn}
|
||||
</h1>
|
||||
<AppList
|
||||
apps={categoryApps()}
|
||||
loading={loadingApps()}
|
||||
category={params.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
23
src/features/categories/store.ts
Normal file
23
src/features/categories/store.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createResource } from 'solid-js';
|
||||
import { getAllCategories, getCategoryApps } from '@/lib/api/category';
|
||||
import type { Category } from '@/types/category';
|
||||
import { AppItem } from '@/types/app';
|
||||
|
||||
const fetchCategories = async (): Promise<Category[]> => {
|
||||
return await getAllCategories();
|
||||
};
|
||||
|
||||
export const fetchCategoryApps = async (categoryId: string): Promise<AppItem[]> => {
|
||||
return await getCategoryApps(categoryId);
|
||||
};
|
||||
|
||||
export const useCategoriesStore = () => {
|
||||
const [categories, { refetch }] = createResource<Category[]>(fetchCategories);
|
||||
const loading = () => categories.loading;
|
||||
|
||||
return {
|
||||
categories,
|
||||
loading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
18
src/features/home/Home.tsx
Normal file
18
src/features/home/Home.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Component } from 'solid-js';
|
||||
import { useHomeStore } from './store';
|
||||
import AppList from '@/components/AppList';
|
||||
import HomeCarousel from '@/components/HomeCarousel';
|
||||
|
||||
const Home: Component = () => {
|
||||
const { apps, loading, slides } = useHomeStore();
|
||||
|
||||
return (
|
||||
<div class="p-6 w-full h-full">
|
||||
<HomeCarousel slides={slides() ?? []} loading={loading()} />
|
||||
<h1 class="text-2xl font-bold mb-6">欢迎使用 Spark Store</h1>
|
||||
<AppList apps={apps() ?? []} loading={loading()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
72
src/features/home/store.ts
Normal file
72
src/features/home/store.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { AppItem } from '@/types/app';
|
||||
import { HomeLink } from '@/types/home';
|
||||
import { createResource } from 'solid-js';
|
||||
import { getHomeLinks } from '@/lib/api/home';
|
||||
|
||||
const fetchApps = async (): Promise<AppItem[]> => {
|
||||
// 模拟从后端获取数据的延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return [
|
||||
{
|
||||
pkgname: '1',
|
||||
name: 'Visual Studio Code',
|
||||
more: '轻量级但功能强大的代码编辑器',
|
||||
category: 'development',
|
||||
icon: '/icons/vscode.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '2',
|
||||
name: 'Firefox',
|
||||
more: '注重隐私的开源浏览器',
|
||||
category: 'development',
|
||||
icon: '/icons/firefox.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '3',
|
||||
name: 'GIMP',
|
||||
more: '功能丰富的图像编辑软件',
|
||||
category: 'development',
|
||||
icon: '/icons/gimp.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '4',
|
||||
name: 'Notion',
|
||||
more: '功能强大的笔记软件',
|
||||
category: 'development',
|
||||
icon: '/icons/notion.png',
|
||||
update: ''
|
||||
},
|
||||
{
|
||||
pkgname: '5',
|
||||
name: 'Slack',
|
||||
more: '团队沟通和协作工具',
|
||||
category: 'development',
|
||||
icon: '/icons/slack.png',
|
||||
update: ''
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getCarouselSlides = async (): Promise<HomeLink[]> => {
|
||||
return await getHomeLinks();
|
||||
};
|
||||
|
||||
export const useHomeStore = () => {
|
||||
const [apps, { refetch: refetchApps }] = createResource<AppItem[]>(fetchApps);
|
||||
const [slides, { refetch: refetchSlides }] = createResource<HomeLink[]>(getCarouselSlides);
|
||||
const loading = () => apps.loading || slides.loading;
|
||||
|
||||
return {
|
||||
apps,
|
||||
slides,
|
||||
loading,
|
||||
refetch: () => {
|
||||
refetchApps();
|
||||
refetchSlides();
|
||||
}
|
||||
};
|
||||
};
|
||||
46
src/features/search/Search.tsx
Normal file
46
src/features/search/Search.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js';
|
||||
import { useSearchParams } from '@solidjs/router';
|
||||
import { searchAllApps } from '@/lib/api/app';
|
||||
import { AppItem } from '@/types/app';
|
||||
import AppList from '@/components/AppList';
|
||||
|
||||
const Search: Component = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchResults, setSearchResults] = createSignal<AppItem[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
|
||||
createEffect(async () => {
|
||||
const query = searchParams.q;
|
||||
if (query && typeof query === 'string') {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const results = await searchAllApps(query);
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '搜索失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 w-full h-full">
|
||||
<h1 class="text-2xl font-bold mb-6">
|
||||
搜索结果: {searchParams.q}
|
||||
</h1>
|
||||
{error() ? (
|
||||
<div class="text-red-500">{error()}</div>
|
||||
) : (
|
||||
<AppList
|
||||
apps={searchResults()}
|
||||
loading={loading()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
43
src/index.tsx
Normal file
43
src/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { lazy } from "solid-js";
|
||||
import App from "./App";
|
||||
import { initServerConfig } from "./lib/api/server";
|
||||
|
||||
const routes = {
|
||||
path: "/",
|
||||
component: App,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
component: lazy(() => import("./features/home/Home")),
|
||||
},
|
||||
{
|
||||
path: "/categories/:id",
|
||||
component: lazy(() => import("./features/categories/Categories")),
|
||||
},
|
||||
{
|
||||
path: "/app/:category/:pkgname",
|
||||
component: lazy(() => import("./features/app-detail/AppDetail")),
|
||||
},
|
||||
{
|
||||
path: "/search",
|
||||
component: lazy(() => import("./features/search/Search")),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 初始化应用
|
||||
const init = async () => {
|
||||
// 等待服务器配置初始化完成
|
||||
await initServerConfig();
|
||||
|
||||
// 渲染应用
|
||||
render(
|
||||
() => <Router>{routes}</Router>,
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
};
|
||||
|
||||
init().catch(console.error);
|
||||
60
src/lib/api/app.ts
Normal file
60
src/lib/api/app.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AppDetail, AppItem } from "@/types/app";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { retryOperation } from "../utils";
|
||||
|
||||
/**
|
||||
* 获取应用详细信息
|
||||
* @param category 应用分类
|
||||
* @param pkgname 应用包名
|
||||
* @returns Promise<AppInfoItem>
|
||||
*/
|
||||
export async function getAppInfo(category: string, pkgname: string): Promise<AppDetail> {
|
||||
try {
|
||||
return await retryOperation(async () => {
|
||||
const appInfo = await invoke<AppDetail>("get_app_info", {
|
||||
category,
|
||||
pkgname,
|
||||
});
|
||||
return appInfo;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`获取应用信息失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索所有应用
|
||||
* @param query 搜索关键词
|
||||
* @returns Promise<AppItem[]>
|
||||
*/
|
||||
export async function searchAllApps(query: string): Promise<AppItem[]> {
|
||||
try {
|
||||
// 从后端获取数据并转换字段名称
|
||||
const rawApps = await retryOperation(async () => {
|
||||
return await invoke<Array<{
|
||||
More: string,
|
||||
Name: string,
|
||||
Pkgname: string,
|
||||
Tags?: string,
|
||||
Update: string,
|
||||
icon?: string,
|
||||
category?: string
|
||||
}>>("search_all_apps", { query });
|
||||
});
|
||||
|
||||
// 将后端返回的大写字段名转换为前端使用的小写格式
|
||||
return rawApps.map(app => ({
|
||||
more: app.More,
|
||||
name: app.Name,
|
||||
pkgname: app.Pkgname,
|
||||
tags: app.Tags,
|
||||
update: app.Update,
|
||||
icon: app.icon,
|
||||
category: app.category
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("搜索应用失败:", error);
|
||||
throw new Error(`搜索应用失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
46
src/lib/api/category.ts
Normal file
46
src/lib/api/category.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Category } from "@/types/category";
|
||||
import { AppItem } from "@/types/app";
|
||||
import { retryOperation } from "../utils";
|
||||
|
||||
export const getAllCategories = async (): Promise<Category[]> => {
|
||||
try {
|
||||
return await retryOperation(async () => {
|
||||
return await invoke<Category[]>("get_all_categories");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取分类列表失败:", error);
|
||||
throw new Error("获取分类列表失败");
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryApps = async (categoryId: string): Promise<AppItem[]> => {
|
||||
// 从后端获取数据并转换字段名称
|
||||
try {
|
||||
const rawApps = await retryOperation(async () => {
|
||||
return await invoke<Array<{
|
||||
More: string,
|
||||
Name: string,
|
||||
Pkgname: string,
|
||||
Tags?: string,
|
||||
Update: string,
|
||||
icon?: string,
|
||||
category?: string
|
||||
}>>("get_category_apps", { categoryId });
|
||||
});
|
||||
|
||||
// 将后端返回的大写字段名转换为前端使用的小写格式
|
||||
return rawApps.map(app => ({
|
||||
more: app.More,
|
||||
name: app.Name,
|
||||
pkgname: app.Pkgname,
|
||||
tags: app.Tags,
|
||||
update: app.Update,
|
||||
icon: app.icon,
|
||||
category: app.category
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("获取分类应用列表失败:", error);
|
||||
throw new Error("获取分类应用列表失败");
|
||||
}
|
||||
};
|
||||
21
src/lib/api/home.ts
Normal file
21
src/lib/api/home.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { retryOperation } from "../utils";
|
||||
import { HomeLink, HomeLinkResponse } from "@/types/home";
|
||||
|
||||
export const getHomeLinks = async (): Promise<HomeLink[]> => {
|
||||
try {
|
||||
const links = await retryOperation(async () => {
|
||||
const result = await invoke<HomeLinkResponse[]>("get_home_links");
|
||||
// 将返回数据中的 url 字段转换为 linkUrl
|
||||
return result.map(link => ({
|
||||
...link,
|
||||
linkUrl: link.url,
|
||||
url: undefined
|
||||
}));
|
||||
});
|
||||
return links;
|
||||
} catch (error) {
|
||||
console.error("获取主页链接列表失败:", error);
|
||||
throw new Error("获取主页链接列表失败");
|
||||
}
|
||||
};
|
||||
50
src/lib/api/server.ts
Normal file
50
src/lib/api/server.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// 存储服务器相关的全局变量
|
||||
let targetArchToStore: string;
|
||||
let jsonServerUrl: string;
|
||||
let imgServerUrl: string;
|
||||
|
||||
// 初始化函数,在应用启动时调用
|
||||
export async function initServerConfig() {
|
||||
targetArchToStore = await invoke('get_target_arch_to_store');
|
||||
jsonServerUrl = await invoke('get_json_server_url');
|
||||
imgServerUrl = await invoke('get_img_server_url');
|
||||
}
|
||||
|
||||
export function getTargetArchToStore(): string {
|
||||
if (!targetArchToStore) {
|
||||
throw new Error('服务器配置未初始化,请先调用 initServerConfig');
|
||||
}
|
||||
return targetArchToStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取JSON服务器URL
|
||||
* @returns 返回JSON服务器的完整URL
|
||||
*/
|
||||
export function getJsonServerUrl(): string {
|
||||
if (!jsonServerUrl) {
|
||||
throw new Error('服务器配置未初始化,请先调用 initServerConfig');
|
||||
}
|
||||
return jsonServerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片服务器URL
|
||||
* @returns 返回图片服务器的完整URL
|
||||
*/
|
||||
export function getImgServerUrl(): string {
|
||||
if (!imgServerUrl) {
|
||||
throw new Error('服务器配置未初始化,请先调用 initServerConfig');
|
||||
}
|
||||
return imgServerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 User-Agent
|
||||
* @returns 返回应用的 User-Agent 字符串
|
||||
*/
|
||||
export async function getUserAgent(): Promise<string> {
|
||||
return await invoke('get_user_agent');
|
||||
}
|
||||
18
src/lib/icon.ts
Normal file
18
src/lib/icon.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Package, Code, Globe, Palette, MessageCircle, Gamepad2, Image, Music, FileText, Grid, type Icon } from 'lucide-solid';
|
||||
|
||||
const iconMap: Record<string, typeof Icon> = {
|
||||
'code': Code,
|
||||
'globe': Globe,
|
||||
'palette': Palette,
|
||||
'message-circle': MessageCircle,
|
||||
'gamepad-2': Gamepad2,
|
||||
'image': Image,
|
||||
'music': Music,
|
||||
'file-text': FileText,
|
||||
'grid': Grid
|
||||
};
|
||||
|
||||
export const getIconComponent = (iconName?: string): typeof Icon => {
|
||||
if (!iconName) return Package;
|
||||
return iconMap[iconName] || Package;
|
||||
};
|
||||
24
src/lib/share.ts
Normal file
24
src/lib/share.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
|
||||
export const getTargetArchToStore = async (): Promise<string> => {
|
||||
return await invoke<string>("get_target_arch_to_store");
|
||||
};
|
||||
|
||||
export const copy = async (text: string): Promise<void> => {
|
||||
console.log(text);
|
||||
await writeText(text);
|
||||
};
|
||||
|
||||
export const generateShareLinks = async (category: string, pkgname: string) => {
|
||||
const targetArch = await getTargetArchToStore();
|
||||
const spkLink = `spk://${targetArch}/${category}/${pkgname}`;
|
||||
const shareLink = `https://spk-resolv.spark-app.store/?spk=spk://${targetArch}/${category}/${pkgname}`;
|
||||
const shareIframe = `<iframe src="https://spk-resolv.spark-app.store/?spk=${encodeURIComponent(`spk://${targetArch}/${category}/${pkgname}`)}" height="350" width="100%" border="0"></iframe>`;
|
||||
|
||||
return {
|
||||
spkLink,
|
||||
shareLink,
|
||||
shareIframe
|
||||
};
|
||||
};
|
||||
26
src/lib/utils.ts
Normal file
26
src/lib/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const retryOperation = async <T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> => {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === maxRetries) break;
|
||||
// 等待一小段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
35
src/types/app.ts
Normal file
35
src/types/app.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface AppItem {
|
||||
// 更多信息
|
||||
more: string;
|
||||
// 应用名称
|
||||
name: string;
|
||||
// 包名
|
||||
pkgname: string;
|
||||
// 标签
|
||||
tags?: string;
|
||||
// 更新信息
|
||||
update: string;
|
||||
// 图标
|
||||
icon?: string;
|
||||
// 分类
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface AppDetail {
|
||||
More: string;
|
||||
Name: string;
|
||||
Pkgname: string;
|
||||
Tags: string;
|
||||
Update: string;
|
||||
Icon: string;
|
||||
Category: string;
|
||||
Version: string;
|
||||
Filename: string;
|
||||
Torrent_address: string;
|
||||
Author: string;
|
||||
Contributor: string;
|
||||
Website: string;
|
||||
Size: string;
|
||||
DownloadTimes: number;
|
||||
Screenshots: string[];
|
||||
}
|
||||
5
src/types/category.ts
Normal file
5
src/types/category.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Category {
|
||||
id: string;
|
||||
icon: string;
|
||||
name_zh_cn: string;
|
||||
}
|
||||
15
src/types/home.ts
Normal file
15
src/types/home.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface HomeLink {
|
||||
name: string;
|
||||
more: string;
|
||||
imgUrl: string;
|
||||
type: string;
|
||||
linkUrl: string;
|
||||
}
|
||||
|
||||
export interface HomeLinkResponse {
|
||||
name: string;
|
||||
more: string;
|
||||
imgUrl: string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user