1
0
Fork 1
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:
Yuan 2025-03-12 19:22:07 +08:00 committed by GitHub
parent e5a53a1ea3
commit ab157ad769
Signed by: github
GPG key ID: B5690EEEBB952194
149 changed files with 4140 additions and 8457 deletions

View file

@ -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)

View file

@ -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" } }]
}

View file

@ -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"
}

File diff suppressed because it is too large Load diff

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

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

View file

@ -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" />

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

View file

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

View file

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

View file

@ -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'>;
};

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

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

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

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

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

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

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

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

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

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

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -1,2 +0,0 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View file

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

View file

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

View file

@ -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>

View file

@ -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} />

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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} />

View file

@ -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>

View file

@ -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}
/>

View file

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

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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}
/>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

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

View file

@ -1,7 +0,0 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View file

@ -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}
/>

View file

@ -1,7 +0,0 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View file

@ -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}
/>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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}
/>

View file

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

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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 />

View file

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

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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} />

View file

@ -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