mirror of
https://github.com/0x2E/fusion.git
synced 2025-06-08 05:27:15 +09:00
refactor: new ui (#69)
This commit is contained in:
parent
e5a53a1ea3
commit
ab157ad769
149 changed files with 4140 additions and 8457 deletions
|
@ -74,6 +74,6 @@ All configuration items can be found [here](https://github.com/0x2E/fusion/blob/
|
|||
|
||||
## Credits
|
||||
|
||||
- Front-end is built with: [Sveltekit](https://github.com/sveltejs/kit), [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte)
|
||||
- Front-end is built with: [Sveltekit](https://github.com/sveltejs/kit)
|
||||
- Back-end is built with: [Echo](https://github.com/labstack/echo), [GORM](https://github.com/go-gorm/gorm)
|
||||
- Parsing feed with [gofeed](https://github.com/mmcdole/gofeed)
|
||||
|
|
|
@ -3,6 +3,6 @@
|
|||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "gray"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
}
|
4722
frontend/package-lock.json
generated
4722
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -9,46 +9,39 @@
|
|||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"shadcn:update": "npx shadcn-svelte@latest update"
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.17.2",
|
||||
"@sveltejs/kit": "^2.19.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.13",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||
"@typescript-eslint/parser": "^8.24.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.3.2",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
"@typescript-eslint/parser": "^8.26.1",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.46.1",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-svelte": "^3.1.0",
|
||||
"lucide-svelte": "^0.475.0",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"svelte": "^5.20.1",
|
||||
"svelte-check": "^4.1.4",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte": "^5.23.0",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-variants": "^0.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.13",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"vite": "^6.2.1"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"daisyui": "^5.0.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"ky": "^1.7.5",
|
||||
"moment": "^2.30.1",
|
||||
"typescript-eslint": "^8.24.0"
|
||||
"ky": "^1.7.5"
|
||||
}
|
||||
}
|
||||
|
|
2545
frontend/pnpm-lock.yaml
generated
Normal file
2545
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
|
@ -1,75 +1,82 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--primary: 220.9 39.3% 11%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--ring: 224 71.4% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
@plugin "@tailwindcss/typography";
|
||||
@plugin "daisyui";
|
||||
|
||||
.dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--primary: 210 20% 98%;
|
||||
--primary-foreground: 220.9 39.3% 11%;
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
--ring: 216 12.2% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'light';
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
--color-base-100: oklch(100% 0 0);
|
||||
--color-base-200: oklch(98% 0 0);
|
||||
--color-base-300: oklch(95% 0 0);
|
||||
--color-base-content: oklch(21% 0.006 285.885);
|
||||
--color-primary: oklch(42% 0.199 265.638);
|
||||
--color-primary-content: oklch(97% 0.014 254.604);
|
||||
--color-secondary: oklch(51% 0.262 276.966);
|
||||
--color-secondary-content: oklch(94% 0.028 342.258);
|
||||
--color-accent: oklch(64% 0.222 41.116);
|
||||
--color-accent-content: oklch(98% 0.016 73.684);
|
||||
--color-neutral: oklch(27% 0.006 286.033);
|
||||
--color-neutral-content: oklch(96% 0.001 286.375);
|
||||
--color-info: oklch(58% 0.158 241.966);
|
||||
--color-info-content: oklch(97% 0.013 236.62);
|
||||
--color-success: oklch(52% 0.154 150.069);
|
||||
--color-success-content: oklch(98% 0.018 155.826);
|
||||
--color-warning: oklch(76% 0.188 70.08);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(50% 0.213 27.518);
|
||||
--color-error-content: oklch(97% 0.013 17.38);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'dark';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'dark';
|
||||
--color-base-100: oklch(21% 0.006 285.885);
|
||||
--color-base-200: oklch(14% 0.005 285.823);
|
||||
--color-base-300: oklch(0% 0 0);
|
||||
--color-base-content: oklch(87% 0.006 286.286);
|
||||
--color-primary: oklch(37% 0.146 265.522);
|
||||
--color-primary-content: oklch(97% 0.014 254.604);
|
||||
--color-secondary: oklch(45% 0.085 224.283);
|
||||
--color-secondary-content: oklch(98% 0.019 200.873);
|
||||
--color-accent: oklch(67.271% 0.167 35.791);
|
||||
--color-accent-content: oklch(13.454% 0.033 35.791);
|
||||
--color-neutral: oklch(27.441% 0.013 253.041);
|
||||
--color-neutral-content: oklch(85.488% 0.002 253.041);
|
||||
--color-info: oklch(50% 0.134 242.749);
|
||||
--color-info-content: oklch(97% 0.013 236.62);
|
||||
--color-success: oklch(44% 0.119 151.328);
|
||||
--color-success-content: oklch(98% 0.018 155.826);
|
||||
--color-warning: oklch(68% 0.162 75.834);
|
||||
--color-warning-content: oklch(98% 0.026 102.212);
|
||||
--color-error: oklch(50% 0.213 27.518);
|
||||
--color-error-content: oklch(97% 0.013 17.38);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-base-200;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" />
|
||||
|
|
4
frontend/src/lib/api/favicon.ts
Normal file
4
frontend/src/lib/api/favicon.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export function getFavicon(feedLink: string): string {
|
||||
const domain = new URL(feedLink).hostname;
|
||||
return 'https://www.google.com/s2/favicons?sz=32&domain=' + domain;
|
||||
}
|
|
@ -20,7 +20,7 @@ export async function listFeeds(filter?: FeedListFiler) {
|
|||
}
|
||||
|
||||
export async function getFeed(id: number) {
|
||||
return await api.get('feed/' + id).json<Feed>();
|
||||
return await api.get('feeds/' + id).json<Feed>();
|
||||
}
|
||||
|
||||
export async function checkValidity(link: string) {
|
||||
|
@ -33,10 +33,15 @@ export async function checkValidity(link: string) {
|
|||
return resp.feed_links;
|
||||
}
|
||||
|
||||
export async function createFeed(data: {
|
||||
export type FeedCreateForm = {
|
||||
group_id: number;
|
||||
feeds: { name: string; link: string }[];
|
||||
}) {
|
||||
feeds: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export async function createFeed(data: FeedCreateForm) {
|
||||
const feeds = data.feeds.map((v) => {
|
||||
return { name: v.name, link: v.link };
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { defaultPageSize } from '$lib/consts';
|
||||
import type { URL } from 'url';
|
||||
import { api } from './api';
|
||||
import type { Item } from './model';
|
||||
|
@ -26,7 +27,7 @@ export async function listItems(options?: ListFilter) {
|
|||
export function parseURLtoFilter(params: URLSearchParams): ListFilter {
|
||||
const filter: ListFilter = {
|
||||
page: parseInt(params.get('page') || '1'),
|
||||
page_size: parseInt(params.get('page_size') || '10')
|
||||
page_size: parseInt(params.get('page_size') || String(defaultPageSize))
|
||||
};
|
||||
const keyword = params.get('keyword');
|
||||
if (keyword) filter.keyword = keyword;
|
||||
|
|
|
@ -23,5 +23,5 @@ export type Item = {
|
|||
bookmark: boolean;
|
||||
pub_date: Date;
|
||||
updated_at: Date;
|
||||
feed: Pick<Feed, 'id' | 'name'>;
|
||||
feed: Pick<Feed, 'id' | 'name' | 'link'>;
|
||||
};
|
||||
|
|
59
frontend/src/lib/components/ActionSearch.svelte
Normal file
59
frontend/src/lib/components/ActionSearch.svelte
Normal file
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { listItems } from '$lib/api/item';
|
||||
import type { Item } from '$lib/api/model';
|
||||
import { debounce } from '$lib/utils';
|
||||
import { Search } from 'lucide-svelte';
|
||||
import ItemList from './ItemList.svelte';
|
||||
|
||||
let modal = $state<HTMLDialogElement>();
|
||||
let keyword = $state('');
|
||||
let result = $state<{ total: number; items: Item[] } | null>();
|
||||
// let isMac = navigator.platform.indexOf('Mac') === 0 || navigator.platform === 'iPhone';
|
||||
|
||||
const handleSearch = debounce(async () => {
|
||||
const resp = await listItems(); // TODO filter
|
||||
result = resp;
|
||||
}, 500);
|
||||
</script>
|
||||
|
||||
<label class="input input-sm lg:w-80">
|
||||
<Search class="size-4 opacity-50" />
|
||||
<input
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder="Search in title and content"
|
||||
onclick={() => modal?.showModal()}
|
||||
/>
|
||||
<!-- <kbd class="kbd kbd-sm">{isMac ? '⌘' : '^'}</kbd>
|
||||
<kbd class="kbd kbd-sm">K</kbd> -->
|
||||
</label>
|
||||
|
||||
<dialog id="search" bind:this={modal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box min-h-80 w-full overflow-x-hidden sm:max-w-4xl">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">✕</button>
|
||||
</form>
|
||||
<h3 class="text-lg font-bold">Search</h3>
|
||||
<div class="py-4">
|
||||
<label class="input w-full">
|
||||
<Search class="size-4 opacity-50" />
|
||||
<input
|
||||
type="search"
|
||||
required
|
||||
placeholder="Search in title and content"
|
||||
bind:value={keyword}
|
||||
oninput={handleSearch}
|
||||
class="w-full"
|
||||
/>
|
||||
</label>
|
||||
{#if result?.total}
|
||||
<div class="mt-6">
|
||||
<ItemList items={result.items} total={result.total} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
62
frontend/src/lib/components/FeedActionAdd.svelte
Normal file
62
frontend/src/lib/components/FeedActionAdd.svelte
Normal file
|
@ -0,0 +1,62 @@
|
|||
<script module>
|
||||
let show = $state(false);
|
||||
export function toggleShow() {
|
||||
show = !show;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte';
|
||||
import FeedActionAddOne from './FeedActionAddOne.svelte';
|
||||
import FeedActionAddOpml from './FeedActionAddOPML.svelte';
|
||||
|
||||
let modal = $state<HTMLDialogElement>();
|
||||
|
||||
const tabs: { id: string; name: string; component: Component<any> }[] = [
|
||||
{ id: 'manually', name: 'Manually', component: FeedActionAddOne },
|
||||
{ id: 'import_opml', name: 'Import OPML', component: FeedActionAddOpml }
|
||||
];
|
||||
|
||||
let selectedTabID = $state(tabs[0].id);
|
||||
let selectedTab = $derived(tabs.find((v) => v.id === selectedTabID) || tabs[0]);
|
||||
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
modal?.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
function doneCallback() {
|
||||
modal?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal} onclose={() => (show = false)} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">✕</button>
|
||||
</form>
|
||||
<h3 class="text-lg font-bold">Add Feed(s)</h3>
|
||||
<div class="tabs tabs-box tabs-sm mt-2 w-fit">
|
||||
{#each tabs as tab}
|
||||
<input
|
||||
type="radio"
|
||||
name="add_feeds"
|
||||
class="tab text-xs font-medium"
|
||||
aria-label={tab.name}
|
||||
value={tab.id}
|
||||
bind:group={selectedTabID}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if show}
|
||||
<!-- used to destroy and recreate component to avoid resetting form manually -->
|
||||
<div>
|
||||
<selectedTab.component {doneCallback} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
161
frontend/src/lib/components/FeedActionAddOPML.svelte
Normal file
161
frontend/src/lib/components/FeedActionAddOPML.svelte
Normal file
|
@ -0,0 +1,161 @@
|
|||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { createFeed } from '$lib/api/feed';
|
||||
import { allGroups, createGroup } from '$lib/api/group';
|
||||
import type { Group } from '$lib/api/model';
|
||||
import { parse } from '$lib/opml';
|
||||
import { Folder } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
doneCallback: () => void;
|
||||
}
|
||||
|
||||
let { doneCallback }: Props = $props();
|
||||
let importing = $state(false);
|
||||
let importLog = $state<{ content: string; isError?: boolean }[]>([]);
|
||||
let parsedGroupFeeds: { name: string; feeds: { name: string; link: string }[] }[] = $state([]);
|
||||
let uploadedOpmls = $state<FileList>();
|
||||
|
||||
let groups: Group[] = $state([]);
|
||||
onMount(async () => {
|
||||
const resp = await allGroups();
|
||||
groups = resp;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (uploadedOpmls) {
|
||||
importLog = [];
|
||||
parseOPML(uploadedOpmls);
|
||||
}
|
||||
});
|
||||
|
||||
function parseOPML(opmls: FileList) {
|
||||
if (!opmls) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (f) => {
|
||||
const content = f.target?.result?.toString();
|
||||
if (!content) {
|
||||
toast.error('Failed to load file content');
|
||||
return;
|
||||
}
|
||||
parsedGroupFeeds = parse(content).filter((v) => v.feeds.length > 0);
|
||||
console.log(parsedGroupFeeds);
|
||||
};
|
||||
reader.readAsText(opmls[0]);
|
||||
}
|
||||
|
||||
async function handleImportFeeds(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
importing = true;
|
||||
const existingGroups = groups.map((v) => {
|
||||
return { id: v.id, name: v.name };
|
||||
});
|
||||
for (const g of parsedGroupFeeds) {
|
||||
let groupID = existingGroups.find((v) => v.name === g.name)?.id;
|
||||
|
||||
if (groupID === undefined) {
|
||||
try {
|
||||
groupID = (await createGroup(g.name)).id;
|
||||
importLog.push({ content: `Created group ${g.name}` });
|
||||
} catch (e) {
|
||||
importLog.push({
|
||||
content: `Failed to create group ${g.name}. error: ${(e as Error).message}`,
|
||||
isError: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await createFeed({ group_id: groupID, feeds: g.feeds });
|
||||
g.feeds.forEach((f) => importLog.push({ content: `Imported ${f.link}` }));
|
||||
} catch (e) {
|
||||
g.feeds.forEach((f) =>
|
||||
importLog.push({
|
||||
content: `Failed to import ${g.name}. error: ${(e as Error).message}`,
|
||||
isError: true
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
importing = false;
|
||||
if (!importLog.find((v) => v.isError)) {
|
||||
toast.success('Imported successfully');
|
||||
doneCallback();
|
||||
}
|
||||
invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleImportFeeds} class="flex flex-col">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Pick a OPML file</legend>
|
||||
<input
|
||||
type="file"
|
||||
bind:files={uploadedOpmls}
|
||||
accept=".opml,.xml,.txt"
|
||||
required
|
||||
class="file-input"
|
||||
/>
|
||||
<p class="fieldset-label">
|
||||
The file should be <a
|
||||
href="http://opml.org/spec2.opml"
|
||||
target="_blank"
|
||||
class="font-medium underline">OPML</a
|
||||
> format. You can get one from your previous RSS reader.
|
||||
</p>
|
||||
</fieldset>
|
||||
<details>
|
||||
<summary class="text-base-content/60 text-sm font-medium"> How it works? </summary>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
<ul class="list-inside list-disc">
|
||||
<li>
|
||||
Feeds will be imported into the corresponding group, which will be created automatically
|
||||
if it does not exist.
|
||||
</li>
|
||||
<li>
|
||||
Multidimensional group will be flattened to a one-dimensional structure, using a naming
|
||||
convention like 'a/b/c'.
|
||||
</li>
|
||||
<li>The existing feed with the same link will be overridden.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
{#if parsedGroupFeeds.length > 0}
|
||||
<div>
|
||||
<p class="text-success text-sm">Parsed successfully.</p>
|
||||
<div class="bg-base-200 overflow-x-auto rounded-md p-2 text-sm text-nowrap">
|
||||
{#each parsedGroupFeeds as group}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Folder size={14} />{group.name}
|
||||
</div>
|
||||
<ul class="ml-[2ch] list-inside list-decimal [&:not(:last-child)]:mb-2">
|
||||
{#each group.feeds as feed}
|
||||
<li>
|
||||
{feed.name} (<a href={feed.link} target="_blank" class="text-blue-600"
|
||||
>{feed.link}</a
|
||||
>)
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
</div>
|
||||
<ul class="mt-2 list-inside list-disc">
|
||||
{#each importLog as log}
|
||||
<li class={log.isError ? 'text-error' : ''}>{log.content}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={importing} class="btn btn-primary mt-4 ml-auto">
|
||||
{#if importing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
<span>Submit</span>
|
||||
</button>
|
||||
</form>
|
131
frontend/src/lib/components/FeedActionAddOne.svelte
Normal file
131
frontend/src/lib/components/FeedActionAddOne.svelte
Normal file
|
@ -0,0 +1,131 @@
|
|||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { checkValidity, createFeed, type FeedCreateForm } from '$lib/api/feed';
|
||||
import { allGroups } from '$lib/api/group';
|
||||
import type { Group } from '$lib/api/model';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
doneCallback: () => void;
|
||||
}
|
||||
|
||||
let { doneCallback }: Props = $props();
|
||||
|
||||
let step = $state(1);
|
||||
let form = $state<FeedCreateForm>({ group_id: 1, feeds: [{ name: '', link: '' }] });
|
||||
let loading = $state(false);
|
||||
let linkCandidate: { title: string; link: string }[] = $state([]);
|
||||
let groups: Group[] = $state([]);
|
||||
onMount(async () => {
|
||||
const resp = await allGroups();
|
||||
groups = resp;
|
||||
});
|
||||
|
||||
// const fakeCandidates = [
|
||||
// { title: 'test1', link: 'https://test1/1.xml' },
|
||||
// { title: 'test2', link: 'https://test2/2.xml' }
|
||||
// ];
|
||||
|
||||
async function handleAdd() {
|
||||
loading = true;
|
||||
toast.promise(checkValidity(form.feeds[0].link), {
|
||||
loading: 'Waiting for validating and sniffing ' + form.feeds[0].link,
|
||||
success: (resp) => {
|
||||
loading = false;
|
||||
if (resp.length < 1) {
|
||||
throw new Error(
|
||||
`No valid links were found for the RSS. Please check the link, or submit an RSS link directly`
|
||||
);
|
||||
}
|
||||
if (resp.length === 1) {
|
||||
if (!form.feeds[0].name) {
|
||||
form.feeds[0].name = resp[0].title;
|
||||
}
|
||||
form.feeds[0].link = resp[0].link;
|
||||
handleContinue();
|
||||
} else if (resp.length > 1) {
|
||||
linkCandidate = resp;
|
||||
step = 2;
|
||||
}
|
||||
return form.feeds[0].link + ' is valid';
|
||||
},
|
||||
error: (error) => {
|
||||
loading = false;
|
||||
return `Failed to validate ${form.feeds[0].link}: ${error}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
if (!form.feeds[0].name) {
|
||||
form.feeds[0].name = new URL(form.feeds[0].link).hostname;
|
||||
}
|
||||
try {
|
||||
await createFeed(form);
|
||||
toast.success('Feed has been created. Refreshing is running in the background');
|
||||
doneCallback();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
invalidateAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if step === 1}
|
||||
<form onsubmit={handleAdd} class="flex flex-col">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Link</legend>
|
||||
<input type="url" class="input w-full" bind:value={form.feeds[0].link} required />
|
||||
<p class="fieldset-label">
|
||||
Either the RSS link or the website link. The server will automatically attempt to locate the
|
||||
RSS feed.
|
||||
</p>
|
||||
<p class="fieldset-label">The existing feed with the same link will be overridden.</p>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Name</legend>
|
||||
<input type="text" class="input w-full" bind:value={form.feeds[0].name} />
|
||||
<p class="fieldset-label">Optional. Leave blank for automatic naming.</p>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Group</legend>
|
||||
<select class="select w-full" bind:value={form.group_id} required>
|
||||
{#each groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</fieldset>
|
||||
<button type="submit" disabled={loading} class="btn btn-primary ml-auto mt-2">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
<span> Submit </span>
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form onsubmit={handleContinue} class="flex flex-col">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Select a link</legend>
|
||||
{#each linkCandidate as l, index}
|
||||
<label class="fieldset-label">
|
||||
<input
|
||||
type="radio"
|
||||
id={String(index)}
|
||||
name="feed_link"
|
||||
value={l.link}
|
||||
onchange={() => {
|
||||
if (!form.feeds[0].name) {
|
||||
form.feeds[0].name = l.title;
|
||||
}
|
||||
form.feeds[0].link = l.link;
|
||||
}}
|
||||
class="radio radio-sm"
|
||||
/>
|
||||
{l.title} ({l.link})
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
<button type="submit" class="btn btn-primary ml-auto mt-4">Confirm</button>
|
||||
</form>
|
||||
{/if}
|
44
frontend/src/lib/components/FeedActionRefresh.svelte
Normal file
44
frontend/src/lib/components/FeedActionRefresh.svelte
Normal file
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { refreshFeeds } from '$lib/api/feed';
|
||||
import type { Feed } from '$lib/api/model';
|
||||
import { RefreshCcw } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
feed?: Feed;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
let { feed, all }: Props = $props();
|
||||
|
||||
async function handleRefresh() {
|
||||
if (all) {
|
||||
if (!confirm('Are you sure you want to refresh all feeds except the suspended ones?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast.promise(refreshFeeds({ id: feed?.id, all: all }), {
|
||||
success: () => {
|
||||
invalidateAll();
|
||||
if (all) {
|
||||
return 'Start refreshing in the background';
|
||||
}
|
||||
return 'Refresh successfully';
|
||||
},
|
||||
error: (e) => {
|
||||
invalidateAll();
|
||||
console.log(e);
|
||||
return String(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let tooltip = $derived(all ? 'Refresh Feeds' : 'Refresh Feed');
|
||||
</script>
|
||||
|
||||
<div class="tooltip tooltip-bottom" data-tip={tooltip}>
|
||||
<button onclick={handleRefresh} class="btn btn-ghost btn-square">
|
||||
<RefreshCcw class="size-4" />
|
||||
</button>
|
||||
</div>
|
|
@ -1,91 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Feed } from '$lib/api/model';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
|
||||
import { tick } from 'svelte';
|
||||
import { Button, buttonVariants } from './ui/button';
|
||||
|
||||
interface Props {
|
||||
data: Feed[];
|
||||
selected: number | undefined;
|
||||
onSelectedChange: (selected: number | undefined) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { data, selected, onSelectedChange, className = '' }: Props = $props();
|
||||
let open = $state(false);
|
||||
|
||||
let optionAll = { value: '-1', label: 'All' };
|
||||
let feeds = data
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((f) => {
|
||||
return { value: String(f.id), label: f.name };
|
||||
});
|
||||
feeds.unshift(optionAll);
|
||||
|
||||
let triggerRef = $state<HTMLButtonElement>(null!);
|
||||
|
||||
// We want to refocus the trigger button when the user selects
|
||||
// an item from the list so users can continue navigating the
|
||||
// rest of the form with the keyboard.
|
||||
function closeAndFocusTrigger() {
|
||||
open = false;
|
||||
tick().then(() => {
|
||||
triggerRef.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
bind:ref={triggerRef}
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'w-[200px] justify-between overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{feeds.find((f) => f.value === String(selected))?.label || 'Select a feed...'}
|
||||
<ChevronsUpDown class="opacity-50" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[200px] p-0">
|
||||
<Command.Root
|
||||
filter={(value, search) => {
|
||||
let name = '';
|
||||
if (value === optionAll.value) {
|
||||
name = optionAll.label;
|
||||
} else {
|
||||
name = data.find((v) => v.id === parseInt(value))?.name ?? '';
|
||||
}
|
||||
return name.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
|
||||
}}
|
||||
>
|
||||
<Command.Input placeholder="Search feed..." class="h-9" />
|
||||
<Command.List>
|
||||
<Command.Empty>No feed found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each feeds as feed}
|
||||
<Command.Item
|
||||
value={feed.value}
|
||||
onSelect={() => {
|
||||
const id = parseInt(feed.value);
|
||||
if (id === -1) {
|
||||
onSelectedChange(undefined);
|
||||
} else {
|
||||
onSelectedChange(id);
|
||||
}
|
||||
closeAndFocusTrigger();
|
||||
}}
|
||||
>
|
||||
<Check class={cn(String(selected) !== feed.value && 'text-transparent')} />
|
||||
{feed.label}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
|
@ -1,18 +0,0 @@
|
|||
<script lang="ts">
|
||||
const version = import.meta.env.FUSION.version;
|
||||
</script>
|
||||
|
||||
<footer class="mt-8 py-3 bg-muted text-muted-foreground text-sm">
|
||||
<div class="flex justify-between max-w-[900px] mx-auto px-4">
|
||||
<span>
|
||||
Version <a href="https://github.com/0x2e/fusion" target="_blank" class="underline">
|
||||
{version}</a
|
||||
>.
|
||||
</span>
|
||||
<span>
|
||||
Icon by <a href="https://icons8.com/icon/FeQbTvGTsiN5/news" target="_blank" class="underline"
|
||||
>Icons8</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
|
@ -1,40 +0,0 @@
|
|||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Icon as IconType } from 'lucide-svelte';
|
||||
import { buttonVariants } from './ui/button';
|
||||
|
||||
interface Props {
|
||||
fn: (e: Event) => void;
|
||||
tooltip?: string;
|
||||
icon: typeof IconType;
|
||||
iconSize?: number;
|
||||
buttonClass?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
fn,
|
||||
tooltip = '',
|
||||
icon: Icon,
|
||||
iconSize = 18,
|
||||
buttonClass = '',
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger
|
||||
onclick={fn}
|
||||
aria-label={tooltip}
|
||||
{disabled}
|
||||
class={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), 'rounded-full', buttonClass)}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{tooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
|
@ -4,15 +4,12 @@
|
|||
import type { Item } from '$lib/api/model';
|
||||
import { BookmarkIcon, BookmarkXIcon } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ItemActionBase from './ItemActionBase.svelte';
|
||||
|
||||
interface Props {
|
||||
data: Item;
|
||||
buttonClass?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
let { data, buttonClass = '', iconSize = 18 }: Props = $props();
|
||||
let { data }: Props = $props();
|
||||
|
||||
async function toggleBookmark(e: Event) {
|
||||
e.preventDefault();
|
||||
|
@ -23,8 +20,12 @@
|
|||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
let icon = $derived(data.bookmark ? BookmarkXIcon : BookmarkIcon);
|
||||
let Icon = $derived(data.bookmark ? BookmarkXIcon : BookmarkIcon);
|
||||
let tooltip = $derived(data.bookmark ? 'Cancel Bookmark' : 'Add to Bookmark');
|
||||
</script>
|
||||
|
||||
<ItemActionBase fn={toggleBookmark} {tooltip} {buttonClass} {icon} {iconSize} />
|
||||
<div class="tooltip tooltip-bottom" data-tip={tooltip}>
|
||||
<button onclick={toggleBookmark} aria-label={tooltip} class="btn btn-ghost btn-square">
|
||||
<Icon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { Item } from '$lib/api/model';
|
||||
import { ArrowUpIcon } from 'lucide-svelte';
|
||||
import ItemActionBase from './ItemActionBase.svelte';
|
||||
import ItemActionBookmark from './ItemActionBookmark.svelte';
|
||||
import ItemActionNavigate from './ItemActionNavigate.svelte';
|
||||
import ItemActionUnread from './ItemActionUnread.svelte';
|
||||
import ItemActionVisitLink from './ItemActionVisitLink.svelte';
|
||||
import { Separator } from './ui/separator';
|
||||
|
||||
interface Props {
|
||||
data: Item;
|
||||
fixed?: boolean;
|
||||
}
|
||||
|
||||
let { data, fixed = true }: Props = $props();
|
||||
|
||||
function handleScrollTop(e: Event) {
|
||||
e.preventDefault();
|
||||
document.body.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="{fixed ? 'fixed' : ''} bottom-2 left-0 right-0">
|
||||
<div
|
||||
class="flex flex-row justify-center items-center gap-2 rounded-full border shadow w-fit mx-auto bg-background px-6 py-2"
|
||||
>
|
||||
<ItemActionUnread {data} />
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<ItemActionBookmark {data} />
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<ItemActionVisitLink {data} />
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<ItemActionBase fn={handleScrollTop} tooltip="Back to Top" icon={ArrowUpIcon} />
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<ItemActionNavigate {data} />
|
||||
</div>
|
||||
</div>
|
16
frontend/src/lib/components/ItemActionGotoFeed.svelte
Normal file
16
frontend/src/lib/components/ItemActionGotoFeed.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { Item } from '$lib/api/model';
|
||||
import { Newspaper } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
data: Item;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="tooltip tooltip-bottom" data-tip={'Go to Feed'}>
|
||||
<a href={'/feeds/' + data.feed.id} class="btn btn-ghost btn-square">
|
||||
<Newspaper class="size-4" />
|
||||
</a>
|
||||
</div>
|
30
frontend/src/lib/components/ItemActionMarkAllasRead.svelte
Normal file
30
frontend/src/lib/components/ItemActionMarkAllasRead.svelte
Normal file
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { updateUnread } from '$lib/api/item';
|
||||
import type { Item } from '$lib/api/model';
|
||||
import { CheckCheck } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
let { items }: Props = $props();
|
||||
|
||||
async function handleMarkAllAsRead() {
|
||||
try {
|
||||
const ids = items.map((v) => v.id);
|
||||
await updateUnread(ids, false);
|
||||
toast.success('Update successfully');
|
||||
invalidateAll();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tooltip tooltip-bottom" data-tip="Mark All as Read">
|
||||
<button onclick={handleMarkAllAsRead} class="btn btn-ghost btn-square">
|
||||
<CheckCheck class="size-4" />
|
||||
</button>
|
||||
</div>
|
|
@ -4,15 +4,12 @@
|
|||
import type { Item } from '$lib/api/model';
|
||||
import { CheckIcon, UndoIcon } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ItemActionBase from './ItemActionBase.svelte';
|
||||
|
||||
interface Props {
|
||||
data: Item;
|
||||
buttonClass?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
let { data, buttonClass = '', iconSize = 18 }: Props = $props();
|
||||
let { data }: Props = $props();
|
||||
|
||||
async function toggleUnread(e: Event) {
|
||||
e.preventDefault();
|
||||
|
@ -23,8 +20,12 @@
|
|||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
let icon = $derived(data.unread ? CheckIcon : UndoIcon);
|
||||
let Icon = $derived(data.unread ? CheckIcon : UndoIcon);
|
||||
let tooltip = $derived(data.unread ? 'Mark as Read' : 'Mark as Unread');
|
||||
</script>
|
||||
|
||||
<ItemActionBase fn={toggleUnread} {tooltip} {buttonClass} {icon} {iconSize} />
|
||||
<div class="tooltip tooltip-bottom" data-tip={tooltip}>
|
||||
<button onclick={toggleUnread} class="btn btn-ghost btn-square">
|
||||
<Icon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1,22 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { Item } from '$lib/api/model';
|
||||
import { ExternalLinkIcon } from 'lucide-svelte';
|
||||
import ItemActionBase from './ItemActionBase.svelte';
|
||||
import { ExternalLink } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
data: Item;
|
||||
buttonClass?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
let { data, buttonClass = '', iconSize = 18 }: Props = $props();
|
||||
|
||||
function visitLink(e: Event) {
|
||||
e.preventDefault();
|
||||
window.open(data.link, '_blank');
|
||||
}
|
||||
const icon = ExternalLinkIcon;
|
||||
const tooltip = 'Visit Original Link';
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ItemActionBase fn={visitLink} {tooltip} {buttonClass} {icon} {iconSize} />
|
||||
<div class="tooltip tooltip-bottom" data-tip={'Visit Original Link'}>
|
||||
<a href={data.link} target="_blank" class="btn btn-ghost btn-square">
|
||||
<ExternalLink class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -1,57 +1,26 @@
|
|||
<script lang="ts">
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { applyFilterToURL, parseURLtoFilter, updateUnread } from '$lib/api/item';
|
||||
import type { Feed, Item } from '$lib/api/model';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { cn, debounce } from '$lib/utils';
|
||||
import { CheckCheck, type Icon as IconType } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import FeedsSelect from './FeedsSelect.svelte';
|
||||
import { getFavicon } from '$lib/api/favicon';
|
||||
import { applyFilterToURL, parseURLtoFilter } from '$lib/api/item';
|
||||
import type { Item } from '$lib/api/model';
|
||||
import ItemActionBookmark from './ItemActionBookmark.svelte';
|
||||
import ItemActionUnread from './ItemActionUnread.svelte';
|
||||
import ItemActionVisitLink from './ItemActionVisitLink.svelte';
|
||||
import { Button, buttonVariants } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import Pagination from './Pagination.svelte';
|
||||
|
||||
interface Props {
|
||||
data: { feeds: Feed[]; items: { total: number; data: Item[] } };
|
||||
total: number;
|
||||
items: Item[];
|
||||
highlightUnread?: boolean;
|
||||
}
|
||||
let { data }: Props = $props();
|
||||
let { items, total, highlightUnread }: Props = $props();
|
||||
|
||||
let filter = $state(parseURLtoFilter(page.url.searchParams));
|
||||
function applyFilter() {
|
||||
console.log(`filter reactive updates:\nnew: ${JSON.stringify(filter)}`);
|
||||
|
||||
const url = page.url;
|
||||
applyFilterToURL(url, filter);
|
||||
goto(url, { invalidateAll: true });
|
||||
}
|
||||
|
||||
async function handleMarkAllAsRead() {
|
||||
try {
|
||||
const ids = data.items.data.map((v) => v.id);
|
||||
await updateUnread(ids, false);
|
||||
toast.success('Update successfully');
|
||||
invalidateAll();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchInput = debounce(function (e: Event) {
|
||||
if (e.target instanceof HTMLInputElement) {
|
||||
filter.keyword = e.target.value;
|
||||
filter.page = 1;
|
||||
applyFilter();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function fromNow(d: Date) {
|
||||
function timeDiff(d: Date) {
|
||||
d = new Date(d);
|
||||
const now = new Date();
|
||||
if (d.getTime() > now.getTime()) {
|
||||
return 'now';
|
||||
}
|
||||
const hours = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60));
|
||||
const days = Math.floor(hours / 24);
|
||||
const months = Math.floor(days / 30);
|
||||
|
@ -63,144 +32,68 @@
|
|||
return '?';
|
||||
}
|
||||
|
||||
const actions: { icon: typeof IconType; tooltip: string; handler: () => void }[] = [
|
||||
{ icon: CheckCheck, tooltip: 'Mark as Read', handler: handleMarkAllAsRead }
|
||||
];
|
||||
let filter = $derived(parseURLtoFilter(page.url.searchParams));
|
||||
async function handleChangePage(pageNumber: number) {
|
||||
filter.page = pageNumber;
|
||||
const url = page.url;
|
||||
applyFilterToURL(url, filter);
|
||||
await goto(url, { invalidateAll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-center w-full gap-2">
|
||||
<div class="flex flex-col md:flex-row gap-2">
|
||||
<FeedsSelect
|
||||
data={data.feeds}
|
||||
selected={filter.feed_id}
|
||||
onSelectedChange={(id: number | undefined) => {
|
||||
filter.feed_id = id;
|
||||
filter.page = 1;
|
||||
applyFilter();
|
||||
}}
|
||||
className="w-full md:w-[200px]"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search in title and content..."
|
||||
value={filter.keyword}
|
||||
oninput={handleSearchInput}
|
||||
class="w-full md:w-[400px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if data.items.data.length > 0}
|
||||
<div>
|
||||
{#each actions as action}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger
|
||||
onclick={action.handler}
|
||||
class={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'w-full md:w-[40px]')}
|
||||
<div>
|
||||
<ul data-sveltekit-preload-data="hover">
|
||||
{#each items as item}
|
||||
<li class="group rounded-md">
|
||||
<a
|
||||
href={'/items/' + item.id}
|
||||
class="hover:bg-base-300 relative flex w-full flex-col items-center justify-between space-x-2 space-y-1 rounded-md px-2 py-2 transition-colors md:flex-row"
|
||||
>
|
||||
<div class="flex w-full md:w-[80%] md:shrink-0">
|
||||
<h2
|
||||
class={`line-clamp-2 w-full truncate font-medium md:line-clamp-1 ${highlightUnread && !item.unread ? 'text-base-content/60' : ''}`}
|
||||
>
|
||||
<action.icon size="20" />
|
||||
<span class="ml-1 md:hidden">{action.tooltip}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{action.tooltip}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ul data-sveltekit-preload-data="hover" class="mt-4">
|
||||
{#each data.items.data as item}
|
||||
<li class="group rounded-md">
|
||||
<Button
|
||||
href={'/items?id=' + item.id}
|
||||
class="flex justify-between items-center gap-2 py-6"
|
||||
variant="ghost"
|
||||
>
|
||||
<h2 class="truncate text-lg font-medium">
|
||||
{item.title}
|
||||
</h2>
|
||||
<div class="flex justify-between items-center flex-shrink-0 w-1/3 md:w-1/4">
|
||||
{item.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex w-full md:grow">
|
||||
<div
|
||||
class="text-base-content/60 flex w-full justify-between gap-2 text-xs font-normal group-hover:hidden"
|
||||
>
|
||||
<div class="flex grow items-center space-x-2 overflow-x-hidden">
|
||||
<div class="avatar">
|
||||
<div class="size-4 rounded-full">
|
||||
<img src={getFavicon(item.feed.link)} alt={item.feed.name} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="line-clamp-1">
|
||||
{item.feed.name}
|
||||
</span>
|
||||
</div>
|
||||
<span class="w-[4ch] shrink-0 truncate text-right">
|
||||
{timeDiff(item.pub_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-end w-full gap-2 text-sm text-muted-foreground group-hover:hidden"
|
||||
class="invisible absolute right-1 w-fit justify-end gap-2 md:group-hover:visible md:group-hover:flex"
|
||||
>
|
||||
<span class="w-full truncate">{item.feed.name}</span>
|
||||
<span class="w-[5ch] truncate">
|
||||
{fromNow(item.pub_date)}
|
||||
</span>
|
||||
<ItemActionUnread data={item} />
|
||||
<ItemActionBookmark data={item} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{:else}
|
||||
Nothing here.
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="w-full hidden group-hover:inline-flex justify-end">
|
||||
<ItemActionUnread data={item} buttonClass="hover:bg-gray-300 dark:hover:bg-gray-700" />
|
||||
<ItemActionBookmark
|
||||
data={item}
|
||||
buttonClass="hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
/>
|
||||
<ItemActionVisitLink
|
||||
data={item}
|
||||
buttonClass="hover:bg-gray-300 dark:hover:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</li>
|
||||
{:else}
|
||||
No data
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if data.items.total > 1}
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center mt-8 gap-2">
|
||||
<Pagination.Root
|
||||
count={data.items.total}
|
||||
perPage={filter.page_size}
|
||||
page={filter.page}
|
||||
onPageChange={(p) => {
|
||||
filter.page = p;
|
||||
applyFilter();
|
||||
}}
|
||||
>
|
||||
{#snippet children({ pages, currentPage })}
|
||||
<Pagination.Content class="flex-wrap">
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item isVisible={currentPage === page.value}>
|
||||
<Pagination.Link {page} isActive={currentPage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={String(filter.page_size)}
|
||||
onValueChange={(v) => {
|
||||
filter.page_size = parseInt(v) || 10;
|
||||
applyFilter();
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-[110px]">Page Size</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each [10, 25, 50, 100, 200, 500] as size}
|
||||
<Select.Item value={String(size)}>{size}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="mt-6 flex w-full justify-center">
|
||||
<Pagination
|
||||
currentPage={filter.page}
|
||||
pageSize={filter.page_size}
|
||||
{total}
|
||||
onPageChange={handleChangePage}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -1,129 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { logout } from '$lib/api/login';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { LogOutIcon, MenuIcon, XIcon } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import ThemeToggler from './ThemeToggler.svelte';
|
||||
|
||||
interface link {
|
||||
label: string;
|
||||
url: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
let links: link[] = $state([
|
||||
{ label: 'Unread', url: '/' },
|
||||
{ label: 'Bookmark', url: '/bookmarks' },
|
||||
{ label: 'All', url: '/all' },
|
||||
{ label: 'Feeds', url: '/feeds' }
|
||||
]);
|
||||
let showMenu = $state(false);
|
||||
|
||||
let bodyOverflowDefault = document.body.style.overflow;
|
||||
$effect(() => {
|
||||
document.body.style.overflow = showMenu ? 'hidden' : bodyOverflowDefault;
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logout();
|
||||
toast.success('Bye');
|
||||
goto('/login');
|
||||
} catch {
|
||||
toast.error('Failed to logout.');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
let path = page.url.pathname;
|
||||
for (const l of links) {
|
||||
l.highlight = false;
|
||||
let p = path.split('/');
|
||||
while (p.length > 1) {
|
||||
if (p.join('/') === l.url) {
|
||||
l.highlight = true;
|
||||
break;
|
||||
}
|
||||
p.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav class="block w-full sm:mt-3 mb-6">
|
||||
<div class="flex flex-col items-center w-full sm:max-w-[500px] sm:mx-auto bg-background">
|
||||
<div
|
||||
class="flex justify-between sm:justify-around w-full px-4 sm:px-6 py-2 sm:py-4 sm:rounded-2xl shadow sm:border"
|
||||
>
|
||||
<a href="/">
|
||||
<img src="/icon-96.png" alt="icon" class="w-10" />
|
||||
</a>
|
||||
<div class="hidden sm:block">
|
||||
{#each links as l}
|
||||
<Button
|
||||
variant="ghost"
|
||||
href={l.url}
|
||||
class={l.highlight ? 'bg-accent text-accent-foreground' : ''}
|
||||
>
|
||||
{l.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<ThemeToggler className="hidden sm:flex" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => {
|
||||
handleLogout();
|
||||
showMenu = false;
|
||||
}}
|
||||
class="hidden sm:flex"
|
||||
>
|
||||
<LogOutIcon class="h-[1rem] w-[1rem]" />
|
||||
<span class="sr-only">Logout</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
class="flex sm:hidden"
|
||||
>
|
||||
{#if showMenu}
|
||||
<XIcon />
|
||||
{:else}
|
||||
<MenuIcon />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`${showMenu ? 'opacity-100 visible' : 'opacity-0 invisible'} sm:hidden w-full h-[calc(100dvh)] z-50 fixed top-0 pt-14 pointer-events-none transition-all duration-300 origin-center overflow-y-auto`}
|
||||
>
|
||||
<div class="flex flex-col w-full h-full bg-background pointer-events-auto pt-6 pb-10">
|
||||
{#each links as l}
|
||||
<Button
|
||||
variant="ghost"
|
||||
href={l.url}
|
||||
onclick={() => (showMenu = false)}
|
||||
class={`w-full text-lg h-14 ${l.highlight ? 'bg-accent text-accent-foreground' : ''}`}
|
||||
>
|
||||
{l.label}
|
||||
</Button>
|
||||
{/each}
|
||||
<ThemeToggler className="w-full h-14" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
handleLogout();
|
||||
showMenu = false;
|
||||
}}
|
||||
class="w-[50%] text-lg h-14 mt-auto mx-auto text-destructive"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
|
@ -1,14 +0,0 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
className?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { title, className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center w-full {className}">
|
||||
<h1 class="text-3xl font-bold mb-4 ml-4">{title}</h1>
|
||||
{@render children?.()}
|
||||
</div>
|
31
frontend/src/lib/components/PageNavHeader.svelte
Normal file
31
frontend/src/lib/components/PageNavHeader.svelte
Normal file
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import ActionSearch from './ActionSearch.svelte';
|
||||
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
showSearch?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { title, children, showSearch }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="bg-base-100 border-base-300 sticky top-0 z-50 border-b py-2">
|
||||
<div class="flex flex-col justify-between px-1 md:px-4 lg:flex-row lg:items-center lg:px-8">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label for="sidebar-toggle" class="btn btn-ghost btn-square lg:hidden">
|
||||
<Menu class="size-4" />
|
||||
</label>
|
||||
{#if showSearch}
|
||||
<ActionSearch />
|
||||
{:else if title}
|
||||
<span class="text-base-content/60 text-sm">{title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
71
frontend/src/lib/components/Pagination.svelte
Normal file
71
frontend/src/lib/components/Pagination.svelte
Normal file
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
currentPage?: number;
|
||||
total: number;
|
||||
pageSize?: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
let { currentPage = 1, total, pageSize = 10, onPageChange }: Props = $props();
|
||||
|
||||
let totalPages = $derived(Math.ceil(total / pageSize));
|
||||
let pages = $derived(getPageNumbers(currentPage, totalPages));
|
||||
|
||||
function getPageNumbers(current: number, total: number) {
|
||||
const pages: (number | string)[] = [];
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
pages.push(1);
|
||||
if (current > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
pages.push(total);
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
onPageChange(page);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if pages.length > 1}
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => handlePageChange(currentPage - 1)}>«</button
|
||||
>
|
||||
{#each pages as page}
|
||||
{#if typeof page === 'string'}
|
||||
<button class="join-item btn" disabled>...</button>
|
||||
{:else}
|
||||
<button
|
||||
class={`join-item btn ${page === currentPage ? 'btn-active border-b-base-content/60 border-b-2' : ''}`}
|
||||
onclick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
class="join-item btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => handlePageChange(currentPage + 1)}>»</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
156
frontend/src/lib/components/Sidebar.svelte
Normal file
156
frontend/src/lib/components/Sidebar.svelte
Normal file
|
@ -0,0 +1,156 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getFavicon } from '$lib/api/favicon';
|
||||
import { logout } from '$lib/api/login';
|
||||
import type { Feed, Group } from '$lib/api/model';
|
||||
import {
|
||||
BookmarkCheck,
|
||||
CirclePlus,
|
||||
Inbox,
|
||||
List,
|
||||
LogOut,
|
||||
Settings,
|
||||
type Icon
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { toggleShow } from './FeedActionAdd.svelte';
|
||||
import ThemeController from './ThemeController.svelte';
|
||||
|
||||
interface Props {
|
||||
feeds: Feed[];
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
let { feeds, groups }: Props = $props();
|
||||
|
||||
const version = import.meta.env.FUSION.version;
|
||||
|
||||
type SystemNavLink = {
|
||||
label: string;
|
||||
url: string;
|
||||
icon: typeof Icon;
|
||||
};
|
||||
const systemLinks: SystemNavLink[] = [
|
||||
{ label: 'Unread', url: '/', icon: Inbox },
|
||||
{ label: 'Bookmark', url: '/bookmarks', icon: BookmarkCheck },
|
||||
{ label: 'All', url: '/all', icon: List },
|
||||
{ label: 'Settings', url: '/settings', icon: Settings }
|
||||
];
|
||||
|
||||
function isHighlight(url: string): boolean {
|
||||
let chunks = page.url.pathname.split('/');
|
||||
while (chunks.length > 1) {
|
||||
if (chunks.join('/') === url) {
|
||||
return true;
|
||||
}
|
||||
chunks.pop();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (!confirm('Are you sure you want to log out?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await logout();
|
||||
toast.success('Bye');
|
||||
await goto('/login');
|
||||
} catch {
|
||||
toast.error('Failed to logout.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<a
|
||||
href="https://github.com/0x2E/fusion"
|
||||
target="_blank"
|
||||
class="btn btn-ghost hover:bg-base-content/10 flex items-center justify-start gap-2"
|
||||
>
|
||||
<img src="/icon-96.png" alt="icon" class="w-6" />
|
||||
<span class="text-lg font-bold">Fusion</span>
|
||||
</a>
|
||||
<ThemeController />
|
||||
</div>
|
||||
|
||||
<ul class="menu mt-4 w-full font-medium">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => {
|
||||
toggleShow();
|
||||
}}
|
||||
class="btn btn-sm bg-base-100"
|
||||
>
|
||||
<CirclePlus class="size-4" />
|
||||
<span>Add Feeds</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="menu w-full font-medium">
|
||||
{#each systemLinks as v}
|
||||
<li>
|
||||
<a href={v.url} class={isHighlight(v.url) ? 'menu-active' : ''}>
|
||||
<v.icon class="size-4" /><span>{v.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ul class="menu w-full">
|
||||
<li class="menu-title">Feeds</li>
|
||||
{#each groups as group, index}
|
||||
<li>
|
||||
<details open={index === 0}>
|
||||
<summary class="overflow-hidden">
|
||||
<span class="line-clamp-1">{group.name}</span>
|
||||
</summary>
|
||||
<ul>
|
||||
{#each feeds.filter((v) => v.group.id === group.id) as feed}
|
||||
{@const domain = new URL(feed.link).hostname}
|
||||
{@const textColor = feed.suspended
|
||||
? 'text-base-content/60'
|
||||
: feed.failure
|
||||
? 'text-error'
|
||||
: ''}
|
||||
<li>
|
||||
<a
|
||||
href="/feeds/{feed.id}"
|
||||
class={isHighlight('/feeds/' + feed.id) ? 'menu-active' : ''}
|
||||
>
|
||||
<div class="avatar">
|
||||
<div class="size-4 rounded-full">
|
||||
<img src={getFavicon(feed.link)} alt={feed.name} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
<span class={`line-clamp-1 ${textColor}`}>{feed.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button onclick={handleLogout} class="btn btn-ghost btn-sm mt-auto w-full">
|
||||
<LogOut class="size-4" />
|
||||
Logout</button
|
||||
>
|
||||
<p class="text-base-content/60 text-center text-xs">
|
||||
<span>
|
||||
{version}.
|
||||
</span>
|
||||
<span>
|
||||
Logo by <a href="https://icons8.com/icon/FeQbTvGTsiN5/news" target="_blank">Icons8</a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
27
frontend/src/lib/components/ThemeController.svelte
Normal file
27
frontend/src/lib/components/ThemeController.svelte
Normal file
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
let isDark = $state(localStorage.getItem('theme')?.includes('dark'));
|
||||
|
||||
$effect(() => {
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
localStorage.setItem('theme', theme);
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="btn btn-ghost btn-square swap swap-rotate">
|
||||
<!-- this hidden checkbox controls the state -->
|
||||
<input type="checkbox" class="theme-controller" value="dark" bind:checked={isDark} />
|
||||
|
||||
<!-- sun icon -->
|
||||
<svg class="swap-off size-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- moon icon -->
|
||||
<svg class="swap-on size-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
|
@ -1,32 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { cn } from '$lib/utils';
|
||||
import Moon from 'lucide-svelte/icons/moon';
|
||||
import Sun from 'lucide-svelte/icons/sun';
|
||||
|
||||
import { resetMode, setMode } from 'mode-watcher';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), className)}>
|
||||
<Sun
|
||||
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
|
||||
/>
|
||||
<Moon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
|
||||
/>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => setMode('light')}>Light</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => setMode('dark')}>Dark</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => resetMode()}>System</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} class={cn("text-sm [&_p]:leading-relaxed", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -1,24 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 5,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
aria-level={level}
|
||||
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -1,39 +0,0 @@
|
|||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const alertVariants = tv({
|
||||
base: "[&>svg]:text-foreground relative w-full rounded-lg border px-4 py-3 text-sm [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
variant?: AlertVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} class={cn(alertVariants({ variant }), className)} {...restProps} role="alert">
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -1,14 +0,0 @@
|
|||
import Root from "./alert.svelte";
|
||||
import Description from "./alert-description.svelte";
|
||||
import Title from "./alert-title.svelte";
|
||||
export { alertVariants, type AlertVariant } from "./alert.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
};
|
|
@ -1,49 +0,0 @@
|
|||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
export const badgeVariants = tv({
|
||||
base: "focus:ring-ring inline-flex select-none items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/80 border-transparent shadow",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent shadow",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn } 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}
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
|
@ -1,2 +0,0 @@
|
|||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
|
@ -1,75 +0,0 @@
|
|||
<script lang="ts" module>
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
|
||||
outline:
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
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">
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{href}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
|
@ -1,17 +0,0 @@
|
|||
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,
|
||||
};
|
|
@ -1,35 +0,0 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Command as CommandPrimitive,
|
||||
Dialog as DialogPrimitive,
|
||||
type WithoutChildrenOrChild,
|
||||
} from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import Command from "./command.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.RootProps> &
|
||||
WithoutChildrenOrChild<CommandPrimitive.RootProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {...restProps}>
|
||||
<Dialog.Content class="overflow-hidden p-0" {portalProps}>
|
||||
<Command
|
||||
class="[&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
|
||||
{...restProps}
|
||||
bind:value
|
||||
bind:ref
|
||||
{children}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
|
@ -1,12 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.EmptyProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Empty bind:ref class={cn("py-6 text-center text-sm", className)} {...restProps} />
|
|
@ -1,29 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
heading,
|
||||
...restProps
|
||||
}: CommandPrimitive.GroupProps & {
|
||||
heading?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Group
|
||||
class={cn("text-foreground overflow-hidden p-1", className)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
>
|
||||
{#if heading}
|
||||
<CommandPrimitive.GroupHeading
|
||||
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
|
||||
>
|
||||
{heading}
|
||||
</CommandPrimitive.GroupHeading>
|
||||
{/if}
|
||||
<CommandPrimitive.GroupItems {children} />
|
||||
</CommandPrimitive.Group>
|
|
@ -1,25 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import Search from "lucide-svelte/icons/search";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(""),
|
||||
...restProps
|
||||
}: CommandPrimitive.InputProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center border-b px-3" data-command-input-wrapper="">
|
||||
<Search class="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
class={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Item
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.LinkItemProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.LinkItem
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.List
|
||||
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,12 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Separator bind:ref class={cn("bg-border -mx-1 h-px", className)} {...restProps} />
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
|
@ -1,21 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CommandPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CommandPrimitive.Root
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,40 +0,0 @@
|
|||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
|
||||
import Root from "./command.svelte";
|
||||
import Dialog from "./command-dialog.svelte";
|
||||
import Empty from "./command-empty.svelte";
|
||||
import Group from "./command-group.svelte";
|
||||
import Item from "./command-item.svelte";
|
||||
import Input from "./command-input.svelte";
|
||||
import List from "./command-list.svelte";
|
||||
import Separator from "./command-separator.svelte";
|
||||
import Shortcut from "./command-shortcut.svelte";
|
||||
import LinkItem from "./command-link-item.svelte";
|
||||
|
||||
const Loading: typeof CommandPrimitive.Loading = CommandPrimitive.Loading;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Dialog,
|
||||
Empty,
|
||||
Group,
|
||||
Item,
|
||||
LinkItem,
|
||||
Input,
|
||||
List,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Loading,
|
||||
//
|
||||
Root as Command,
|
||||
Dialog as CommandDialog,
|
||||
Empty as CommandEmpty,
|
||||
Group as CommandGroup,
|
||||
Item as CommandItem,
|
||||
LinkItem as CommandLinkItem,
|
||||
Input as CommandInput,
|
||||
List as CommandList,
|
||||
Separator as CommandSeparator,
|
||||
Shortcut as CommandShortcut,
|
||||
Loading as CommandLoading,
|
||||
};
|
|
@ -1,38 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import X from "lucide-svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</Dialog.Portal>
|
|
@ -1,16 +0,0 @@
|
|||
<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
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -1,19 +0,0 @@
|
|||
<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
|
||||
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/80",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,8 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
type $$Props = DialogPrimitive.PortalProps;
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...$$restProps}>
|
||||
<slot />
|
||||
</DialogPrimitive.Portal>
|
|
@ -1,16 +0,0 @@
|
|||
<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
|
||||
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,37 +0,0 @@
|
|||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
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";
|
||||
|
||||
const Root: typeof DialogPrimitive.Root = DialogPrimitive.Root;
|
||||
const Trigger: typeof DialogPrimitive.Trigger = DialogPrimitive.Trigger;
|
||||
const Close: typeof DialogPrimitive.Close = DialogPrimitive.Close;
|
||||
const Portal: typeof DialogPrimitive.Portal = DialogPrimitive.Portal;
|
||||
|
||||
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,
|
||||
};
|
|
@ -1,40 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import Check from "lucide-svelte/icons/check";
|
||||
import Minus from "lucide-svelte/icons/minus";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if indeterminate}
|
||||
<Minus class="size-4" />
|
||||
{:else}
|
||||
<Check class={cn("size-4", !checked && "text-transparent")} />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
@ -1,27 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"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-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.GroupHeadingProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,23 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,23 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { type WithElementRef } from "bits-ui";
|
||||
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}
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
type $$Props = DropdownMenuPrimitive.RadioGroupProps;
|
||||
|
||||
export let value: $$Props["value"] = undefined;
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup {...$$restProps} bind:value>
|
||||
<slot />
|
||||
</DropdownMenuPrimitive.RadioGroup>
|
|
@ -1,30 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from "bits-ui";
|
||||
import Circle from "lucide-svelte/icons/circle";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if checked}
|
||||
<Circle class="size-2 fill-current" />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
|
@ -1,16 +0,0 @@
|
|||
<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
|
||||
class={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { type WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
class={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
|
@ -1,19 +0,0 @@
|
|||
<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
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-lg focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive, type WithoutChild } from "bits-ui";
|
||||
import ChevronRight from "lucide-svelte/icons/chevron-right";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.SubTriggerProps> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRight class="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
|
@ -1,50 +0,0 @@
|
|||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
|
||||
import Content from "./dropdown-menu-content.svelte";
|
||||
import GroupHeading from "./dropdown-menu-group-heading.svelte";
|
||||
import Item from "./dropdown-menu-item.svelte";
|
||||
import Label from "./dropdown-menu-label.svelte";
|
||||
import RadioItem from "./dropdown-menu-radio-item.svelte";
|
||||
import Separator from "./dropdown-menu-separator.svelte";
|
||||
import Shortcut from "./dropdown-menu-shortcut.svelte";
|
||||
import SubContent from "./dropdown-menu-sub-content.svelte";
|
||||
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
|
||||
|
||||
const Sub = DropdownMenuPrimitive.Sub;
|
||||
const Root = DropdownMenuPrimitive.Root;
|
||||
const Trigger = DropdownMenuPrimitive.Trigger;
|
||||
const Group = DropdownMenuPrimitive.Group;
|
||||
const RadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
export {
|
||||
CheckboxItem,
|
||||
Content,
|
||||
Root as DropdownMenu,
|
||||
CheckboxItem as DropdownMenuCheckboxItem,
|
||||
Content as DropdownMenuContent,
|
||||
Group as DropdownMenuGroup,
|
||||
GroupHeading as DropdownMenuGroupHeading,
|
||||
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,
|
||||
Group,
|
||||
GroupHeading,
|
||||
Item,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Root,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Sub,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
Trigger,
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
|
@ -1,22 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLInputAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,7 +0,0 @@
|
|||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
|
@ -1,19 +0,0 @@
|
|||
<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
|
||||
class={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,24 +0,0 @@
|
|||
import Root from "./pagination.svelte";
|
||||
import Content from "./pagination-content.svelte";
|
||||
import Item from "./pagination-item.svelte";
|
||||
import Link from "./pagination-link.svelte";
|
||||
import PrevButton from "./pagination-prev-button.svelte";
|
||||
import NextButton from "./pagination-next-button.svelte";
|
||||
import Ellipsis from "./pagination-ellipsis.svelte";
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Link,
|
||||
PrevButton,
|
||||
NextButton,
|
||||
Ellipsis,
|
||||
//
|
||||
Root as Pagination,
|
||||
Content as PaginationContent,
|
||||
Item as PaginationItem,
|
||||
Link as PaginationLink,
|
||||
PrevButton as PaginationPrevButton,
|
||||
NextButton as PaginationNextButton,
|
||||
Ellipsis as PaginationEllipsis,
|
||||
};
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
|
||||
</script>
|
||||
|
||||
<ul bind:this={ref} class={cn("flex flex-row items-center gap-1", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</ul>
|
|
@ -1,22 +0,0 @@
|
|||
<script lang="ts">
|
||||
import Ellipsis from "lucide-svelte/icons/ellipsis";
|
||||
import type { WithElementRef, WithoutChildren } from "bits-ui";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
aria-hidden="true"
|
||||
class={cn("flex size-9 items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">More pages</span>
|
||||
</span>
|
|
@ -1,14 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li bind:this={ref} {...restProps}>
|
||||
{@render children?.()}
|
||||
</li>
|
|
@ -1,41 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive, type WithoutChild } from "bits-ui";
|
||||
import {
|
||||
type Props as ButtonProps,
|
||||
buttonVariants,
|
||||
} from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type Props = WithoutChild<PaginationPrimitive.PageProps> &
|
||||
ButtonProps & {
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
size = "icon",
|
||||
isActive = false,
|
||||
page,
|
||||
children,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
{page.value}
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.Page
|
||||
{page}
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
children={children || Fallback}
|
||||
/>
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import ChevronRight from "lucide-svelte/icons/chevron-right";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.NextButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRight />
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.NextButton
|
||||
bind:ref
|
||||
{...restProps}
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'ghost', className: 'gap-1 px-0 pr-0 md:px-4 md:pr-2.5' }),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
/>
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import ChevronLeft from "lucide-svelte/icons/chevron-left";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.PrevButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeft />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.PrevButton
|
||||
bind:ref
|
||||
{...restProps}
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'ghost', className: 'gap-1 px-0 pl-0 md:px-4 md:pl-2.5' }),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
/>
|
|
@ -1,24 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
count = 0,
|
||||
perPage = 10,
|
||||
page = $bindable(1),
|
||||
siblingCount = 1,
|
||||
...restProps
|
||||
}: PaginationPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PaginationPrimitive.Root
|
||||
bind:ref
|
||||
class={cn("mx-auto flex w-full flex-col items-center", className)}
|
||||
{count}
|
||||
{perPage}
|
||||
{siblingCount}
|
||||
bind:page
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,17 +0,0 @@
|
|||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Trigger = PopoverPrimitive.Trigger;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
};
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
{align}
|
||||
{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-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
|
@ -1,10 +0,0 @@
|
|||
import Root from "./radio-group.svelte";
|
||||
import Item from "./radio-group-item.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
//
|
||||
Root as RadioGroup,
|
||||
Item as RadioGroupItem,
|
||||
};
|
|
@ -1,30 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { RadioGroup as RadioGroupPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import Circle from "lucide-svelte/icons/circle";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> & {
|
||||
value: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroupPrimitive.Item
|
||||
bind:ref
|
||||
class={cn(
|
||||
"border-primary text-primary focus-visible:ring-ring aspect-square size-4 rounded-full border shadow focus:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<div class="flex items-center justify-center">
|
||||
{#if checked}
|
||||
<Circle class="fill-primary size-3.5" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</RadioGroupPrimitive.Item>
|
|
@ -1,13 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: RadioGroupPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroupPrimitive.Root bind:value class={cn("grid gap-2", className)} {...restProps} bind:ref />
|
|
@ -1,34 +0,0 @@
|
|||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
import GroupHeading from "./select-group-heading.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";
|
||||
|
||||
const Root = SelectPrimitive.Root;
|
||||
const Group = SelectPrimitive.Group;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
Group,
|
||||
GroupHeading,
|
||||
Content,
|
||||
Trigger,
|
||||
Separator,
|
||||
ScrollDownButton,
|
||||
ScrollUpButton,
|
||||
//
|
||||
Root as Select,
|
||||
Item as SelectItem,
|
||||
Group as SelectGroup,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Content as SelectContent,
|
||||
Trigger as SelectTrigger,
|
||||
Separator as SelectSeparator,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
};
|
|
@ -1,38 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
|
||||
import * as Select from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: SelectPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
"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-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden 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}
|
||||
>
|
||||
<Select.ScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
"h-[var(--bits-select-anchor-height)] w-full min-w-[var(--bits-select-anchor-width)] p-1"
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<Select.ScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SelectPrimitive.GroupHeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -1,37 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
|
||||
import Check from "lucide-svelte/icons/check";
|
||||
import { cn } 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}
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<Check class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = SelectPrimitive.LabelProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Label
|
||||
class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</SelectPrimitive.Label>
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import ChevronDown from "lucide-svelte/icons/chevron-down";
|
||||
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDown class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import ChevronUp from "lucide-svelte/icons/chevron-up";
|
||||
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUp class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
|
@ -1,13 +0,0 @@
|
|||
<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 class={cn("bg-muted -mx-1 my-1 h-px", className)} {...restProps} />
|
|
@ -1,24 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
|
||||
import ChevronDown from "lucide-svelte/icons/chevron-down";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
class={cn(
|
||||
"border-input ring-offset-background data-[placeholder]:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue