🎉 创世提交

This commit is contained in:
柚子
2025-01-22 01:48:07 +08:00
commit 5d3481b9ea
92 changed files with 11705 additions and 0 deletions

141
src/App.css Normal file
View 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
View 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
View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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%;
}
}

View 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;

View 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
};
};

View 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;

View 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 }

View 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 }

View 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 }

View 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 }

View 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
}

View 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
View 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
}

View 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
}

View 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 }

View 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
View 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 }

View 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 }

View 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));
}

View 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;

View 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,
};
};

View 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;

View 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,
};
};

View 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;

View 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();
}
};
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
export interface Category {
id: string;
icon: string;
name_zh_cn: string;
}

15
src/types/home.ts Normal file
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />