feat(analysis): 增强图表交互功能
- 分类支出排行: 饼图支持点击类别切换显示/隐藏,百分比动态重新计算 - 每日支出趋势: 图例支持点击切换类别显示,隐藏类别不参与堆叠计算 - Dialog列表: 添加列排序功能(时间/商家/描述/金额) - Dialog列表: 添加分页功能,每页10条(分类)/8条(每日) - 饼图hover效果: 扇形放大、阴影增强、中心显示详情
This commit is contained in:
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
9
web/.prettierignore
Normal file
9
web/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
17
web/.prettierrc
Normal file
17
web/.prettierrc
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
web/README.md
Normal file
38
web/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
16
web/components.json
Normal file
16
web/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
41
web/eslint.config.js
Normal file
41
web/eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
"no-undef": 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
53
web/package.json
Normal file
53
web/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"@types/node": "^18",
|
||||
"@vitest/browser-playwright": "^4.0.15",
|
||||
"bits-ui": "^2.14.4",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"globals": "^16.5.0",
|
||||
"layerchart": "2.0.0-next.43",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.15",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
}
|
||||
}
|
||||
128
web/src/app.css
Normal file
128
web/src/app.css
Normal file
@@ -0,0 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* 移除图表元素的 focus outline */
|
||||
svg *:focus,
|
||||
svg *:focus-visible,
|
||||
[data-layerchart] *:focus,
|
||||
[data-layerchart] *:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
13
web/src/app.d.ts
vendored
Normal file
13
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
web/src/app.html
Normal file
11
web/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
7
web/src/demo.spec.ts
Normal file
7
web/src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
164
web/src/lib/api.ts
Normal file
164
web/src/lib/api.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// API 配置
|
||||
const API_BASE = 'http://localhost:8080';
|
||||
|
||||
// 类型定义
|
||||
export interface UploadData {
|
||||
bill_type: 'alipay' | 'wechat';
|
||||
file_url: string;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
result: boolean;
|
||||
message: string;
|
||||
data?: UploadData;
|
||||
}
|
||||
|
||||
export interface ReviewRecord {
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description: string;
|
||||
income_expense: string;
|
||||
amount: string;
|
||||
remark: string;
|
||||
review_level: 'HIGH' | 'LOW';
|
||||
}
|
||||
|
||||
export interface ReviewData {
|
||||
total: number;
|
||||
high: number;
|
||||
low: number;
|
||||
records: ReviewRecord[];
|
||||
}
|
||||
|
||||
export interface ReviewResponse {
|
||||
result: boolean;
|
||||
message: string;
|
||||
data?: ReviewData;
|
||||
}
|
||||
|
||||
export interface BillRecord {
|
||||
time: string;
|
||||
category: string;
|
||||
merchant: string;
|
||||
description: string;
|
||||
income_expense: string;
|
||||
amount: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
remark: string;
|
||||
needs_review: string;
|
||||
}
|
||||
|
||||
// 上传账单
|
||||
export async function uploadBill(file: File, options?: { year?: number; month?: number }): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (options?.year) {
|
||||
formData.append('year', options.year.toString());
|
||||
}
|
||||
if (options?.month) {
|
||||
formData.append('month', options.month.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取复核记录
|
||||
export async function getReviewRecords(fileName: string): Promise<ReviewResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/review?file=${encodeURIComponent(fileName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 下载文件 URL
|
||||
export function getDownloadUrl(fileUrl: string): string {
|
||||
return `${API_BASE}${fileUrl}`;
|
||||
}
|
||||
|
||||
// 解析账单内容(用于前端展示全部记录)
|
||||
export async function fetchBillContent(fileName: string): Promise<BillRecord[]> {
|
||||
const response = await fetch(`${API_BASE}/download/${fileName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
return parseCSV(text);
|
||||
}
|
||||
|
||||
// 解析 CSV
|
||||
function parseCSV(text: string): BillRecord[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headers = lines[0].split(',');
|
||||
const records: BillRecord[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = parseCSVLine(lines[i]);
|
||||
if (values.length >= headers.length) {
|
||||
records.push({
|
||||
time: values[0] || '',
|
||||
category: values[1] || '',
|
||||
merchant: values[2] || '',
|
||||
description: values[3] || '',
|
||||
income_expense: values[4] || '',
|
||||
amount: values[5] || '',
|
||||
payment_method: values[6] || '',
|
||||
status: values[7] || '',
|
||||
remark: values[8] || '',
|
||||
needs_review: values[9] || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// 解析 CSV 行(处理引号)
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
408
web/src/lib/components/analysis/CategoryRanking.svelte
Normal file
408
web/src/lib/components/analysis/CategoryRanking.svelte
Normal file
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import PieChartIcon from '@lucide/svelte/icons/pie-chart';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import type { CategoryStat, PieChartDataItem } from '$lib/types/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { getPercentage } from '$lib/services/analysis';
|
||||
import { barColors } from '$lib/constants/chart';
|
||||
|
||||
interface Props {
|
||||
categoryStats: CategoryStat[];
|
||||
pieChartData: PieChartDataItem[];
|
||||
totalExpense: number;
|
||||
records: BillRecord[];
|
||||
}
|
||||
|
||||
let { categoryStats, pieChartData, totalExpense, records }: Props = $props();
|
||||
|
||||
let mode = $state<'bar' | 'pie'>('bar');
|
||||
let dialogOpen = $state(false);
|
||||
let selectedCategory = $state<string | null>(null);
|
||||
let hoveredPieIndex = $state<number | null>(null);
|
||||
|
||||
// 隐藏的类别
|
||||
let hiddenCategories = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleCategory(category: string) {
|
||||
const newSet = new Set(hiddenCategories);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
hiddenCategories = newSet;
|
||||
}
|
||||
|
||||
// 过滤后的饼图数据(排除隐藏的类别)
|
||||
let filteredPieChartData = $derived.by(() => {
|
||||
const visible = pieChartData.filter(item => !hiddenCategories.has(item.category));
|
||||
const total = visible.reduce((sum, item) => sum + item.value, 0);
|
||||
// 重新计算百分比
|
||||
return visible.map(item => ({
|
||||
...item,
|
||||
percentage: total > 0 ? (item.value / total) * 100 : 0
|
||||
}));
|
||||
});
|
||||
|
||||
// 过滤后的总支出
|
||||
let filteredTotalExpense = $derived(
|
||||
filteredPieChartData.reduce((sum, item) => sum + item.value, 0)
|
||||
);
|
||||
|
||||
// 排序状态
|
||||
type SortField = 'time' | 'merchant' | 'description' | 'amount';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
let sortField = $state<SortField>('time');
|
||||
let sortOrder = $state<SortOrder>('desc');
|
||||
|
||||
// 分页状态
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 10; // 每页条目数
|
||||
|
||||
let expenseStats = $derived(categoryStats.filter(s => s.expense > 0));
|
||||
|
||||
// 获取选中分类的账单记录(已排序)
|
||||
let selectedRecords = $derived.by(() => {
|
||||
if (!selectedCategory) return [];
|
||||
|
||||
const filtered = records.filter(r => r.category === selectedCategory && r.income_expense === '支出');
|
||||
|
||||
return filtered.toSorted((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'time':
|
||||
cmp = new Date(a.time).getTime() - new Date(b.time).getTime();
|
||||
break;
|
||||
case 'merchant':
|
||||
cmp = a.merchant.localeCompare(b.merchant);
|
||||
break;
|
||||
case 'description':
|
||||
cmp = (a.description || '').localeCompare(b.description || '');
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
let totalPages = $derived(Math.ceil(selectedRecords.length / pageSize));
|
||||
let paginatedRecords = $derived(
|
||||
selectedRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
|
||||
// 选中分类的统计
|
||||
let selectedStat = $derived(
|
||||
selectedCategory ? categoryStats.find(s => s.category === selectedCategory) : null
|
||||
);
|
||||
|
||||
function openCategoryDetail(category: string) {
|
||||
selectedCategory = category;
|
||||
sortField = 'time';
|
||||
sortOrder = 'desc';
|
||||
currentPage = 1;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortOrder = field === 'amount' ? 'desc' : 'asc';
|
||||
}
|
||||
currentPage = 1; // 排序后重置到第一页
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between pb-2">
|
||||
<div class="space-y-1.5">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<PieChartIcon class="h-5 w-5" />
|
||||
分类支出排行
|
||||
</Card.Title>
|
||||
<Card.Description>各分类支出金额占比(点击查看详情)</Card.Description>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant={mode === 'bar' ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => mode = 'bar'}
|
||||
>
|
||||
<ListIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'pie' ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => mode = 'pie'}
|
||||
>
|
||||
<PieChartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if mode === 'bar'}
|
||||
<!-- 条形图视图 -->
|
||||
<div class="space-y-1">
|
||||
{#each expenseStats as stat, i}
|
||||
<button
|
||||
class="w-full text-left space-y-1 p-2 rounded-lg hover:bg-accent/50 hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 cursor-pointer outline-none focus:outline-none"
|
||||
onclick={() => openCategoryDetail(stat.category)}
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium">{stat.category}</span>
|
||||
<span class="font-mono">¥{stat.expense.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 {barColors[i % barColors.length]}"
|
||||
style="width: {getPercentage(stat.expense, totalExpense)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{stat.count} 笔</span>
|
||||
<span>{getPercentage(stat.expense, totalExpense).toFixed(1)}%</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 饼状图视图 -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="relative">
|
||||
<svg width="220" height="220" viewBox="-110 -110 220 220">
|
||||
{#each filteredPieChartData as item, i}
|
||||
{@const total = filteredPieChartData.reduce((sum, d) => sum + d.value, 0)}
|
||||
{@const startAngle = filteredPieChartData.slice(0, i).reduce((sum, d) => sum + (d.value / total) * Math.PI * 2, 0) - Math.PI / 2}
|
||||
{@const endAngle = startAngle + (item.value / total) * Math.PI * 2}
|
||||
{@const innerRadius = 45}
|
||||
{@const outerRadius = hoveredPieIndex === i ? 95 : 90}
|
||||
{@const x1 = Math.cos(startAngle) * outerRadius}
|
||||
{@const y1 = Math.sin(startAngle) * outerRadius}
|
||||
{@const x2 = Math.cos(endAngle) * outerRadius}
|
||||
{@const y2 = Math.sin(endAngle) * outerRadius}
|
||||
{@const x3 = Math.cos(endAngle) * innerRadius}
|
||||
{@const y3 = Math.sin(endAngle) * innerRadius}
|
||||
{@const x4 = Math.cos(startAngle) * innerRadius}
|
||||
{@const y4 = Math.sin(startAngle) * innerRadius}
|
||||
{@const largeArc = (endAngle - startAngle) > Math.PI ? 1 : 0}
|
||||
<path
|
||||
d="M {x1} {y1} A {outerRadius} {outerRadius} 0 {largeArc} 1 {x2} {y2} L {x3} {y3} A {innerRadius} {innerRadius} 0 {largeArc} 0 {x4} {y4} Z"
|
||||
fill={item.color}
|
||||
class="transition-all duration-200 cursor-pointer outline-none focus:outline-none"
|
||||
style="transform-origin: center; filter: drop-shadow(0 2px 4px rgba(0,0,0,{hoveredPieIndex === i ? '0.3' : '0.15'})); opacity: {hoveredPieIndex !== null && hoveredPieIndex !== i ? '0.6' : '1'};"
|
||||
onclick={() => openCategoryDetail(item.category)}
|
||||
onmouseenter={() => hoveredPieIndex = i}
|
||||
onmouseleave={() => hoveredPieIndex = null}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
/>
|
||||
{/each}
|
||||
<!-- 中心文字 -->
|
||||
{#if hoveredPieIndex !== null && filteredPieChartData[hoveredPieIndex]}
|
||||
{@const hovered = filteredPieChartData[hoveredPieIndex]}
|
||||
<text x="0" y="-12" text-anchor="middle" class="fill-foreground text-xs font-medium">{hovered.category}</text>
|
||||
<text x="0" y="6" text-anchor="middle" class="fill-foreground text-sm font-bold font-mono">¥{hovered.value.toFixed(0)}</text>
|
||||
<text x="0" y="22" text-anchor="middle" class="fill-muted-foreground text-xs">{hovered.percentage.toFixed(1)}%</text>
|
||||
{:else}
|
||||
<text x="0" y="-8" text-anchor="middle" class="fill-foreground text-sm font-medium">总支出</text>
|
||||
<text x="0" y="12" text-anchor="middle" class="fill-foreground text-lg font-bold font-mono">¥{filteredTotalExpense.toFixed(0)}</text>
|
||||
{/if}
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 图例 (点击可切换显示) -->
|
||||
<div class="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{#each pieChartData as item}
|
||||
{@const isHidden = hiddenCategories.has(item.category)}
|
||||
{@const filteredItem = filteredPieChartData.find(f => f.category === item.category)}
|
||||
{@const displayPercentage = isHidden ? item.percentage : (filteredItem?.percentage ?? 0)}
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1 -mx-1 cursor-pointer hover:opacity-80 transition-opacity outline-none"
|
||||
onclick={() => toggleCategory(item.category)}
|
||||
title={isHidden ? '点击显示' : '点击隐藏'}
|
||||
>
|
||||
<span
|
||||
class="h-3 w-3 rounded-sm shrink-0 transition-opacity {isHidden ? 'opacity-30' : ''}"
|
||||
style="background-color: {item.color}"
|
||||
></span>
|
||||
<span class="truncate transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}">{item.category}</span>
|
||||
<span class="font-mono font-medium ml-auto transition-colors {isHidden ? 'text-muted-foreground/40' : ''}">{displayPercentage.toFixed(1)}%</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- 分类详情弹窗 -->
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<PieChartIcon class="h-5 w-5" />
|
||||
{selectedCategory} - 账单明细
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if selectedStat}
|
||||
共 {selectedStat.count} 笔,合计 ¥{selectedStat.expense.toFixed(2)}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto mt-4">
|
||||
{#if selectedRecords.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[140px]">
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('time')}
|
||||
>
|
||||
时间
|
||||
{#if sortField === 'time'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head>
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('merchant')}
|
||||
>
|
||||
商家
|
||||
{#if sortField === 'merchant'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head>
|
||||
<button
|
||||
class="flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('description')}
|
||||
>
|
||||
描述
|
||||
{#if sortField === 'description'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
<Table.Head class="text-right w-[100px]">
|
||||
<button
|
||||
class="flex items-center gap-1 ml-auto hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('amount')}
|
||||
>
|
||||
金额
|
||||
{#if sortField === 'amount'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRecords as record}
|
||||
<Table.Row>
|
||||
<Table.Cell class="text-muted-foreground text-xs">
|
||||
{record.time.substring(0, 16)}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="font-medium">{record.merchant}</Table.Cell>
|
||||
<Table.Cell class="text-muted-foreground truncate max-w-[200px]">
|
||||
{record.description || '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right font-mono text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, selectedRecords.length)} 条,共 {selectedRecords.length} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<Button
|
||||
variant={page === currentPage ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => goToPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-muted-foreground">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-muted-foreground">
|
||||
暂无记录
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button variant="outline" onclick={() => dialogOpen = false}>
|
||||
关闭
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
788
web/src/lib/components/analysis/DailyTrendChart.svelte
Normal file
788
web/src/lib/components/analysis/DailyTrendChart.svelte
Normal file
@@ -0,0 +1,788 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
import { pieColors } from '$lib/constants/chart';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
}
|
||||
|
||||
let { records }: Props = $props();
|
||||
|
||||
// Dialog 状态
|
||||
let dialogOpen = $state(false);
|
||||
let selectedDate = $state<Date | null>(null);
|
||||
let selectedDateRecords = $state<BillRecord[]>([]);
|
||||
|
||||
// 排序状态
|
||||
type SortField = 'time' | 'category' | 'merchant' | 'amount';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
let sortField = $state<SortField>('amount');
|
||||
let sortOrder = $state<SortOrder>('desc');
|
||||
|
||||
// 分页状态
|
||||
let currentPage = $state(1);
|
||||
const pageSize = 8; // 每页条目数
|
||||
|
||||
// 排序后的记录
|
||||
let sortedDateRecords = $derived.by(() => {
|
||||
return selectedDateRecords.toSorted((a, b) => {
|
||||
let cmp = 0;
|
||||
switch (sortField) {
|
||||
case 'time':
|
||||
cmp = a.time.localeCompare(b.time);
|
||||
break;
|
||||
case 'category':
|
||||
cmp = a.category.localeCompare(b.category);
|
||||
break;
|
||||
case 'merchant':
|
||||
cmp = a.merchant.localeCompare(b.merchant);
|
||||
break;
|
||||
case 'amount':
|
||||
cmp = parseFloat(a.amount) - parseFloat(b.amount);
|
||||
break;
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
});
|
||||
|
||||
// 分页计算
|
||||
let totalPages = $derived(Math.ceil(sortedDateRecords.length / pageSize));
|
||||
let paginatedRecords = $derived(
|
||||
sortedDateRecords.slice((currentPage - 1) * pageSize, currentPage * pageSize)
|
||||
);
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortOrder = field === 'amount' ? 'desc' : 'asc';
|
||||
}
|
||||
currentPage = 1; // 排序后重置到第一页
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
}
|
||||
}
|
||||
|
||||
// 时间范围选项
|
||||
type TimeRange = '7d' | '30d' | '3m';
|
||||
let timeRange = $state<TimeRange>('3m');
|
||||
|
||||
// 隐藏的类别
|
||||
let hiddenCategories = $state<Set<string>>(new Set());
|
||||
|
||||
function toggleCategory(category: string) {
|
||||
const newSet = new Set(hiddenCategories);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
hiddenCategories = newSet;
|
||||
}
|
||||
|
||||
const timeRangeOptions = [
|
||||
{ value: '7d', label: '最近 7 天' },
|
||||
{ value: '30d', label: '最近 30 天' },
|
||||
{ value: '3m', label: '最近 3 个月' }
|
||||
];
|
||||
|
||||
// 获取截止日期
|
||||
function getCutoffDate(range: TimeRange): Date {
|
||||
const now = new Date();
|
||||
switch (range) {
|
||||
case '7d':
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case '30d':
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
case '3m':
|
||||
default:
|
||||
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 按日期和分类分组数据
|
||||
let processedData = $derived(() => {
|
||||
const cutoffDate = getCutoffDate(timeRange);
|
||||
|
||||
// 过滤支出记录
|
||||
const expenseRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDate = new Date(r.time.split(' ')[0]);
|
||||
return recordDate >= cutoffDate;
|
||||
});
|
||||
|
||||
if (expenseRecords.length === 0) return { data: [], categories: [], maxValue: 0 };
|
||||
|
||||
// 按日期分组
|
||||
const dateMap = new Map<string, Map<string, number>>();
|
||||
const categorySet = new Set<string>();
|
||||
const categoryTotals: Record<string, number> = {};
|
||||
|
||||
expenseRecords.forEach(record => {
|
||||
const dateStr = record.time.split(' ')[0];
|
||||
const category = record.category || '其他';
|
||||
const amount = parseFloat(record.amount) || 0;
|
||||
|
||||
categorySet.add(category);
|
||||
categoryTotals[category] = (categoryTotals[category] || 0) + amount;
|
||||
|
||||
if (!dateMap.has(dateStr)) {
|
||||
dateMap.set(dateStr, new Map());
|
||||
}
|
||||
const dayData = dateMap.get(dateStr)!;
|
||||
dayData.set(category, (dayData.get(category) || 0) + amount);
|
||||
});
|
||||
|
||||
// 获取前5大分类
|
||||
const sortedCategories = Object.entries(categoryTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([cat]) => cat);
|
||||
|
||||
// 转换为数组格式并计算堆叠值
|
||||
const data = Array.from(dateMap.entries())
|
||||
.map(([dateStr, dayData]) => {
|
||||
const result: Record<string, any> = {
|
||||
date: new Date(dateStr),
|
||||
dateStr: dateStr
|
||||
};
|
||||
|
||||
let cumulative = 0;
|
||||
sortedCategories.forEach(cat => {
|
||||
const value = dayData.get(cat) || 0;
|
||||
result[cat] = value;
|
||||
// 只有未隐藏的类别参与堆叠
|
||||
if (!hiddenCategories.has(cat)) {
|
||||
result[`${cat}_y0`] = cumulative;
|
||||
result[`${cat}_y1`] = cumulative + value;
|
||||
cumulative += value;
|
||||
} else {
|
||||
result[`${cat}_y0`] = 0;
|
||||
result[`${cat}_y1`] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 其他分类汇总
|
||||
let otherSum = 0;
|
||||
dayData.forEach((amount, cat) => {
|
||||
if (!sortedCategories.includes(cat)) {
|
||||
otherSum += amount;
|
||||
}
|
||||
});
|
||||
if (otherSum > 0) {
|
||||
result['其他'] = otherSum;
|
||||
if (!hiddenCategories.has('其他')) {
|
||||
result['其他_y0'] = cumulative;
|
||||
result['其他_y1'] = cumulative + otherSum;
|
||||
cumulative += otherSum;
|
||||
} else {
|
||||
result['其他_y0'] = 0;
|
||||
result['其他_y1'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
result.total = cumulative;
|
||||
return result;
|
||||
})
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
const finalCategories = [...sortedCategories];
|
||||
if (data.some(d => d['其他'] > 0)) {
|
||||
finalCategories.push('其他');
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...data.map(d => d.total || 0), 1);
|
||||
|
||||
return { data, categories: finalCategories, maxValue };
|
||||
});
|
||||
|
||||
// 获取颜色
|
||||
function getColor(index: number): string {
|
||||
return pieColors[index % pieColors.length];
|
||||
}
|
||||
|
||||
// 趋势计算
|
||||
let trendInfo = $derived(() => {
|
||||
const { data } = processedData();
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const lastTotal = data[data.length - 1].total || 0;
|
||||
const prevTotal = data[data.length - 2].total || 0;
|
||||
|
||||
const change = lastTotal - prevTotal;
|
||||
const changePercent = prevTotal > 0 ? (change / prevTotal) * 100 : 0;
|
||||
return { change, changePercent, lastTotal };
|
||||
});
|
||||
|
||||
// 获取描述文本
|
||||
let descriptionText = $derived(() => {
|
||||
const { data } = processedData();
|
||||
const label = timeRangeOptions.find(o => o.value === timeRange)?.label || '最近 3 个月';
|
||||
return `${label}各分类支出趋势 (${data.length} 天)`;
|
||||
});
|
||||
|
||||
function handleTimeRangeChange(value: string | undefined) {
|
||||
if (value && ['7d', '30d', '3m'].includes(value)) {
|
||||
timeRange = value as TimeRange;
|
||||
}
|
||||
}
|
||||
|
||||
// SVG 尺寸
|
||||
const chartWidth = 800;
|
||||
const chartHeight = 250;
|
||||
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
|
||||
const innerWidth = chartWidth - padding.left - padding.right;
|
||||
const innerHeight = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
// 坐标转换
|
||||
function xScale(date: Date, data: any[]): number {
|
||||
if (data.length <= 1) return padding.left;
|
||||
const minDate = data[0].date.getTime();
|
||||
const maxDate = data[data.length - 1].date.getTime();
|
||||
const range = maxDate - minDate || 1;
|
||||
return padding.left + ((date.getTime() - minDate) / range) * innerWidth;
|
||||
}
|
||||
|
||||
function yScale(value: number, maxValue: number): number {
|
||||
if (maxValue === 0) return padding.top + innerHeight;
|
||||
return padding.top + innerHeight - (value / maxValue) * innerHeight;
|
||||
}
|
||||
|
||||
// 生成面积路径
|
||||
function generateAreaPath(category: string, data: any[], maxValue: number): string {
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const points: string[] = [];
|
||||
const bottomPoints: string[] = [];
|
||||
|
||||
data.forEach((d, i) => {
|
||||
const x = xScale(d.date, data);
|
||||
const y1 = yScale(d[`${category}_y1`] || 0, maxValue);
|
||||
const y0 = yScale(d[`${category}_y0`] || 0, maxValue);
|
||||
points.push(`${x},${y1}`);
|
||||
bottomPoints.unshift(`${x},${y0}`);
|
||||
});
|
||||
|
||||
return `M ${points.join(' L ')} L ${bottomPoints.join(' L ')} Z`;
|
||||
}
|
||||
|
||||
// 生成 X 轴刻度
|
||||
function getXTicks(data: any[]): { x: number; label: string }[] {
|
||||
if (data.length === 0) return [];
|
||||
const step = Math.max(1, Math.floor(data.length / 6));
|
||||
return data.filter((_, i) => i % step === 0 || i === data.length - 1).map(d => ({
|
||||
x: xScale(d.date, data),
|
||||
label: `${d.date.getMonth() + 1}/${d.date.getDate()}`
|
||||
}));
|
||||
}
|
||||
|
||||
// 生成 Y 轴刻度
|
||||
function getYTicks(maxValue: number): { y: number; label: string }[] {
|
||||
if (maxValue === 0) return [];
|
||||
const ticks = [];
|
||||
const step = Math.ceil(maxValue / 4 / 100) * 100;
|
||||
for (let v = 0; v <= maxValue; v += step) {
|
||||
ticks.push({
|
||||
y: yScale(v, maxValue),
|
||||
label: `¥${v}`
|
||||
});
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// Tooltip 状态
|
||||
let tooltipData = $state<any>(null);
|
||||
let tooltipX = $state(0);
|
||||
let tooltipY = $state(0);
|
||||
|
||||
function handleMouseMove(event: MouseEvent, data: any[], maxValue: number) {
|
||||
const svg = event.currentTarget as SVGSVGElement;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
|
||||
// 将像素坐标转换为 viewBox 坐标
|
||||
const pixelX = event.clientX - rect.left;
|
||||
const scaleRatio = chartWidth / rect.width;
|
||||
const viewBoxX = pixelX * scaleRatio;
|
||||
|
||||
// 找到最近的数据点
|
||||
let closestIdx = 0;
|
||||
let closestDist = Infinity;
|
||||
data.forEach((d, i) => {
|
||||
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
|
||||
if (dx < closestDist) {
|
||||
closestDist = dx;
|
||||
closestIdx = i;
|
||||
}
|
||||
});
|
||||
|
||||
// 阈值也需要按比例调整
|
||||
if (closestDist < 50 * scaleRatio) {
|
||||
tooltipData = data[closestIdx];
|
||||
tooltipX = xScale(data[closestIdx].date, data);
|
||||
tooltipY = event.clientY - rect.top;
|
||||
} else {
|
||||
tooltipData = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
tooltipData = null;
|
||||
}
|
||||
|
||||
// 点击打开 Dialog
|
||||
function handleClick(event: MouseEvent, data: any[], maxValue: number) {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const svg = event.currentTarget as SVGSVGElement;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
|
||||
// 将像素坐标转换为 viewBox 坐标
|
||||
const pixelX = event.clientX - rect.left;
|
||||
const scaleRatio = chartWidth / rect.width;
|
||||
const viewBoxX = pixelX * scaleRatio;
|
||||
|
||||
// 找到最近的数据点
|
||||
let closestIdx = 0;
|
||||
let closestDist = Infinity;
|
||||
data.forEach((d, i) => {
|
||||
const dx = Math.abs(xScale(d.date, data) - viewBoxX);
|
||||
if (dx < closestDist) {
|
||||
closestDist = dx;
|
||||
closestIdx = i;
|
||||
}
|
||||
});
|
||||
|
||||
// 点击图表任意位置都触发,选择最近的日期
|
||||
const clickedDate = data[closestIdx].date;
|
||||
const dateStr = clickedDate.toISOString().split('T')[0];
|
||||
|
||||
// 找出当天的所有支出记录
|
||||
selectedDate = clickedDate;
|
||||
selectedDateRecords = records.filter(r => {
|
||||
if (r.income_expense !== '支出') return false;
|
||||
const recordDateStr = r.time.split(' ')[0];
|
||||
return recordDateStr === dateStr;
|
||||
});
|
||||
|
||||
// 重置排序和分页状态
|
||||
sortField = 'amount';
|
||||
sortOrder = 'desc';
|
||||
currentPage = 1;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
// 计算选中日期的统计
|
||||
let selectedDateStats = $derived.by(() => {
|
||||
if (!selectedDate || selectedDateRecords.length === 0) return null;
|
||||
|
||||
const categoryMap = new Map<string, { amount: number; count: number }>();
|
||||
let total = 0;
|
||||
|
||||
selectedDateRecords.forEach(r => {
|
||||
const cat = r.category || '其他';
|
||||
const amount = parseFloat(r.amount) || 0;
|
||||
total += amount;
|
||||
|
||||
if (!categoryMap.has(cat)) {
|
||||
categoryMap.set(cat, { amount: 0, count: 0 });
|
||||
}
|
||||
const stat = categoryMap.get(cat)!;
|
||||
stat.amount += amount;
|
||||
stat.count += 1;
|
||||
});
|
||||
|
||||
const categories = Array.from(categoryMap.entries())
|
||||
.map(([category, stat]) => ({ category, ...stat }))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
return { total, categories, count: selectedDateRecords.length };
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if processedData().data.length > 1}
|
||||
{@const { data, categories, maxValue } = processedData()}
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between pb-2">
|
||||
<div class="space-y-1.5">
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Activity class="h-5 w-5" />
|
||||
每日支出趋势
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
{descriptionText()}
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Select.Root type="single" value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<Select.Trigger class="w-[140px] h-8 text-xs">
|
||||
{timeRangeOptions.find(o => o.value === timeRange)?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each timeRangeOptions as option}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<!-- 分类图例 (点击可切换显示) -->
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
{#each categories as category, i}
|
||||
{@const isHidden = hiddenCategories.has(category)}
|
||||
<button
|
||||
class="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity outline-none"
|
||||
onclick={() => toggleCategory(category)}
|
||||
title={isHidden ? '点击显示' : '点击隐藏'}
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-sm transition-opacity {isHidden ? 'opacity-30' : ''}"
|
||||
style="background-color: {getColor(i)}"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs transition-colors {isHidden ? 'text-muted-foreground/40 line-through' : 'text-muted-foreground'}"
|
||||
>{category}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 堆叠面积图 (自定义 SVG) -->
|
||||
<div class="relative w-full" style="aspect-ratio: {chartWidth}/{chartHeight};">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<svg
|
||||
viewBox="0 0 {chartWidth} {chartHeight}"
|
||||
class="w-full h-full cursor-pointer outline-none focus:outline-none"
|
||||
role="application"
|
||||
aria-label="每日支出趋势图表,点击可查看当日详情"
|
||||
tabindex="-1"
|
||||
onmousemove={(e) => handleMouseMove(e, data, maxValue)}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onclick={(e) => handleClick(e, data, maxValue)}
|
||||
>
|
||||
<!-- Y 轴 -->
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top}
|
||||
x2={padding.left}
|
||||
y2={padding.top + innerHeight}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.2"
|
||||
/>
|
||||
|
||||
<!-- Y 轴刻度 -->
|
||||
{#each getYTicks(maxValue) as tick}
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={tick.y}
|
||||
x2={padding.left + innerWidth}
|
||||
y2={tick.y}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.1"
|
||||
stroke-dasharray="4"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={tick.y + 4}
|
||||
text-anchor="end"
|
||||
class="fill-muted-foreground text-[10px]"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- X 轴 -->
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + innerHeight}
|
||||
x2={padding.left + innerWidth}
|
||||
y2={padding.top + innerHeight}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.2"
|
||||
/>
|
||||
|
||||
<!-- X 轴刻度 -->
|
||||
{#each getXTicks(data) as tick}
|
||||
<text
|
||||
x={tick.x}
|
||||
y={padding.top + innerHeight + 20}
|
||||
text-anchor="middle"
|
||||
class="fill-muted-foreground text-[10px]"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- 堆叠面积 (从后向前渲染) -->
|
||||
{#each [...categories].reverse() as category, i}
|
||||
{@const colorIdx = categories.length - 1 - i}
|
||||
<path
|
||||
d={generateAreaPath(category, data, maxValue)}
|
||||
fill={getColor(colorIdx)}
|
||||
fill-opacity="0.7"
|
||||
class="transition-opacity hover:opacity-90"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Tooltip 辅助线 -->
|
||||
{#if tooltipData}
|
||||
<line
|
||||
x1={tooltipX}
|
||||
y1={padding.top}
|
||||
x2={tooltipX}
|
||||
y2={padding.top + innerHeight}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.3"
|
||||
stroke-dasharray="4"
|
||||
/>
|
||||
<!-- 数据点 -->
|
||||
{#each categories as category, i}
|
||||
{#if tooltipData[category] > 0}
|
||||
<circle
|
||||
cx={tooltipX}
|
||||
cy={yScale(tooltipData[`${category}_y1`] || 0, maxValue)}
|
||||
r="4"
|
||||
fill={getColor(i)}
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip -->
|
||||
{#if tooltipData}
|
||||
{@const tooltipLeftPercent = (tooltipX / chartWidth) * 100}
|
||||
{@const adjustedLeft = tooltipLeftPercent > 75 ? tooltipLeftPercent - 25 : tooltipLeftPercent + 2}
|
||||
<div
|
||||
class="absolute pointer-events-none z-10 border-border/50 bg-background rounded-lg border px-3 py-2 text-xs shadow-xl min-w-[160px]"
|
||||
style="left: {adjustedLeft}%; top: 15%;"
|
||||
>
|
||||
<div class="font-medium text-foreground mb-2">
|
||||
{tooltipData.date.toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
{#each categories as category, i}
|
||||
{#if tooltipData[category] > 0}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
style="background-color: {getColor(i)}"
|
||||
></div>
|
||||
<span class="text-muted-foreground">{category}</span>
|
||||
</div>
|
||||
<span class="font-mono font-medium">¥{tooltipData[category].toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="border-t border-border mt-2 pt-2 flex justify-between">
|
||||
<span class="text-muted-foreground">合计</span>
|
||||
<span class="font-mono font-bold">¥{tooltipData.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 趋势指标 -->
|
||||
<div class="flex items-center justify-center gap-2 mt-4 text-sm">
|
||||
{#if trendInfo()}
|
||||
{@const info = trendInfo()}
|
||||
{#if info!.change >= 0}
|
||||
<TrendingUp class="h-4 w-4 text-red-500" />
|
||||
<span class="text-red-500">+{Math.abs(info!.changePercent).toFixed(1)}%</span>
|
||||
{:else}
|
||||
<TrendingDown class="h-4 w-4 text-green-500" />
|
||||
<span class="text-green-500">-{Math.abs(info!.changePercent).toFixed(1)}%</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground">较前一天</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 提示文字 -->
|
||||
<p class="text-center text-xs text-muted-foreground mt-2">
|
||||
点击图表查看当日详情
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<!-- 当日详情 Dialog -->
|
||||
<Dialog.Root bind:open={dialogOpen}>
|
||||
<Dialog.Content class="w-fit min-w-[500px] max-w-[90vw] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Calendar class="h-5 w-5" />
|
||||
{#if selectedDate}
|
||||
{selectedDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if selectedDateStats}
|
||||
{@const stats = selectedDateStats}
|
||||
共 {stats!.count} 笔支出,合计 ¥{stats!.total.toFixed(2)}
|
||||
{/if}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex-1 overflow-auto py-4">
|
||||
{#if selectedDateStats}
|
||||
{@const stats = selectedDateStats}
|
||||
|
||||
<!-- 分类汇总 -->
|
||||
<div class="mb-4 space-y-2">
|
||||
<h4 class="text-sm font-medium text-muted-foreground">分类汇总</h4>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each stats!.categories as cat, i}
|
||||
<div class="flex items-center justify-between p-2 rounded-lg bg-muted/50">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
style="background-color: {getColor(i)}"
|
||||
></div>
|
||||
<span class="text-sm">{cat.category}</span>
|
||||
<span class="text-xs text-muted-foreground">({cat.count}笔)</span>
|
||||
</div>
|
||||
<span class="font-mono text-sm font-medium text-red-600 dark:text-red-400">
|
||||
¥{cat.amount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细记录 -->
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-muted-foreground mb-2">详细记录(点击表头排序)</h4>
|
||||
<div class="rounded-md border overflow-auto max-h-[300px]">
|
||||
<!-- 表头 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b text-xs font-medium text-muted-foreground sticky top-0">
|
||||
<button
|
||||
class="w-[60px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('time')}
|
||||
>
|
||||
时间
|
||||
{#if sortField === 'time'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="w-[80px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('category')}
|
||||
>
|
||||
分类
|
||||
{#if sortField === 'category'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 min-w-[100px] flex items-center gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('merchant')}
|
||||
>
|
||||
商家
|
||||
{#if sortField === 'merchant'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="w-[100px] flex items-center justify-end gap-1 hover:text-foreground transition-colors outline-none"
|
||||
onclick={() => toggleSort('amount')}
|
||||
>
|
||||
金额
|
||||
{#if sortField === 'amount'}
|
||||
{#if sortOrder === 'asc'}<ArrowUp class="h-3 w-3" />{:else}<ArrowDown class="h-3 w-3" />{/if}
|
||||
{:else}
|
||||
<ArrowUpDown class="h-3 w-3 opacity-30" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- 记录列表 -->
|
||||
{#each paginatedRecords as record}
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b last:border-b-0 hover:bg-muted/30">
|
||||
<div class="w-[60px] text-xs text-muted-foreground">
|
||||
{record.time.split(' ')[1] || '--:--'}
|
||||
</div>
|
||||
<div class="w-[80px] text-sm">{record.category}</div>
|
||||
<div class="flex-1 min-w-[100px] text-sm truncate" title="{record.merchant}">
|
||||
{record.merchant}
|
||||
</div>
|
||||
<div class="w-[100px] text-right font-mono text-sm font-medium text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-3 pt-3 border-t">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
显示 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, sortedDateRecords.length)} 条,共 {sortedDateRecords.length} 条
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{#each Array.from({ length: totalPages }, (_, i) => i + 1) as page}
|
||||
{#if page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)}
|
||||
<Button
|
||||
variant={page === currentPage ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
class="h-7 w-7 text-xs"
|
||||
onclick={() => goToPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
{:else if page === currentPage - 2 || page === currentPage + 2}
|
||||
<span class="px-1 text-muted-foreground text-xs">...</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-muted-foreground py-8">暂无数据</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
26
web/src/lib/components/analysis/EmptyState.svelte
Normal file
26
web/src/lib/components/analysis/EmptyState.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
|
||||
interface Props {
|
||||
onLoadDemo: () => void;
|
||||
}
|
||||
|
||||
let { onLoadDemo }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="flex flex-col items-center justify-center py-16">
|
||||
<BarChart3 class="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<p class="text-lg font-medium">输入文件名开始分析</p>
|
||||
<p class="text-sm text-muted-foreground mb-4">上传账单后可在此进行数据分析</p>
|
||||
<Button variant="outline" onclick={onLoadDemo}>
|
||||
<Activity class="mr-2 h-4 w-4" />
|
||||
查看示例数据
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
69
web/src/lib/components/analysis/MonthlyTrend.svelte
Normal file
69
web/src/lib/components/analysis/MonthlyTrend.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
|
||||
import type { MonthlyStat } from '$lib/types/analysis';
|
||||
import { getPercentage } from '$lib/services/analysis';
|
||||
|
||||
interface Props {
|
||||
monthlyStats: MonthlyStat[];
|
||||
}
|
||||
|
||||
let { monthlyStats }: Props = $props();
|
||||
|
||||
let maxValue = $derived(Math.max(...monthlyStats.map(s => Math.max(s.expense, s.income))));
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<BarChart3 class="h-5 w-5" />
|
||||
月度趋势
|
||||
</Card.Title>
|
||||
<Card.Description>收支变化趋势</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
{#each monthlyStats as stat}
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium">{stat.month}</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-5 rounded bg-red-500/20 overflow-hidden flex-1 relative">
|
||||
<div
|
||||
class="h-full bg-red-500 transition-all duration-500"
|
||||
style="width: {getPercentage(stat.expense, maxValue)}%"
|
||||
></div>
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-mono">
|
||||
-¥{stat.expense.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-5 rounded bg-green-500/20 overflow-hidden flex-1 relative">
|
||||
<div
|
||||
class="h-full bg-green-500 transition-all duration-500"
|
||||
style="width: {getPercentage(stat.income, maxValue)}%"
|
||||
></div>
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-mono">
|
||||
+¥{stat.income.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- 图例 -->
|
||||
<div class="flex justify-center gap-6 pt-2">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="w-3 h-3 rounded bg-red-500"></span>
|
||||
支出
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="w-3 h-3 rounded bg-green-500"></span>
|
||||
收入
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
65
web/src/lib/components/analysis/OverviewCards.svelte
Normal file
65
web/src/lib/components/analysis/OverviewCards.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||
import Wallet from '@lucide/svelte/icons/wallet';
|
||||
import type { TotalStats } from '$lib/types/analysis';
|
||||
import { countByType } from '$lib/services/analysis';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
totalStats: TotalStats;
|
||||
records: BillRecord[];
|
||||
}
|
||||
|
||||
let { totalStats, records }: Props = $props();
|
||||
|
||||
let balance = $derived(totalStats.income - totalStats.expense);
|
||||
let expenseCount = $derived(countByType(records, '支出'));
|
||||
let incomeCount = $derived(countByType(records, '收入'));
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card.Root class="border-red-200 dark:border-red-900">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">总支出</Card.Title>
|
||||
<TrendingDown class="h-4 w-4 text-red-500" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold font-mono text-red-600 dark:text-red-400">
|
||||
¥{totalStats.expense.toFixed(2)}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">共 {expenseCount} 笔支出</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class="border-green-200 dark:border-green-900">
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">总收入</Card.Title>
|
||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold font-mono text-green-600 dark:text-green-400">
|
||||
¥{totalStats.income.toFixed(2)}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">共 {incomeCount} 笔收入</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root class={balance >= 0 ? 'border-blue-200 dark:border-blue-900' : 'border-orange-200 dark:border-orange-900'}>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">结余</Card.Title>
|
||||
<Wallet class="h-4 w-4 {balance >= 0 ? 'text-blue-500' : 'text-orange-500'}" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold font-mono {balance >= 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'}">
|
||||
¥{balance.toFixed(2)}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{balance >= 0 ? '本期盈余' : '本期亏空'}
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
|
||||
43
web/src/lib/components/analysis/TopExpenses.svelte
Normal file
43
web/src/lib/components/analysis/TopExpenses.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Flame from '@lucide/svelte/icons/flame';
|
||||
import type { BillRecord } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
records: BillRecord[];
|
||||
}
|
||||
|
||||
let { records }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Flame class="h-5 w-5 text-orange-500" />
|
||||
Top 10 单笔支出
|
||||
</Card.Title>
|
||||
<Card.Description>最大的单笔支出记录</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="space-y-3">
|
||||
{#each records as record, i}
|
||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-sm">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate">{record.merchant}</p>
|
||||
<p class="text-sm text-muted-foreground truncate">
|
||||
{record.description || record.category}
|
||||
</p>
|
||||
</div>
|
||||
<div class="font-mono font-bold text-red-600 dark:text-red-400">
|
||||
¥{record.amount}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
|
||||
8
web/src/lib/components/analysis/index.ts
Normal file
8
web/src/lib/components/analysis/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as OverviewCards } from './OverviewCards.svelte';
|
||||
export { default as DailyTrendChart } from './DailyTrendChart.svelte';
|
||||
export { default as CategoryRanking } from './CategoryRanking.svelte';
|
||||
export { default as MonthlyTrend } from './MonthlyTrend.svelte';
|
||||
export { default as TopExpenses } from './TopExpenses.svelte';
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
|
||||
|
||||
17
web/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
web/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
web/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
web/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
web/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
web/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
13
web/src/lib/components/ui/avatar/index.ts
Normal file
13
web/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
50
web/src/lib/components/ui/badge/badge.svelte
Normal file
50
web/src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
2
web/src/lib/components/ui/badge/index.ts
Normal file
2
web/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
web/src/lib/components/ui/button/button.svelte
Normal file
82
web/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
|
||||
outline:
|
||||
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
17
web/src/lib/components/ui/button/index.ts
Normal file
17
web/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
20
web/src/lib/components/ui/card/card-action.svelte
Normal file
20
web/src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
web/src/lib/components/ui/card/card-content.svelte
Normal file
15
web/src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/card/card-description.svelte
Normal file
20
web/src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
20
web/src/lib/components/ui/card/card-footer.svelte
Normal file
20
web/src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
web/src/lib/components/ui/card/card-header.svelte
Normal file
23
web/src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/card/card-title.svelte
Normal file
20
web/src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("leading-none font-semibold", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
web/src/lib/components/ui/card/card.svelte
Normal file
23
web/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
web/src/lib/components/ui/card/index.ts
Normal file
25
web/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
80
web/src/lib/components/ui/chart/chart-container.svelte
Normal file
80
web/src/lib/components/ui/chart/chart-container.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import ChartStyle from "./chart-style.svelte";
|
||||
import { setChartContext, type ChartConfig } from "./chart-utils.js";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = uid,
|
||||
class: className,
|
||||
children,
|
||||
config,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||
config: ChartConfig;
|
||||
} = $props();
|
||||
|
||||
const chartId = `chart-${id || uid.replace(/:/g, "")}`;
|
||||
|
||||
setChartContext({
|
||||
get config() {
|
||||
return config;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-chart={chartId}
|
||||
data-slot="chart"
|
||||
class={cn(
|
||||
"flex aspect-video justify-center overflow-visible text-xs",
|
||||
// Overrides
|
||||
//
|
||||
// Stroke around dots/marks when hovering
|
||||
"[&_.lc-highlight-point]:stroke-transparent",
|
||||
// override the default stroke color of lines
|
||||
"[&_.lc-line]:stroke-border/50",
|
||||
|
||||
// by default, layerchart shows a line intersecting the point when hovering, this hides that
|
||||
"[&_.lc-highlight-line]:stroke-0",
|
||||
|
||||
// by default, when you hover a point on a stacked series chart, it will drop the opacity
|
||||
// of the other series, this overrides that
|
||||
"[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible",
|
||||
|
||||
// We don't want the little tick lines between the axis labels and the chart, so we remove
|
||||
// the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every
|
||||
// chart.
|
||||
"[&_.lc-axis-tick]:stroke-0",
|
||||
|
||||
// We don't want to display the rule on the x/y axis, as there is already going to be
|
||||
// a grid line there and rule ends up overlapping the marks because it is rendered after
|
||||
// the marks
|
||||
"[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0",
|
||||
"[&_.lc-grid-x-radial-line]:stroke-border [&_.lc-grid-x-radial-circle]:stroke-border",
|
||||
"[&_.lc-grid-y-radial-line]:stroke-border [&_.lc-grid-y-radial-circle]:stroke-border",
|
||||
|
||||
// Legend adjustments
|
||||
"[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5",
|
||||
"[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4",
|
||||
"[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]",
|
||||
|
||||
// Labels
|
||||
"[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent",
|
||||
|
||||
// Tick labels on th x/y axes
|
||||
"[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal",
|
||||
"[&_.lc-tooltip-rects-g]:fill-transparent",
|
||||
"[&_.lc-layout-svg-g]:fill-transparent",
|
||||
"[&_.lc-root-container]:w-full",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChartStyle id={chartId} {config} />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
37
web/src/lib/components/ui/chart/chart-style.svelte
Normal file
37
web/src/lib/components/ui/chart/chart-style.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { THEMES, type ChartConfig } from "./chart-utils.js";
|
||||
|
||||
let { id, config }: { id: string; config: ChartConfig } = $props();
|
||||
|
||||
const colorConfig = $derived(
|
||||
config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null
|
||||
);
|
||||
|
||||
const themeContents = $derived.by(() => {
|
||||
if (!colorConfig || !colorConfig.length) return;
|
||||
|
||||
const themeContents = [];
|
||||
for (let [_theme, prefix] of Object.entries(THEMES)) {
|
||||
let content = `${prefix} [data-chart=${id}] {\n`;
|
||||
const color = colorConfig.map(([key, itemConfig]) => {
|
||||
const theme = _theme as keyof typeof itemConfig.theme;
|
||||
const color = itemConfig.theme?.[theme] || itemConfig.color;
|
||||
return color ? `\t--color-${key}: ${color};` : null;
|
||||
});
|
||||
|
||||
content += color.join("\n") + "\n}";
|
||||
|
||||
themeContents.push(content);
|
||||
}
|
||||
|
||||
return themeContents.join("\n");
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if themeContents}
|
||||
{#key id}
|
||||
<svelte:element this={"style"}>
|
||||
{themeContents}
|
||||
</svelte:element>
|
||||
{/key}
|
||||
{/if}
|
||||
159
web/src/lib/components/ui/chart/chart-tooltip.svelte
Normal file
159
web/src/lib/components/ui/chart/chart-tooltip.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js";
|
||||
import { getTooltipContext, Tooltip as TooltipPrimitive } from "layerchart";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function defaultFormatter(value: any, _payload: TooltipPayload[]) {
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
hideLabel = false,
|
||||
indicator = "dot",
|
||||
hideIndicator = false,
|
||||
labelKey,
|
||||
label,
|
||||
labelFormatter = defaultFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
nameKey,
|
||||
color,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & {
|
||||
hideLabel?: boolean;
|
||||
label?: string;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
hideIndicator?: boolean;
|
||||
labelClassName?: string;
|
||||
labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null;
|
||||
formatter?: Snippet<
|
||||
[
|
||||
{
|
||||
value: unknown;
|
||||
name: string;
|
||||
item: TooltipPayload;
|
||||
index: number;
|
||||
payload: TooltipPayload[];
|
||||
},
|
||||
]
|
||||
>;
|
||||
} = $props();
|
||||
|
||||
const chart = useChart();
|
||||
const tooltipCtx = getTooltipContext();
|
||||
|
||||
const formattedLabel = $derived.by(() => {
|
||||
if (hideLabel || !tooltipCtx.payload?.length) return null;
|
||||
|
||||
const [item] = tooltipCtx.payload;
|
||||
const key = labelKey ?? item?.label ?? item?.name ?? "value";
|
||||
|
||||
const itemConfig = getPayloadConfigFromPayload(chart.config, item, key);
|
||||
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (chart.config[label as keyof typeof chart.config]?.label ?? label)
|
||||
: (itemConfig?.label ?? item.label);
|
||||
|
||||
if (value === undefined) return null;
|
||||
if (!labelFormatter) return value;
|
||||
return labelFormatter(value, tooltipCtx.payload);
|
||||
});
|
||||
|
||||
const nestLabel = $derived(tooltipCtx.payload.length === 1 && indicator !== "dot");
|
||||
</script>
|
||||
|
||||
{#snippet TooltipLabel()}
|
||||
{#if formattedLabel}
|
||||
<div class={cn("font-medium", labelClassName)}>
|
||||
{#if typeof formattedLabel === "function"}
|
||||
{@render formattedLabel()}
|
||||
{:else}
|
||||
{formattedLabel}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<TooltipPrimitive.Root variant="none">
|
||||
<div
|
||||
class={cn(
|
||||
"border-border/50 bg-background grid min-w-[9rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if !nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<div class="grid gap-1.5">
|
||||
{#each tooltipCtx.payload as item, i (item.key + i)}
|
||||
{@const key = `${nameKey || item.key || item.name || "value"}`}
|
||||
{@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)}
|
||||
{@const indicatorColor = color || item.payload?.color || item.color}
|
||||
<div
|
||||
class={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{#if formatter && item.value !== undefined && item.name}
|
||||
{@render formatter({
|
||||
value: item.value,
|
||||
name: item.name,
|
||||
item,
|
||||
index: i,
|
||||
payload: tooltipCtx.payload,
|
||||
})}
|
||||
{:else}
|
||||
{#if itemConfig?.icon}
|
||||
<itemConfig.icon />
|
||||
{:else if !hideIndicator}
|
||||
<div
|
||||
style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};"
|
||||
class={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"size-2.5": indicator === "dot",
|
||||
"h-full w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-1 shrink-0 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div class="grid gap-1.5">
|
||||
{#if nestLabel}
|
||||
{@render TooltipLabel()}
|
||||
{/if}
|
||||
<span class="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if item.value !== undefined}
|
||||
<span class="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPrimitive.Root>
|
||||
66
web/src/lib/components/ui/chart/chart-utils.ts
Normal file
66
web/src/lib/components/ui/chart/chart-utils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Tooltip } from "layerchart";
|
||||
import { getContext, setContext, type Component, type ComponentProps, type Snippet } from "svelte";
|
||||
|
||||
export const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: string;
|
||||
icon?: Component;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never;
|
||||
|
||||
export type TooltipPayload = ExtractSnippetParams<
|
||||
ComponentProps<typeof Tooltip.Root>["children"]
|
||||
>["payload"][number];
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
export function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: TooltipPayload,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) return undefined;
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (payload.key === key) {
|
||||
configLabelKey = payload.key;
|
||||
} else if (payload.name === key) {
|
||||
configLabelKey = payload.name;
|
||||
} else if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload !== undefined &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const chartContextKey = Symbol("chart-context");
|
||||
|
||||
export function setChartContext(value: ChartContextValue) {
|
||||
return setContext(chartContextKey, value);
|
||||
}
|
||||
|
||||
export function useChart() {
|
||||
return getContext<ChartContextValue>(chartContextKey);
|
||||
}
|
||||
6
web/src/lib/components/ui/chart/index.ts
Normal file
6
web/src/lib/components/ui/chart/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import ChartContainer from "./chart-container.svelte";
|
||||
import ChartTooltip from "./chart-tooltip.svelte";
|
||||
|
||||
export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js";
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip };
|
||||
7
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
45
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import DialogPortal from "./dialog-portal.svelte";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DialogPortal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-lg leading-none font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Root bind:open {...restProps} />
|
||||
34
web/src/lib/components/ui/dialog/index.ts
Normal file
34
web/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import Root from "./dialog.svelte";
|
||||
import Portal from "./dialog-portal.svelte";
|
||||
import Title from "./dialog-title.svelte";
|
||||
import Footer from "./dialog-footer.svelte";
|
||||
import Header from "./dialog-header.svelte";
|
||||
import Overlay from "./dialog-overlay.svelte";
|
||||
import Content from "./dialog-content.svelte";
|
||||
import Description from "./dialog-description.svelte";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Close,
|
||||
//
|
||||
Root as Dialog,
|
||||
Title as DialogTitle,
|
||||
Portal as DialogPortal,
|
||||
Footer as DialogFooter,
|
||||
Header as DialogHeader,
|
||||
Trigger as DialogTrigger,
|
||||
Overlay as DialogOverlay,
|
||||
Content as DialogContent,
|
||||
Description as DialogDescription,
|
||||
Close as DialogClose,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable([]),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-checkbox-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<span
|
||||
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
{#if indeterminate}
|
||||
<MinusIcon class="size-4" />
|
||||
{:else}
|
||||
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPortal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPortal>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import CircleIcon from "@lucide/svelte/icons/circle";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span
|
||||
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
{#if checked}
|
||||
<CircleIcon class="size-2 fill-current" />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-separator"
|
||||
class={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubTriggerProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="ms-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.SubProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Sub bind:open {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: DropdownMenuPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Root bind:open {...restProps} />
|
||||
54
web/src/lib/components/ui/dropdown-menu/index.ts
Normal file
54
web/src/lib/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import Root from "./dropdown-menu.svelte";
|
||||
import Sub from "./dropdown-menu-sub.svelte";
|
||||
import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte";
|
||||
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
|
||||
import Content from "./dropdown-menu-content.svelte";
|
||||
import Group from "./dropdown-menu-group.svelte";
|
||||
import Item from "./dropdown-menu-item.svelte";
|
||||
import Label from "./dropdown-menu-label.svelte";
|
||||
import RadioGroup from "./dropdown-menu-radio-group.svelte";
|
||||
import RadioItem from "./dropdown-menu-radio-item.svelte";
|
||||
import Separator from "./dropdown-menu-separator.svelte";
|
||||
import Shortcut from "./dropdown-menu-shortcut.svelte";
|
||||
import Trigger from "./dropdown-menu-trigger.svelte";
|
||||
import SubContent from "./dropdown-menu-sub-content.svelte";
|
||||
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
|
||||
import GroupHeading from "./dropdown-menu-group-heading.svelte";
|
||||
import Portal from "./dropdown-menu-portal.svelte";
|
||||
|
||||
export {
|
||||
CheckboxGroup,
|
||||
CheckboxItem,
|
||||
Content,
|
||||
Portal,
|
||||
Root as DropdownMenu,
|
||||
CheckboxGroup as DropdownMenuCheckboxGroup,
|
||||
CheckboxItem as DropdownMenuCheckboxItem,
|
||||
Content as DropdownMenuContent,
|
||||
Portal as DropdownMenuPortal,
|
||||
Group as DropdownMenuGroup,
|
||||
Item as DropdownMenuItem,
|
||||
Label as DropdownMenuLabel,
|
||||
RadioGroup as DropdownMenuRadioGroup,
|
||||
RadioItem as DropdownMenuRadioItem,
|
||||
Separator as DropdownMenuSeparator,
|
||||
Shortcut as DropdownMenuShortcut,
|
||||
Sub as DropdownMenuSub,
|
||||
SubContent as DropdownMenuSubContent,
|
||||
SubTrigger as DropdownMenuSubTrigger,
|
||||
Trigger as DropdownMenuTrigger,
|
||||
GroupHeading as DropdownMenuGroupHeading,
|
||||
Group,
|
||||
GroupHeading,
|
||||
Item,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Root,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Sub,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
Trigger,
|
||||
};
|
||||
7
web/src/lib/components/ui/input/index.ts
Normal file
7
web/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
52
web/src/lib/components/ui/input/input.svelte
Normal file
52
web/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "input",
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
7
web/src/lib/components/ui/label/index.ts
Normal file
7
web/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
web/src/lib/components/ui/label/label.svelte
Normal file
20
web/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
37
web/src/lib/components/ui/select/index.ts
Normal file
37
web/src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Root from "./select.svelte";
|
||||
import Group from "./select-group.svelte";
|
||||
import Label from "./select-label.svelte";
|
||||
import Item from "./select-item.svelte";
|
||||
import Content from "./select-content.svelte";
|
||||
import Trigger from "./select-trigger.svelte";
|
||||
import Separator from "./select-separator.svelte";
|
||||
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import GroupHeading from "./select-group-heading.svelte";
|
||||
import Portal from "./select-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Group,
|
||||
Label,
|
||||
Item,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
GroupHeading,
|
||||
Portal,
|
||||
//
|
||||
Root as Select,
|
||||
Group as SelectGroup,
|
||||
Label as SelectLabel,
|
||||
Item as SelectItem,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Portal as SelectPortal,
|
||||
};
|
||||
45
web/src/lib/components/ui/select/select-content.svelte
Normal file
45
web/src/lib/components/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import SelectPortal from "./select-portal.svelte";
|
||||
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import type { WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
preventScroll = true,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPortal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPortal>
|
||||
21
web/src/lib/components/ui/select/select-group-heading.svelte
Normal file
21
web/src/lib/components/ui/select/select-group-heading.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
7
web/src/lib/components/ui/select/select-group.svelte
Normal file
7
web/src/lib/components/ui/select/select-group.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
|
||||
38
web/src/lib/components/ui/select/select-item.svelte
Normal file
38
web/src/lib/components/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Item
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
20
web/src/lib/components/ui/select/select-label.svelte
Normal file
20
web/src/lib/components/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
7
web/src/lib/components/ui/select/select-portal.svelte
Normal file
7
web/src/lib/components/ui/select/select-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
18
web/src/lib/components/ui/select/select-separator.svelte
Normal file
18
web/src/lib/components/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
29
web/src/lib/components/ui/select/select-trigger.svelte
Normal file
29
web/src/lib/components/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = "default",
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: "sm" | "default";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
11
web/src/lib/components/ui/select/select.svelte
Normal file
11
web/src/lib/components/ui/select/select.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: SelectPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
|
||||
7
web/src/lib/components/ui/separator/index.ts
Normal file
7
web/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
21
web/src/lib/components/ui/separator/separator.svelte
Normal file
21
web/src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "separator",
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
34
web/src/lib/components/ui/sheet/index.ts
Normal file
34
web/src/lib/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import Root from "./sheet.svelte";
|
||||
import Portal from "./sheet-portal.svelte";
|
||||
import Trigger from "./sheet-trigger.svelte";
|
||||
import Close from "./sheet-close.svelte";
|
||||
import Overlay from "./sheet-overlay.svelte";
|
||||
import Content from "./sheet-content.svelte";
|
||||
import Header from "./sheet-header.svelte";
|
||||
import Footer from "./sheet-footer.svelte";
|
||||
import Title from "./sheet-title.svelte";
|
||||
import Description from "./sheet-description.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Close,
|
||||
Trigger,
|
||||
Portal,
|
||||
Overlay,
|
||||
Content,
|
||||
Header,
|
||||
Footer,
|
||||
Title,
|
||||
Description,
|
||||
//
|
||||
Root as Sheet,
|
||||
Close as SheetClose,
|
||||
Trigger as SheetTrigger,
|
||||
Portal as SheetPortal,
|
||||
Overlay as SheetOverlay,
|
||||
Content as SheetContent,
|
||||
Header as SheetHeader,
|
||||
Footer as SheetFooter,
|
||||
Title as SheetTitle,
|
||||
Description as SheetDescription,
|
||||
};
|
||||
7
web/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
web/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
|
||||
60
web/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
60
web/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
export const sheetVariants = tv({
|
||||
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
variants: {
|
||||
side: {
|
||||
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
left: "data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
|
||||
right: "data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
});
|
||||
|
||||
export type Side = VariantProps<typeof sheetVariants>["side"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import SheetPortal from "./sheet-portal.svelte";
|
||||
import SheetOverlay from "./sheet-overlay.svelte";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
side = "right",
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
|
||||
side?: Side;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SheetPortal {...portalProps}>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="sheet-content"
|
||||
class={cn(sheetVariants({ side }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<SheetPrimitive.Close
|
||||
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
17
web/src/lib/components/ui/sheet/sheet-description.svelte
Normal file
17
web/src/lib/components/ui/sheet/sheet-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="sheet-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
web/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
20
web/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sheet-footer"
|
||||
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
20
web/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sheet-header"
|
||||
class={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
web/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
20
web/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="sheet-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/sheet/sheet-portal.svelte
Normal file
7
web/src/lib/components/ui/sheet/sheet-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SheetPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Portal {...restProps} />
|
||||
17
web/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
17
web/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="sheet-title"
|
||||
class={cn("text-foreground font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
web/src/lib/components/ui/sheet/sheet-trigger.svelte
Normal file
7
web/src/lib/components/ui/sheet/sheet-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
|
||||
7
web/src/lib/components/ui/sheet/sheet.svelte
Normal file
7
web/src/lib/components/ui/sheet/sheet.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: SheetPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Root bind:open {...restProps} />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user