Initial commit
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: #fdba74;
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: #fef9c3;
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: #ff9398;
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: #059669;
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: #422006;
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { readingTime } from '$lib/utils';
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Tags from './Tags.svelte';
|
||||
import CalendarArrowUp from '@lucide/svelte/icons/calendar-arrow-up';
|
||||
import BookOpenCheck from '@lucide/svelte/icons/book-open-check';
|
||||
|
||||
export let event: NDKEvent;
|
||||
|
||||
const longFormClient = 'https://grimoire.rocks/run?cmd=open%20';
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class="w-72 flex-none shadow-sm transition-shadow duration-200 hover:shadow-lg md:w-auto md:flex-auto"
|
||||
>
|
||||
<img
|
||||
src={event.tagValue('image')}
|
||||
class="max-h-[900px] w-full rounded-t-xl"
|
||||
alt={event.tagValue('name') ? event.tagValue('name') : event.tagValue('title')}
|
||||
/>
|
||||
<Card.Header>
|
||||
<Card.Title class="hover:text-neutral-400">
|
||||
<a href="{longFormClient}{event.encode()}">
|
||||
{event.tagValue('name') ? event.tagValue('name') : event.tagValue('title')}
|
||||
</a>
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
<div class="flex items-center">
|
||||
<CalendarArrowUp class="h-4" />
|
||||
{new Date(Number(event.tagValue('published_at')) * 1000).toLocaleDateString('es-ES')}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<BookOpenCheck class="h-4" />
|
||||
{readingTime(event.content)} mins
|
||||
</div>
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="relative max-h-32 overflow-hidden">
|
||||
{event.tagValue('summary')}
|
||||
<div
|
||||
class="pointer-events-none absolute right-0 bottom-0 left-0 h-16 bg-gradient-to-t from-card to-transparent"
|
||||
></div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="relative max-h-24 items-start overflow-hidden">
|
||||
<Tags tags={event.tags.filter((v) => v[0] === 't')} />
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import ndk from '$lib/stores/ndk';
|
||||
import { parseDriveEvent, getBlobUrl, fileName, type BlossomFile } from '$lib/blossom';
|
||||
import type { NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import PdfCover from './PdfCover.svelte';
|
||||
|
||||
export let pubkey: string;
|
||||
export let driveId: string;
|
||||
|
||||
let driveName = '';
|
||||
let description = '';
|
||||
let files: BlossomFile[] = [];
|
||||
let servers: string[] = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let activePdf: string | null = null;
|
||||
|
||||
onMount(() => {
|
||||
const $ndk = get(ndk);
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [30563 as number],
|
||||
authors: [pubkey],
|
||||
'#d': [driveId]
|
||||
};
|
||||
|
||||
$ndk
|
||||
.fetchEvent(filter)
|
||||
.then((event) => {
|
||||
if (!event) {
|
||||
error = 'Drive not found.';
|
||||
return;
|
||||
}
|
||||
|
||||
const drive = parseDriveEvent(event);
|
||||
driveName = drive.name;
|
||||
description = drive.description;
|
||||
servers = drive.servers;
|
||||
files = drive.files.filter(
|
||||
(f) => f.mimeType === 'application/pdf' || f.path.endsWith('.pdf')
|
||||
);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
})
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
function openPdf(file: BlossomFile) {
|
||||
activePdf = getBlobUrl(file.sha256, servers, '.pdf');
|
||||
}
|
||||
|
||||
function closePdf() {
|
||||
activePdf = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="animate-pulse text-sm text-gray-500">Loading drive…</p>
|
||||
{:else if error}
|
||||
<p class="text-sm font-medium text-red-600">Error: {error}</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4">
|
||||
{#each files as file (file.sha256)}
|
||||
{@const blobUrl = getBlobUrl(file.sha256, servers, '.pdf')}
|
||||
{@const name = fileName(file.path)}
|
||||
|
||||
<div class="group flex flex-col gap-2">
|
||||
<!-- Cover — clicking opens the viewer -->
|
||||
<button
|
||||
on:click={() => openPdf(file)}
|
||||
class="block w-full overflow-hidden rounded-lg shadow-md ring-2 ring-transparent transition-all duration-200 group-hover:-translate-y-1 group-hover:shadow-xl group-hover:ring-indigo-400"
|
||||
aria-label="Read {name}"
|
||||
>
|
||||
<PdfCover url={blobUrl} alt={name} />
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<p
|
||||
class="truncate px-1 text-center text-xs leading-tight font-medium text-gray-700 transition-colors group-hover:text-indigo-600"
|
||||
title={name}
|
||||
>
|
||||
{name.replace(/\.pdf$/i, '')}
|
||||
</p>
|
||||
|
||||
<!-- Download link -->
|
||||
<a
|
||||
href={blobUrl}
|
||||
download={name}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-center text-xs text-gray-400 transition-colors hover:text-indigo-500"
|
||||
>
|
||||
⬇ Download
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PDF Viewer Modal -->
|
||||
{#if activePdf}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
on:click={closePdf}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="relative flex w-[90vw] max-w-4xl flex-col rounded-xl bg-white p-4 shadow-2xl"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<!-- Modal header -->
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700">Fanzine Viewer</span>
|
||||
<button
|
||||
on:click={closePdf}
|
||||
class="text-xl leading-none text-gray-400 transition-colors hover:text-gray-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- PDF iframe -->
|
||||
<iframe
|
||||
src={activePdf}
|
||||
title="Fanzine PDF"
|
||||
class="w-full rounded border border-gray-200"
|
||||
style="height: 80vh;"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
// import { _ } from '../../services/i18n'
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import RsvpButton from './RsvpButton.svelte';
|
||||
import CalendarEventInfo from './CalendarEventInfo.svelte';
|
||||
|
||||
export let event: NDKEvent;
|
||||
</script>
|
||||
|
||||
<Card.Root
|
||||
class="w-72 flex-none shadow-sm transition-shadow duration-200 hover:shadow-md md:w-auto md:flex-auto"
|
||||
>
|
||||
<div class="flex w-full items-start">
|
||||
<img
|
||||
src={event.tagValue('image')}
|
||||
class="max-h-full max-w-full rounded-t-xl object-contain"
|
||||
alt={event.tagValue('name') ? event.tagValue('name') : event.tagValue('title')}
|
||||
/>
|
||||
</div>
|
||||
<Card.Header>
|
||||
<Card.Title class="hover:text-neutral-300">
|
||||
<a href="/calendar/{event.encode()}">
|
||||
{event.tagValue('name') ? event.tagValue('name') : event.tagValue('title')}
|
||||
</a>
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
<CalendarEventInfo {event} />
|
||||
</Card.Description>
|
||||
<Card.Action>
|
||||
<RsvpButton {event} />
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
// import { _ } from '../services/i18n';
|
||||
import ndk from '$lib/stores/ndk';
|
||||
// import { Avatar, Popover } from 'flowbite-svelte';
|
||||
import type { NDKEvent, NDKSubscription, NDKUser, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import type { NDKEventStore, ExtendedBaseType } from '@nostr-dev-kit/ndk-svelte';
|
||||
|
||||
export let event: NDKEvent;
|
||||
|
||||
interface Attendee {
|
||||
user: NDKUser;
|
||||
status: string;
|
||||
rsvpEvent: NDKEvent;
|
||||
}
|
||||
|
||||
const attendees = writable<Attendee[]>([]);
|
||||
let subscription: NDKSubscription | null = null;
|
||||
let isLoading = true;
|
||||
|
||||
onMount(async () => {
|
||||
if (!$ndk) return;
|
||||
|
||||
// Subscribe to RSVP events for this specific event
|
||||
subscription = $ndk.subscribe({
|
||||
kinds: [31925 as NDKKind],
|
||||
'#e': [event.id]
|
||||
});
|
||||
|
||||
subscription.on('event', async (event: NDKEvent) => {
|
||||
await handleRsvpEvent(event);
|
||||
});
|
||||
|
||||
subscription.on('eose', () => {
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
// Start the subscription
|
||||
subscription.start();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (subscription) {
|
||||
subscription.stop();
|
||||
}
|
||||
});
|
||||
|
||||
async function handleRsvpEvent(rsvpEvent: NDKEvent) {
|
||||
// Get the status from the event tags
|
||||
const statusTag = rsvpEvent.tags.find((tag) => tag[0] === 'status');
|
||||
const status = statusTag?.[1];
|
||||
|
||||
if (!status) return;
|
||||
|
||||
// Get the user who made the RSVP
|
||||
const user = rsvpEvent.author;
|
||||
if (!user) return;
|
||||
|
||||
// Fetch user profile if not already loaded
|
||||
if (!user.profile) {
|
||||
await user.fetchProfile();
|
||||
}
|
||||
|
||||
attendees.update((currentAttendees) => {
|
||||
// Remove any existing RSVP from this user
|
||||
const filtered = currentAttendees.filter((attendee) => attendee.user.pubkey !== user.pubkey);
|
||||
|
||||
// Only add if status is 'accepted'
|
||||
if (status === 'accepted') {
|
||||
return [...filtered, { user, status, rsvpEvent }];
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
|
||||
$: acceptedAttendees = $attendees.filter((attendee) => attendee.status === 'accepted');
|
||||
|
||||
let responses: NDKEventStore<ExtendedBaseType<NDKEvent>>;
|
||||
|
||||
const filter = {
|
||||
kinds: [31925 as number],
|
||||
'#a': [`${event.kind}:${event.author.pubkey}:${event.dTag}`]
|
||||
};
|
||||
|
||||
responses = $ndk.storeSubscribe(filter, { closeOnEose: true });
|
||||
|
||||
onMount(() => {
|
||||
responses = $ndk.storeSubscribe(filter, { closeOnEose: true });
|
||||
console.log(responses);
|
||||
});
|
||||
|
||||
$: totalresponses = $responses.length;
|
||||
|
||||
onDestroy(() => responses?.unsubscribe());
|
||||
</script>
|
||||
|
||||
<!-- <h3 class="text-lg font-semibold"> -->
|
||||
<!-- Attending ({acceptedAttendees.length}) -->
|
||||
<!-- </h3> -->
|
||||
{#if isLoading}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
<span class="text-sm text-muted-foreground">Cargando asistentes...</span>
|
||||
</div>
|
||||
{:else if acceptedAttendees.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Aún no se ha apuntado nadie</p>
|
||||
{:else}
|
||||
<div class="flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background">
|
||||
{#each acceptedAttendees as attendee (attendee.user.pubkey)}
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={attendee.user.profile?.image || attendee.user.profile?.picture}
|
||||
alt={attendee.user.profile?.displayName || attendee.user.profile?.name}
|
||||
/>
|
||||
<Avatar.Fallback
|
||||
>{attendee.user.profile?.displayName.slice(0, 2) ||
|
||||
attendee.user.profile?.name.slice(0, 2)}</Avatar.Fallback
|
||||
>
|
||||
</Avatar.Root>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
// import { _ } from '../../services/i18n'
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import Calendar from '@lucide/svelte/icons/calendar';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||
import Separator from '$lib/components/ui/separator/separator.svelte';
|
||||
|
||||
export let event: NDKEvent;
|
||||
|
||||
function getTagValue(tags: string[][], name: string, position: number = 0): string | null {
|
||||
const found = tags.find((v) => v[0] === name);
|
||||
if (!found) return null;
|
||||
const [, ...values] = found;
|
||||
return values[position];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center">
|
||||
<Calendar class="h-4" />
|
||||
{new Date(Number(event.tagValue('start')) * 1000).toLocaleDateString('es-ES', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: '2-digit',
|
||||
year: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div class="flex items-center">
|
||||
<Clock class="h-4" />
|
||||
{new Date(Number(event.tagValue('start')) * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}-{new Date(Number(event.tagValue('end')) * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div class="flex items-center">
|
||||
<MapPin class="h-4" />
|
||||
{#if event.tagValue('location')}
|
||||
{getTagValue(event.tags, 'location', 1)
|
||||
? getTagValue(event.tags, 'location', 1)
|
||||
: getTagValue(event.tags, 'location')}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import ndk from '$lib/stores/ndk';
|
||||
import { parseDriveEvent, getBlobUrl, fileName, type BlossomFile } from '$lib/blossom';
|
||||
import type { NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import MoveRight from '@lucide/svelte/icons/move-right';
|
||||
import PdfCover from './PdfCover.svelte';
|
||||
|
||||
export let pubkey: string;
|
||||
export let driveId: string;
|
||||
/** How many fanzines to show before "see all" — mirrors your products.slice(0, 3) */
|
||||
export let limit: number = 3;
|
||||
/** The href for the "see all" link */
|
||||
export let href: string = '/fanzines';
|
||||
/** Section heading */
|
||||
export let heading: string = 'Fanzines';
|
||||
|
||||
let files: BlossomFile[] = [];
|
||||
let servers: string[] = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let activePdf: string | null = null;
|
||||
let activeTitle: string = '';
|
||||
|
||||
onMount(() => {
|
||||
const $ndk = get(ndk);
|
||||
|
||||
const filter: NDKFilter = {
|
||||
kinds: [30563],
|
||||
authors: [pubkey],
|
||||
'#d': [driveId]
|
||||
};
|
||||
|
||||
$ndk
|
||||
.fetchEvent(filter)
|
||||
.then((event) => {
|
||||
if (!event) {
|
||||
error = 'Drive not found.';
|
||||
return;
|
||||
}
|
||||
const drive = parseDriveEvent(event);
|
||||
servers = drive.servers;
|
||||
files = drive.files.filter(
|
||||
(f) => f.mimeType === 'application/pdf' || f.path.endsWith('.pdf')
|
||||
);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
})
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
function openPdf(file: BlossomFile) {
|
||||
activePdf = getBlobUrl(file.sha256, servers, '.pdf');
|
||||
activeTitle = fileName(file.path);
|
||||
}
|
||||
|
||||
function closePdf() {
|
||||
activePdf = null;
|
||||
activeTitle = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<a {href} class="mb-4 flex items-center gap-1 text-2xl font-bold hover:text-neutral-500">
|
||||
{heading}<MoveRight />
|
||||
</a>
|
||||
|
||||
{#if loading}
|
||||
<!-- Skeleton row — same layout as the real cards -->
|
||||
<div
|
||||
class="flex gap-4 overflow-x-auto pb-4 md:grid md:grid-cols-2 md:overflow-visible md:pb-0 lg:grid-cols-3"
|
||||
>
|
||||
{#each Array(limit) as _}
|
||||
<div class="flex w-40 shrink-0 flex-col gap-2 md:w-auto">
|
||||
<div class="aspect-[3/4] animate-pulse rounded-lg bg-neutral-200" />
|
||||
<div class="mx-auto h-3 w-3/4 animate-pulse rounded bg-neutral-200" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-sm text-red-500">{error}</p>
|
||||
{:else if files.length === 0}
|
||||
<p class="text-sm text-neutral-400">No fanzines found.</p>
|
||||
{:else}
|
||||
<!-- Horizontal scroll on mobile, grid on md+ — exactly your pattern -->
|
||||
<div
|
||||
class="flex gap-4 overflow-x-auto pb-4 md:grid md:grid-cols-2 md:overflow-visible md:pb-0 lg:grid-cols-3"
|
||||
>
|
||||
{#each files.slice(0, limit) as file (file.sha256)}
|
||||
{@const blobUrl = getBlobUrl(file.sha256, servers, '.pdf')}
|
||||
{@const name = fileName(file.path).replace(/\.pdf$/i, '')}
|
||||
|
||||
<!-- FanzineCard — mirrors your <ProductCard /> -->
|
||||
<div
|
||||
class="group flex w-40 shrink-0 cursor-pointer flex-col gap-2 md:w-auto"
|
||||
on:click={() => openPdf(file)}
|
||||
on:keydown={(e) => e.key === 'Enter' && openPdf(file)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Read {name}"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="overflow-hidden rounded-lg shadow-md ring-2 ring-transparent transition-all duration-200 group-hover:-translate-y-1 group-hover:shadow-lg group-hover:ring-neutral-400"
|
||||
>
|
||||
<PdfCover url={blobUrl} alt={name} />
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<p
|
||||
class="truncate px-1 text-center text-sm font-medium text-neutral-700 transition-colors group-hover:text-neutral-500"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- PDF Viewer Modal -->
|
||||
{#if activePdf}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
on:click={closePdf}
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="relative flex w-[92vw] max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl"
|
||||
style="height: 90vh;"
|
||||
on:click|stopPropagation
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<span class="truncate text-sm font-semibold text-neutral-800">
|
||||
📄 {activeTitle.replace(/\.pdf$/i, '')}
|
||||
</span>
|
||||
<button
|
||||
on:click={closePdf}
|
||||
class="ml-4 text-xl leading-none text-neutral-400 transition-colors hover:text-neutral-700"
|
||||
aria-label="Close viewer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<iframe src={activePdf} title="Fanzine PDF" class="w-full flex-1 border-none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
import FooterButton from './FooterButton.svelte';
|
||||
</script>
|
||||
|
||||
<section class="mx-auto mb-2 flex justify-center pl-10">
|
||||
<FooterButton
|
||||
url="https://chachi.chat/community.bitcointxoko.com/txoko"
|
||||
w="20"
|
||||
h="18"
|
||||
p="3"
|
||||
icon="M0 0 C2.3925 -0.0825 4.785 -0.165 7.25 -0.25 C7.99668945 -0.28641602 8.74337891 -0.32283203 9.51269531 -0.36035156 C12.69907309 -0.41348643 14.73390382 -0.16167869 17.46875 1.5 C19.62741002 5.0243429 19.43342869 8.25689854 19.25 12.25 C19.23195313 12.99507812 19.21390625 13.74015625 19.1953125 14.5078125 C19.14835649 16.33909701 19.07661652 18.16971649 19 20 C17.52093108 20.02689216 16.04172517 20.04634621 14.5625 20.0625 C13.32693359 20.07990234 13.32693359 20.07990234 12.06640625 20.09765625 C10 20 10 20 9 19 C8.73706944 14.96839809 8.70595156 13.44107266 11 10 C14.625 9.8125 14.625 9.8125 18 10 C18 9.34 18 8.68 18 8 C17.21625 7.896875 16.4325 7.79375 15.625 7.6875 C13 7 13 7 11 4 C9.02 4 7.04 4 5 4 C5 9.28 5 14.56 5 20 C3.35 20 1.7 20 0 20 C0 13.4 0 6.8 0 0 Z "
|
||||
/>
|
||||
<FooterButton
|
||||
url="https://matrix.to/#/#community:bitcointxoko.com"
|
||||
w="32"
|
||||
h="32"
|
||||
p="6"
|
||||
icon="M0.844 0.735v30.531h2.197v0.735h-3.041v-32h3.041v0.735zM10.235 10.412v1.547h0.041c0.412-0.595 0.912-1.047 1.489-1.371 0.579-0.323 1.251-0.484 2-0.484 0.719 0 1.38 0.141 1.975 0.417 0.599 0.281 1.047 0.776 1.359 1.479 0.339-0.5 0.803-0.943 1.38-1.323 0.579-0.38 1.267-0.573 2.063-0.573 0.604 0 1.161 0.073 1.677 0.224 0.521 0.145 0.959 0.38 1.328 0.703 0.365 0.329 0.651 0.751 0.86 1.272 0.203 0.52 0.307 1.151 0.307 1.891v7.635h-3.129v-6.468c0-0.381-0.016-0.745-0.048-1.084-0.020-0.307-0.099-0.604-0.239-0.88-0.131-0.251-0.333-0.459-0.584-0.593-0.255-0.152-0.609-0.224-1.047-0.224-0.443 0-0.797 0.083-1.068 0.249-0.265 0.167-0.489 0.396-0.64 0.667-0.161 0.287-0.265 0.604-0.308 0.927-0.052 0.349-0.077 0.699-0.083 1.048v6.359h-3.131v-6.401c0-0.339-0.005-0.672-0.025-1-0.011-0.317-0.073-0.624-0.193-0.916-0.104-0.281-0.301-0.516-0.552-0.672-0.255-0.167-0.636-0.255-1.136-0.255-0.151 0-0.348 0.031-0.588 0.099-0.24 0.067-0.479 0.192-0.703 0.375-0.229 0.188-0.428 0.453-0.589 0.797-0.161 0.343-0.239 0.796-0.239 1.359v6.62h-3.131v-11.421zM31.156 31.265v-30.531h-2.197v-0.735h3.041v32h-3.041v-0.735z"
|
||||
/>
|
||||
<FooterButton
|
||||
url="https://simplex.bitcointxoko.com"
|
||||
w="34"
|
||||
h="34"
|
||||
p="5"
|
||||
icon="M3.02972 8.59396L8.62219 14.186L14.3703 8.43848L17.1668 11.2346L11.4182 16.982L17.0112 22.5742L14.1371 25.448L8.5441 19.8557L2.79651 25.6035L0 22.8074L5.74813 17.0597L0.155656 11.4678L3.02972 8.59396Z M14.0922 25.5L16.9434 22.6486L16.9423 22.6478L22.6464 16.9456L17.0512 11.3519L17.0518 11.3514L14.2542 8.55418L8.65961 2.95973L11.5114 0.108337L17.106 5.70288L22.8095 0L25.607 2.79722L19.903 8.5L25.4981 14.0943L31.2022 8.39169L33.9997 11.1889L28.2957 16.8914L33.8914 22.4861L31.0396 25.3375L25.4439 19.7428L19.7404 25.4454L25.3361 31.0403L22.4843 33.8917L16.8887 28.2968L11.1862 34L8.38867 31.2028L14.0922 25.5Z"
|
||||
/>
|
||||
<FooterButton
|
||||
url="https://github.com/bitcointxoko"
|
||||
w="496"
|
||||
h="512"
|
||||
p="6"
|
||||
icon="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
/>
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let url: string;
|
||||
export let icon: string;
|
||||
export let w: string;
|
||||
export let h: string;
|
||||
</script>
|
||||
|
||||
<a href={url} class="inline-flex items-center align-middle">
|
||||
<button class="flex h-16 w-16 cursor-pointer items-center gap-2 text-gray-700 dark:text-gray-50">
|
||||
<svg
|
||||
fill="#ffffff"
|
||||
class="h-5 w-5 fill-current"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d={icon} />
|
||||
</svg>
|
||||
</button>
|
||||
</a>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { initializeNDK, signInWithNIP07, signOut, user, isAuthenticated } from '$lib/utils/auth';
|
||||
|
||||
import Menu from '@lucide/svelte/icons/menu';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import { toggleMode } from 'mode-watcher';
|
||||
|
||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
|
||||
onMount(async () => {
|
||||
await initializeNDK();
|
||||
});
|
||||
|
||||
async function handleSignIn() {
|
||||
try {
|
||||
await signInWithNIP07();
|
||||
} catch (error) {
|
||||
alert('Failed to sign in. Make sure you have a NIP-07 extension installed.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignOut() {
|
||||
signOut();
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
url: '/',
|
||||
label: 'Inicio'
|
||||
},
|
||||
{
|
||||
url: '/calendar',
|
||||
label: 'Calendario'
|
||||
},
|
||||
{
|
||||
url: '/articles',
|
||||
label: 'Artículos'
|
||||
},
|
||||
{
|
||||
url: '/fanzines',
|
||||
label: 'Fanzines'
|
||||
},
|
||||
{
|
||||
url: '/shop',
|
||||
label: 'Tienda'
|
||||
},
|
||||
{
|
||||
url: '/apps',
|
||||
label: 'Apps'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="mx-3 flex items-center justify-between">
|
||||
<a href="/">
|
||||
<img src="/logo.webp" class="me-3 h-12 sm:h-14" alt="Bitcoin Txoko Logo" />
|
||||
</a>
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if $isAuthenticated && $user}
|
||||
{#await $user.fetchProfile() then profile}
|
||||
{#if profile}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center gap-2 rounded-full bg-foreground px-4 py-2 shadow-sm hover:shadow-lg"
|
||||
>
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={profile.image}
|
||||
alt={profile.displayName ? profile.displayName : profile.name}
|
||||
/>
|
||||
<Avatar.Fallback>
|
||||
{profile.displayName
|
||||
? profile.displayName.slice(0, 2)
|
||||
: profile.name?.slice(0, 2)}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<Menu class="text-background" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="bg-card">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label>
|
||||
{profile.displayName ? profile.displayName : profile.name}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
{#each tabs as tab (tab.url)}
|
||||
<DropdownMenu.Item>
|
||||
<a href={tab.url}>{tab.label}</a>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={toggleMode}>
|
||||
<SunIcon
|
||||
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
|
||||
/>
|
||||
<MoonIcon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
|
||||
/>
|
||||
<span class="text-foreground">Tema</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item>
|
||||
<button on:click={handleSignOut} class="text-destructive">Cerrar sesión</button>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded-full bg-foreground p-3 shadow-md">
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class="border-r border-r-black px-2 font-semibold text-background">
|
||||
Empezar
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Iniciar sesión</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Iniciar sesión con una extensión de navegador NIP-07, por ejemplo Alby o nos2x.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<button
|
||||
class={buttonVariants({ variant: 'default' })}
|
||||
on:click={handleSignIn}
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
id="user-menu-item-1"
|
||||
>
|
||||
Conectarse con extensión
|
||||
</button>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Menu class="text-background" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
{#each tabs as tab (tab.url)}
|
||||
<DropdownMenu.Item>
|
||||
<a href={tab.url}>{tab.label}</a>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={toggleMode}>
|
||||
<SunIcon
|
||||
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
|
||||
/>
|
||||
<MoonIcon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
|
||||
/>
|
||||
<span class="text-foreground">Tema</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'; // Import CSS here instead of <style>
|
||||
import geohash from 'latlon-geohash';
|
||||
|
||||
export let geohashString; // e.g., "u4pruydqqvj"
|
||||
let mapContainer;
|
||||
|
||||
onMount(() => {
|
||||
// Decode Geohash to [lat, lon]
|
||||
const { lat, lon } = geohash.decode(geohashString);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: mapContainer,
|
||||
style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
center: [lon, lat], // [longitude, latitude]
|
||||
zoom: 12
|
||||
});
|
||||
|
||||
// Wait for the map to fully load before adding the marker
|
||||
map.on('load', () => {
|
||||
// Force the map to recalculate its container size
|
||||
map.resize();
|
||||
|
||||
// Add marker and ensure it's centered
|
||||
new maplibregl.Marker().setLngLat([lon, lat]).addTo(map);
|
||||
|
||||
// Explicitly fly to the location to guarantee centering
|
||||
map.flyTo({
|
||||
center: [lon, lat],
|
||||
zoom: 12,
|
||||
essential: true
|
||||
});
|
||||
});
|
||||
|
||||
return () => map.remove(); // Clean up on unmount
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={mapContainer} class="h-96 w-full rounded-md"></div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
|
||||
import { toggleMode } from 'mode-watcher';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
</script>
|
||||
|
||||
<Button onclick={toggleMode} variant="outline" size="icon">
|
||||
<SunIcon
|
||||
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
|
||||
/>
|
||||
<MoonIcon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
|
||||
/>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!-- src/lib/PdfCover.svelte -->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let url: string;
|
||||
export let alt: string = 'PDF cover';
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let loading = true;
|
||||
let failed = false;
|
||||
|
||||
onMount(() => {
|
||||
// Dynamic import — only runs in the browser, never on the server
|
||||
import('pdfjs-dist')
|
||||
.then((pdfjsLib) => {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
return pdfjsLib.getDocument(url).promise;
|
||||
})
|
||||
.then((pdf) => pdf.getPage(1))
|
||||
.then((page) => {
|
||||
const desiredWidth = 240;
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const scale = desiredWidth / viewport.width;
|
||||
const scaled = page.getViewport({ scale });
|
||||
|
||||
canvas.width = scaled.width;
|
||||
canvas.height = scaled.height;
|
||||
|
||||
return page.render({
|
||||
canvasContext: canvas.getContext('2d')!,
|
||||
viewport: scaled
|
||||
}).promise;
|
||||
})
|
||||
.then(() => {
|
||||
loading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
loading = false;
|
||||
failed = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative aspect-[3/4] w-full overflow-hidden rounded bg-gray-100">
|
||||
{#if loading}
|
||||
<div class="absolute inset-0 animate-pulse bg-gradient-to-br from-gray-200 to-gray-300" />
|
||||
{:else if failed}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-400">
|
||||
<span class="text-4xl">📄</span>
|
||||
<span class="text-xs">{alt}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="h-full w-full object-contain"
|
||||
class:hidden={loading || failed}
|
||||
aria-label={alt}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
// import { _ } from '../../services/i18n'
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
|
||||
export let product;
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full max-w-sm">
|
||||
<div class="flex h-48 w-full items-start justify-center rounded-md">
|
||||
<img
|
||||
src={product.images[0].src}
|
||||
class="max-h-full max-w-full rounded-md object-contain"
|
||||
alt={product.images[0].alt}
|
||||
/>
|
||||
</div>
|
||||
<Card.Header>
|
||||
<Card.Title class="hover:text-neutral-200">
|
||||
<a href="/shop/{product.choiceKey}">
|
||||
{product.name}
|
||||
</a>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Footer class="flex-col">
|
||||
<form class="w-full" method="POST" action={product.action}>
|
||||
{#if product.disabled}
|
||||
<Button class="w-full" disabled>agotado</Button>
|
||||
{:else}
|
||||
<Button class="w-full justify-center" value={product.choiceKey}>
|
||||
<button class="flex justify-center gap-1" name="choiceKey" value={product.choiceKey}>
|
||||
Comprar por
|
||||
<span class="flex">
|
||||
{#if product.currency === 'sats'}
|
||||
{product.price}
|
||||
<svg
|
||||
data-v-52a72b4a=""
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="mt-0.5 h-8"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.75 18.5V21h-1.5v-2.5h1.5zM17 16.75H7v-1.5h10v1.5zM17 12.75H7v-1.5h10v1.5zM17 8.75H7v-1.5h10v1.5zM12.75 3v2.5h-1.5V3h1.5z"
|
||||
clip-rule="evenodd"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
{:else}
|
||||
{product.price}{product.currency}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</form>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
// import { _ } from '../../services/i18n'
|
||||
import Check from '@lucide/svelte/icons/check';
|
||||
import MailQuestion from '@lucide/svelte/icons/mail-question';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||
import Button from '$lib/components/ui/button/button.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { ndk, user, isAuthenticated } from '$lib/utils/auth';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export let event: NDKEvent;
|
||||
|
||||
function canRsvp(event: NDKEvent) {
|
||||
const date = (new Date().getTime() / 1000).toFixed(0);
|
||||
if (Number(event.tagValue('end')) > Number(date)) return true;
|
||||
else return false;
|
||||
}
|
||||
|
||||
let selectedResponse = '';
|
||||
let isSubmitting = false;
|
||||
|
||||
// RSVP response options for kind 31925
|
||||
const rsvpOptions = [
|
||||
{ value: 'accepted', label: 'Yes, I will attend', emoji: '✅' },
|
||||
{ value: 'declined', label: 'No, I cannot attend', emoji: '❌' },
|
||||
{ value: 'tentative', label: 'Maybe, not sure yet', emoji: '❓' }
|
||||
];
|
||||
|
||||
async function handleSubmit(event: NDKEvent) {
|
||||
if (!selectedResponse) {
|
||||
toast.error('Please select an RSVP response');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$ndk || !$user) {
|
||||
toast.error('Please sign in first');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
// Create kind 31925 RSVP event
|
||||
const rsvpEvent = new NDKEvent($ndk);
|
||||
rsvpEvent.kind = 31925;
|
||||
rsvpEvent.content = '';
|
||||
|
||||
// Add required tags for RSVP
|
||||
rsvpEvent.tags = [
|
||||
['e', event.id], // Reference to the event being responded to
|
||||
['status', selectedResponse], // The RSVP response
|
||||
['a', `${event.kind}:${event.author.pubkey}:${event.dTag}`],
|
||||
['p', `${event.author.pubkey}`]
|
||||
];
|
||||
|
||||
// Sign and publish the event
|
||||
await rsvpEvent.sign();
|
||||
await rsvpEvent.publish();
|
||||
|
||||
toast.success(
|
||||
`RSVP sent: ${rsvpOptions.find((opt) => opt.value === selectedResponse)?.label}`
|
||||
);
|
||||
|
||||
// Reset form
|
||||
selectedResponse = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to send RSVP:', error);
|
||||
toast.error('Failed to send RSVP. Please try again.');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if canRsvp(event)}
|
||||
{#if $isAuthenticated && $user}
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class={buttonVariants({ variant: 'outline' })}>RSVP</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>RSVP</Dialog.Title>
|
||||
<Dialog.Description>Haznos saber si vas a venir al evento.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<form on:submit={() => handleSubmit(event)} class="flex items-center gap-2">
|
||||
<ToggleGroup.Root
|
||||
bind:value={selectedResponse}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
type="single"
|
||||
>
|
||||
<ToggleGroup.Item value="accepted" aria-label="Toggle accepted">
|
||||
<Check class="size-4" />
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="tentative" aria-label="Toggle tentative">
|
||||
<MailQuestion class="size-4" />
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="declined" aria-label="Toggle declined">
|
||||
<X class="size-4" />
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
<Button type="submit">{isSubmitting ? 'Enviando...' : 'Enviar'}</Button>
|
||||
</form>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{:else}
|
||||
<Button disabled>RSVP</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button disabled variant="secondary">terminado</Button>
|
||||
{/if}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import Hash from '@lucide/svelte/icons/hash';
|
||||
// import { PUBLIC_NOSTR_LONG_FORM_CLIENT } from '$env/static/public';
|
||||
|
||||
export let tags: string[] | string[][] = [];
|
||||
const isNostr = Array.isArray(tags[0]) && tags[0][0] === `t`;
|
||||
|
||||
const longFormClient: string = 'https://habla.news';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-2 font-mono">
|
||||
{#each tags as tag, i (tag)}
|
||||
<Badge variant="secondary" href="{longFormClient}/t/{tag[1]}">
|
||||
<Hash />{isNostr ? tag[1] : tag}
|
||||
</Badge>
|
||||
{#if tags[i + 1]}
|
||||
<span class="text-muted-bright"> </span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,41 @@
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
export interface BlossomFile {
|
||||
sha256: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mimeType: string | undefined;
|
||||
}
|
||||
|
||||
export interface BlossomDrive {
|
||||
name: string;
|
||||
description: string;
|
||||
servers: string[];
|
||||
files: BlossomFile[];
|
||||
}
|
||||
|
||||
export function parseDriveEvent(event: NDKEvent): BlossomDrive {
|
||||
const name = event.tags.find((t) => t[0] === 'name')?.[1] ?? 'Untitled Drive';
|
||||
const description = event.tags.find((t) => t[0] === 'description')?.[1] ?? '';
|
||||
const servers = event.tags.filter((t) => t[0] === 'server').map((t) => t[1]);
|
||||
|
||||
const files: BlossomFile[] = event.tags
|
||||
.filter((t) => t[0] === 'x')
|
||||
.map((t) => ({
|
||||
sha256: t[1],
|
||||
path: t[2],
|
||||
size: parseInt(t[3] ?? '0', 10),
|
||||
mimeType: t[4]
|
||||
}));
|
||||
|
||||
return { name, description, servers, files };
|
||||
}
|
||||
|
||||
export function getBlobUrl(sha256: string, servers: string[], ext = '.pdf'): string {
|
||||
const base = servers[0] ?? 'https://cdn.satellite.earth';
|
||||
return `${base.replace(/\/$/, '')}/${sha256}${ext}`;
|
||||
}
|
||||
|
||||
export function fileName(path: string): string {
|
||||
return path.split('/').pop() ?? path;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn("aspect-square size-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable("loading"),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Root from "./avatar.svelte";
|
||||
import Image from "./avatar-image.svelte";
|
||||
import Fallback from "./avatar-fallback.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
'flex flex-col gap-6 rounded-xl border bg-card pb-6 text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import emblaCarouselSvelte from "embla-carousel-svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Content/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-slot="carousel-content"
|
||||
class="overflow-hidden"
|
||||
use:emblaCarouselSvelte={{
|
||||
options: {
|
||||
container: "[data-embla-container]",
|
||||
slides: "[data-embla-slide]",
|
||||
...emblaCtx.options,
|
||||
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins: emblaCtx.plugins,
|
||||
}}
|
||||
onemblaInit={emblaCtx.onInit}
|
||||
>
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"flex",
|
||||
emblaCtx.orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
data-embla-container=""
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Item/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="carousel-item"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
class={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
emblaCtx.orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
data-embla-slide=""
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
|
||||
import type { WithoutChildren } from "bits-ui";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Next/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
{variant}
|
||||
{size}
|
||||
aria-disabled={!emblaCtx.canScrollNext}
|
||||
class={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
onclick={emblaCtx.scrollNext}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
>
|
||||
<ArrowRightIcon class="size-4" />
|
||||
<span class="sr-only">Next slide</span>
|
||||
</Button>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||
import type { WithoutChildren } from "bits-ui";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
{variant}
|
||||
{size}
|
||||
aria-disabled={!emblaCtx.canScrollPrev}
|
||||
class={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
onclick={emblaCtx.scrollPrev}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
<span class="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type CarouselAPI,
|
||||
type CarouselProps,
|
||||
type EmblaContext,
|
||||
setEmblaContext,
|
||||
} from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
opts = {},
|
||||
plugins = [],
|
||||
setApi = () => {},
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<CarouselProps> = $props();
|
||||
|
||||
let carouselState = $state<EmblaContext>({
|
||||
api: undefined,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
orientation,
|
||||
canScrollNext: false,
|
||||
canScrollPrev: false,
|
||||
handleKeyDown,
|
||||
options: opts,
|
||||
plugins,
|
||||
onInit,
|
||||
scrollSnaps: [],
|
||||
selectedIndex: 0,
|
||||
scrollTo,
|
||||
});
|
||||
|
||||
setEmblaContext(carouselState);
|
||||
|
||||
function scrollPrev() {
|
||||
carouselState.api?.scrollPrev();
|
||||
}
|
||||
|
||||
function scrollNext() {
|
||||
carouselState.api?.scrollNext();
|
||||
}
|
||||
|
||||
function scrollTo(index: number, jump?: boolean) {
|
||||
carouselState.api?.scrollTo(index, jump);
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!carouselState.api) return;
|
||||
carouselState.selectedIndex = carouselState.api.selectedScrollSnap();
|
||||
carouselState.canScrollNext = carouselState.api.canScrollNext();
|
||||
carouselState.canScrollPrev = carouselState.api.canScrollPrev();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
}
|
||||
|
||||
function onInit(event: CustomEvent<CarouselAPI>) {
|
||||
carouselState.api = event.detail;
|
||||
setApi(carouselState.api);
|
||||
|
||||
carouselState.scrollSnaps = carouselState.api.scrollSnapList();
|
||||
carouselState.api.on("select", onSelect);
|
||||
onSelect();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
carouselState.api?.off("select", onSelect);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="carousel"
|
||||
class={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
import type {
|
||||
EmblaCarouselSvelteType,
|
||||
default as emblaCarouselSvelte,
|
||||
} from "embla-carousel-svelte";
|
||||
import { getContext, hasContext, setContext } from "svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
export type CarouselAPI =
|
||||
NonNullable<NonNullable<EmblaCarouselSvelteType["$$_attributes"]>["on:emblaInit"]> extends (
|
||||
evt: CustomEvent<infer CarouselAPI>
|
||||
) => void
|
||||
? CarouselAPI
|
||||
: never;
|
||||
|
||||
type EmblaCarouselConfig = NonNullable<Parameters<typeof emblaCarouselSvelte>[1]>;
|
||||
|
||||
export type CarouselOptions = EmblaCarouselConfig["options"];
|
||||
export type CarouselPlugins = EmblaCarouselConfig["plugins"];
|
||||
|
||||
////
|
||||
|
||||
export type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugins;
|
||||
setApi?: (api: CarouselAPI | undefined) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
} & WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
||||
|
||||
const EMBLA_CAROUSEL_CONTEXT = Symbol("EMBLA_CAROUSEL_CONTEXT");
|
||||
|
||||
export type EmblaContext = {
|
||||
api: CarouselAPI | undefined;
|
||||
orientation: "horizontal" | "vertical";
|
||||
scrollNext: () => void;
|
||||
scrollPrev: () => void;
|
||||
canScrollNext: boolean;
|
||||
canScrollPrev: boolean;
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
options: CarouselOptions;
|
||||
plugins: CarouselPlugins;
|
||||
onInit: (e: CustomEvent<CarouselAPI>) => void;
|
||||
scrollTo: (index: number, jump?: boolean) => void;
|
||||
scrollSnaps: number[];
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export function setEmblaContext(config: EmblaContext): EmblaContext {
|
||||
setContext(EMBLA_CAROUSEL_CONTEXT, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function getEmblaContext(name = "This component") {
|
||||
if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) {
|
||||
throw new Error(`${name} must be used within a <Carousel.Root> component`);
|
||||
}
|
||||
return getContext<ReturnType<typeof setEmblaContext>>(EMBLA_CAROUSEL_CONTEXT);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Root from "./carousel.svelte";
|
||||
import Content from "./carousel-content.svelte";
|
||||
import Item from "./carousel-item.svelte";
|
||||
import Previous from "./carousel-previous.svelte";
|
||||
import Next from "./carousel-next.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Previous,
|
||||
Next,
|
||||
//
|
||||
Root as Carousel,
|
||||
Content as CarouselContent,
|
||||
Item as CarouselItem,
|
||||
Previous as CarouselPrevious,
|
||||
Next as CarouselNext,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
children: Snippet;
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Portal {...portalProps}>
|
||||
<Dialog.Overlay />
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dialog-content"
|
||||
class={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</DialogPrimitive.Content>
|
||||
</Dialog.Portal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="dialog-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-footer"
|
||||
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dialog-header"
|
||||
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="dialog-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="dialog-title"
|
||||
class={cn("text-lg font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||
@@ -0,0 +1,37 @@
|
||||
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";
|
||||
import Trigger from "./dialog-trigger.svelte";
|
||||
import Close from "./dialog-close.svelte";
|
||||
|
||||
const Root = DialogPrimitive.Root;
|
||||
const 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,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
bind:ref
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if indeterminate}
|
||||
<MinusIcon class="size-4" />
|
||||
{:else}
|
||||
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.()}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ContentProps & {
|
||||
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-content"
|
||||
{sideOffset}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
...restProps
|
||||
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-group-heading"
|
||||
data-inset={inset}
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.ItemProps & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
class={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import CircleIcon from "@lucide/svelte/icons/circle";
|
||||
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
class={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
{#if checked}
|
||||
<CircleIcon class="size-2 fill-current" />
|
||||
{/if}
|
||||
</span>
|
||||
{@render childrenProp?.({ checked })}
|
||||
{/snippet}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Separator
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-separator"
|
||||
class={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
inset,
|
||||
children,
|
||||
...restProps
|
||||
}: DropdownMenuPrimitive.SubTriggerProps & {
|
||||
inset?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
bind:ref
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
class={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronRightIcon class="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||
@@ -0,0 +1,49 @@
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
|
||||
import Content from "./dropdown-menu-content.svelte";
|
||||
import Group from "./dropdown-menu-group.svelte";
|
||||
import Item from "./dropdown-menu-item.svelte";
|
||||
import Label from "./dropdown-menu-label.svelte";
|
||||
import RadioGroup from "./dropdown-menu-radio-group.svelte";
|
||||
import RadioItem from "./dropdown-menu-radio-item.svelte";
|
||||
import Separator from "./dropdown-menu-separator.svelte";
|
||||
import Shortcut from "./dropdown-menu-shortcut.svelte";
|
||||
import Trigger from "./dropdown-menu-trigger.svelte";
|
||||
import SubContent from "./dropdown-menu-sub-content.svelte";
|
||||
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
|
||||
import GroupHeading from "./dropdown-menu-group-heading.svelte";
|
||||
const Sub = DropdownMenuPrimitive.Sub;
|
||||
const Root = DropdownMenuPrimitive.Root;
|
||||
|
||||
export {
|
||||
CheckboxItem,
|
||||
Content,
|
||||
Root as DropdownMenu,
|
||||
CheckboxItem as DropdownMenuCheckboxItem,
|
||||
Content as DropdownMenuContent,
|
||||
Group as DropdownMenuGroup,
|
||||
Item as DropdownMenuItem,
|
||||
Label as DropdownMenuLabel,
|
||||
RadioGroup as DropdownMenuRadioGroup,
|
||||
RadioItem as DropdownMenuRadioItem,
|
||||
Separator as DropdownMenuSeparator,
|
||||
Shortcut as DropdownMenuShortcut,
|
||||
Sub as DropdownMenuSub,
|
||||
SubContent as DropdownMenuSubContent,
|
||||
SubTrigger as DropdownMenuSubTrigger,
|
||||
Trigger as DropdownMenuTrigger,
|
||||
GroupHeading as DropdownMenuGroupHeading,
|
||||
Group,
|
||||
GroupHeading,
|
||||
Item,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
Root,
|
||||
Separator,
|
||||
Shortcut,
|
||||
Sub,
|
||||
SubContent,
|
||||
SubTrigger,
|
||||
Trigger,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "input",
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,25 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
|
||||
</script>
|
||||
|
||||
<ul
|
||||
bind:this={ref}
|
||||
data-slot="pagination-content"
|
||||
class={cn("flex flex-row items-center gap-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ul>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
aria-hidden="true"
|
||||
data-slot="pagination-ellipsis"
|
||||
class={cn("flex size-9 items-center justify-center", className)}
|
||||
{...restProps}
|
||||
>
|
||||
<EllipsisIcon class="size-4" />
|
||||
<span class="sr-only">More pages</span>
|
||||
</span>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li bind:this={ref} data-slot="pagination-item" {...restProps}>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { type Props, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
size = "icon",
|
||||
isActive,
|
||||
page,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.PageProps &
|
||||
Props & {
|
||||
isActive: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
{page.value}
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.Page
|
||||
bind:ref
|
||||
{page}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.NextButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<span>Next</span>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.NextButton
|
||||
bind:ref
|
||||
aria-label="Go to next page"
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
size: "default",
|
||||
variant: "ghost",
|
||||
class: "gap-1 px-2.5 sm:pr-2.5",
|
||||
}),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: PaginationPrimitive.PrevButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
<span>Previous</span>
|
||||
{/snippet}
|
||||
|
||||
<PaginationPrimitive.PrevButton
|
||||
bind:ref
|
||||
aria-label="Go to previous page"
|
||||
class={cn(
|
||||
buttonVariants({
|
||||
size: "default",
|
||||
variant: "ghost",
|
||||
class: "gap-1 px-2.5 sm:pl-2.5",
|
||||
}),
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,28 @@
|
||||
<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
|
||||
bind:page
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
class={cn("mx-auto flex w-full justify-center", className)}
|
||||
{count}
|
||||
{perPage}
|
||||
{siblingCount}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
import Trigger from "./popover-trigger.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
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 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn("", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Progress as ProgressPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
max = 100,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<ProgressPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="progress"
|
||||
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
|
||||
{value}
|
||||
{max}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
||||
></div>
|
||||
</ProgressPrimitive.Root>
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="separator"
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="skeleton"
|
||||
class={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...restProps}
|
||||
></div>
|
||||
@@ -0,0 +1,10 @@
|
||||
import Root from "./toggle-group.svelte";
|
||||
import Item from "./toggle-group-item.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
//
|
||||
Root as ToggleGroup,
|
||||
Item as ToggleGroupItem,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
|
||||
import { getToggleGroupCtx } from "./toggle-group.svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { type ToggleVariants, toggleVariants } from "$lib/components/ui/toggle/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
size,
|
||||
variant,
|
||||
...restProps
|
||||
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
|
||||
|
||||
const ctx = getToggleGroupCtx();
|
||||
</script>
|
||||
|
||||
<ToggleGroupPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={ctx.variant || variant}
|
||||
data-size={ctx.size || size}
|
||||
class={cn(
|
||||
toggleVariants({
|
||||
variant: ctx.variant || variant,
|
||||
size: ctx.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{value}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" module>
|
||||
import { getContext, setContext } from "svelte";
|
||||
import type { ToggleVariants } from "$lib/components/ui/toggle/index.js";
|
||||
export function setToggleGroupCtx(props: ToggleVariants) {
|
||||
setContext("toggleGroup", props);
|
||||
}
|
||||
|
||||
export function getToggleGroupCtx() {
|
||||
return getContext<ToggleVariants>("toggleGroup");
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
|
||||
|
||||
setToggleGroupCtx({
|
||||
variant,
|
||||
size,
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<ToggleGroupPrimitive.Root
|
||||
bind:value={value as never}
|
||||
bind:ref
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"group/toggle-group data-[variant=outline]:shadow-xs flex w-fit items-center rounded-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Root from "./toggle.svelte";
|
||||
export {
|
||||
toggleVariants,
|
||||
type ToggleSize,
|
||||
type ToggleVariant,
|
||||
type ToggleVariants,
|
||||
} from "./toggle.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Toggle,
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const toggleVariants = tv({
|
||||
base: "hover:bg-muted hover:text-muted-foreground data-[state=on]:bg-accent data-[state=on]:text-accent-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border-input shadow-xs hover:bg-accent hover:text-accent-foreground border bg-transparent",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
|
||||
export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
|
||||
export type ToggleVariants = VariantProps<typeof toggleVariants>;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Toggle as TogglePrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
pressed = $bindable(false),
|
||||
class: className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
...restProps
|
||||
}: TogglePrimitive.RootProps & {
|
||||
variant?: ToggleVariant;
|
||||
size?: ToggleSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<TogglePrimitive.Root
|
||||
bind:ref
|
||||
bind:pressed
|
||||
data-slot="toggle"
|
||||
class={cn(toggleVariants({ variant, size }), className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "LNbits",
|
||||
"url": "https://bitcointxoko.org"
|
||||
},
|
||||
{
|
||||
"name": "BTCPay",
|
||||
"url": "https://btcpay.bitcointxoko.org"
|
||||
},
|
||||
{
|
||||
"name": "Blossom Server",
|
||||
"url": "https://loratu.bitcointxoko.org"
|
||||
},
|
||||
{
|
||||
"name": "Blossom Drive",
|
||||
"url": "https://blossom.bitcointxoko.org"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
[
|
||||
{
|
||||
"name": "Una Posada para Caminantes",
|
||||
"images": [
|
||||
{
|
||||
"alt": "una-posada-para-caminantes",
|
||||
"src": "https://loratu.bitcointxoko.org/7228b931ae8bda272498df8d40433bc9d4fbafc938f0d4f2ed5baa5a34f3ab6b.png",
|
||||
"title": "una-posada-para-caminantes"
|
||||
}
|
||||
],
|
||||
"description": "Antes de que existieran Bitcoin, los mercados libres digitales o la conversación global sobre soberanía tecnológica, Una posada para caminantes anticipó —con una precisión sorprendente— la aparición de redes paralelas fuera del alcance de los gobiernos tradicionales. En esta novela provocadora, Paul Rosenberg entrelaza ciencia, filosofía, criptografía y política para seguir a un puñado de personajes —científicos, hackers, periodistas, inversores— que se apartan de las instituciones del mundo oficial y comienzan a construir el suyo propio. Lo que descubren no es solo una nueva forma de pensar, sino una nueva forma de existir: libre, voluntaria y cifrada. A medio camino entre el relato de ideas y la advertencia visionaria, esta obra funciona como un tratado encubierto sobre soberanía individual, mercados libres y resistencia pacífica al poder centralizado.",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/4xToJK4VnWhw5sWAGiS1rxZdCW6/pos",
|
||||
"choiceKey": "una-posada-para-caminantes",
|
||||
"price": "25,00",
|
||||
"currency": "€",
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"name": "Criptoria (Tapa Blanda)",
|
||||
"images": [
|
||||
{
|
||||
"alt": "criptoria-tapa-blanda",
|
||||
"src": "https://loratu.bitcointxoko.org/0a4492c4394b67793228d93f82883ff8c84f70f3cd67588b21bf2ab4efd54760.png",
|
||||
"title": "criptoria-tapa-blanda"
|
||||
}
|
||||
],
|
||||
"description": "Criptoria: de Turing a Nakamoto es una madriguera de madrigueras sobre las computadoras, las redes, la ciberseguridad y el dinero digital. Desde Grecia hasta Bitcoin, Criptoria narra una selección de hitos acontecidos antes y después de Alan Turing. El libro expone una amplia colección de personas, organizaciones, conceptos e inventos que evidencian la profunda relación histórica y cultural entre la matemática, la criptografía, la informática y la lucha por la soberanía individual. Criptoria está dirigido a todas las personas convencidas de que, más allá de las vías heredadas o impuestas, hay otras formas de organización social y económica que pueden y deben ser exploradas.",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/4xToJK4VnWhw5sWAGiS1rxZdCW6/pos",
|
||||
"choiceKey": "criptoria-tapa-blanda",
|
||||
"price": "25,00",
|
||||
"currency": "€",
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"name": "Criptoria (Tapa Dura)",
|
||||
"images": [
|
||||
{
|
||||
"alt": "criptoria-tapa-dura",
|
||||
"src": "https://loratu.bitcointxoko.org/0a4492c4394b67793228d93f82883ff8c84f70f3cd67588b21bf2ab4efd54760.png",
|
||||
"title": "criptoria-tapa-dura"
|
||||
}
|
||||
],
|
||||
"description": "Criptoria: de Turing a Nakamoto es una madriguera de madrigueras sobre las computadoras, las redes, la ciberseguridad y el dinero digital. Desde Grecia hasta Bitcoin, Criptoria narra una selección de hitos acontecidos antes y después de Alan Turing. El libro expone una amplia colección de personas, organizaciones, conceptos e inventos que evidencian la profunda relación histórica y cultural entre la matemática, la criptografía, la informática y la lucha por la soberanía individual. Criptoria está dirigido a todas las personas convencidas de que, más allá de las vías heredadas o impuestas, hay otras formas de organización social y económica que pueden y deben ser exploradas.",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/4xToJK4VnWhw5sWAGiS1rxZdCW6/pos",
|
||||
"choiceKey": "criptoria-tapa-dura",
|
||||
"price": "43,00",
|
||||
"currency": "€",
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"name": "BBQ en Eibar",
|
||||
"images": [
|
||||
{
|
||||
"alt": "bbq",
|
||||
"src": "https://loratu.bitcointxoko.com/9119d4e30ab94f352ae748daf6d849811d7af24e10c345489631c5638c5ba060.webp",
|
||||
"title": "bbq"
|
||||
}
|
||||
],
|
||||
"description": "Bote para cubrir los gastos de carne, carbón, bebidas, etc. Uno de nosotros en Eibar se encargará de hacer la compra antes del meetup.",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/41wJSH8CL4aMBzUa7UpnAKH1NcEz/pos",
|
||||
"choiceKey": "bbq-eibar",
|
||||
"price": "20.000",
|
||||
"currency": "sats",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"name": "Calcetines",
|
||||
"images": [
|
||||
{
|
||||
"alt": "socks",
|
||||
"src": "https://loratu.bitcointxoko.org/6f5f418ac2dc5b67b00d2c19a829bfd77d5eda23240d87f9b35d4489e7cdec85.webp",
|
||||
"title": "socks"
|
||||
},
|
||||
{
|
||||
"alt": "socks",
|
||||
"src": "https://loratu.bitcointxoko.org/5ff548bdbf323bd56846775a5f86c9892197e867b3e4f545602d55217bcb293f.webp",
|
||||
"title": "socks"
|
||||
},
|
||||
{
|
||||
"alt": "socks",
|
||||
"src": "https://loratu.bitcointxoko.org/e8424ad97d9c2e852b5438fdc5dac6d38a0091d672da173fc96f1c75870ceabd.webp",
|
||||
"title": "socks"
|
||||
},
|
||||
{
|
||||
"alt": "socks",
|
||||
"src": "https://loratu.bitcointxoko.org/f1b028dfc2365f81a8fa3c3d3169bc3b532a61b759949279f66776363b3fcc66.webp",
|
||||
"title": "socks"
|
||||
}
|
||||
],
|
||||
"description": "¡Ponte a la moda Bitcoin con nuestros calcetines de Bitcoin! 🧦 ¡Da pasos seguros con estilo mientras muestras tu amor por Bitcoin! 🚀",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/4xToJK4VnWhw5sWAGiS1rxZdCW6/pos",
|
||||
"choiceKey": "calcetines",
|
||||
"price": "10,00",
|
||||
"currency": "€",
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"name": "Calcetines x 3",
|
||||
"images": [
|
||||
{
|
||||
"alt": "socks",
|
||||
"src": "https://loratu.bitcointxoko.org/3199d0c2c665f7eec0e2df89eb9d5cae7da5e09de566c95449b82dcd7081bc16.webp",
|
||||
"title": "socks"
|
||||
}
|
||||
],
|
||||
"description": "Presentamos nuestros nuevos calcetines Bitcoin, la forma más cómoda de caminar hacia el futuro financiero. 🧦 ¡Lleva consigo el espíritu de Satoshi en cada paso! 🚀 ",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/4xToJK4VnWhw5sWAGiS1rxZdCW6/pos",
|
||||
"choiceKey": "calcetines-x-3",
|
||||
"price": "25,00",
|
||||
"currency": "€",
|
||||
"disabled": false
|
||||
},
|
||||
{
|
||||
"name": "Tarjeta NFC",
|
||||
"images": [
|
||||
{
|
||||
"alt": "tarjeta nfc",
|
||||
"src": "https://loratu.bitcointxoko.org/c6ec6f400675cdccb43829c1f41f1a1261b28cae0c85107765caa6382dee1254.jpg"
|
||||
}
|
||||
],
|
||||
"description": "Un chip NXP NTAG424 DNA dentro de una tarjeta NFC PVC blanca. ",
|
||||
"action": "https://btcpay.bitcointxoko.org/apps/41wJSH8CL4aMBzUa7UpnAKH1NcEz/pos",
|
||||
"choiceKey": "boltcard",
|
||||
"price": "1.000",
|
||||
"currency": "sats",
|
||||
"disabled": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type NDKUser, NDKKind, NDKNip07Signer, NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const currentUser = writable<NDKUser | null>(null);
|
||||
|
||||
// Store the user's current follower list as an array of pubkeys
|
||||
// export const currentUserFollows = writable<string[]>([]);
|
||||
// Store the user's app settings for Listr
|
||||
// export const currentUserSettings = writable<App.UserSettings | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch the follows for a user, formatted as an array of pubkeys
|
||||
* @param user the user who you want to fetch follows for
|
||||
* @returns an array of pubkeys of all the users they follow
|
||||
*/
|
||||
// export async function fetchUserFollows(user: NDKUser): Promise<string[]> {
|
||||
// const followsSet = await user.follows();
|
||||
// return Array.from(followsSet).map((user) => user.pubkey);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Fetch the app specific settings for Listr
|
||||
* @param user the user you want to fetch settings for
|
||||
* @returns A promise object with the settings or null
|
||||
*/
|
||||
// export async function fetchUserSettings(user: NDKUser): Promise<App.UserSettings> {
|
||||
// if (!user || !user.ndk) throw new Error("No logged in user or NDK instance");
|
||||
// const ndk = user.ndk;
|
||||
// const settingsEvents = await ndk.fetchEvents({
|
||||
// kinds: [NDKKind.AppSpecificData],
|
||||
// authors: [user.pubkey],
|
||||
// "#d": ["listr/settings/v1"],
|
||||
// });
|
||||
// const eventsArray = Array.from(settingsEvents);
|
||||
|
||||
// let settings: App.UserSettings = { devMode: false };
|
||||
|
||||
// if (eventsArray.length === 1) {
|
||||
// const event: NDKEvent = eventsArray[0] as NDKEvent;
|
||||
// let signer: NDKNip07Signer;
|
||||
// if (!ndk.signer) {
|
||||
// signer = new NDKNip07Signer();
|
||||
// ndk.signer = signer;
|
||||
// }
|
||||
// await event.decrypt(user);
|
||||
// settings = JSON.parse(event.content);
|
||||
// } else if (eventsArray.length > 1) {
|
||||
// console.error("Many settings events", eventsArray);
|
||||
// }
|
||||
// return settings;
|
||||
// }
|
||||
|
||||
export default currentUser;
|
||||
@@ -0,0 +1,14 @@
|
||||
// stores.ts
|
||||
import { derived } from 'svelte/store';
|
||||
import type { NDKEventStore, ExtendedBaseType } from '@nostr-dev-kit/ndk-svelte';
|
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
export function createRecentEventsStore(
|
||||
events: NDKEventStore<ExtendedBaseType<NDKEvent>>,
|
||||
count: number = 5
|
||||
) {
|
||||
return derived(events, ($events) => {
|
||||
if (!$events) return [];
|
||||
return [...$events].sort((a, b) => b.created_at - a.created_at).slice(0, count);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// import { PUBLIC_PUBKEY, PUBLIC_RELAYS } from '$env/static/public';
|
||||
import { browser } from '$app/environment';
|
||||
import type { NDKCacheAdapter } from '@nostr-dev-kit/ndk';
|
||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
|
||||
import NDKSvelte from '@nostr-dev-kit/ndk-svelte';
|
||||
import NDK, { NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const relayUrls: string[] = [
|
||||
'wss://nos.lol',
|
||||
'wss://nostr.wine',
|
||||
'wss://relay.damus.io',
|
||||
'wss://koru.bitcointxoko.org'
|
||||
];
|
||||
// let relayUrls: string[];
|
||||
// relayUrls = PUBLIC_RELAYS.split(',');
|
||||
|
||||
// Create a new NDK instance with explicit relays
|
||||
// const ndk = new NDK({
|
||||
// explicitRelayUrls: relayUrls
|
||||
// });
|
||||
// Now connect to specified relays
|
||||
// if (browser) {
|
||||
// ndk.connect().then(() => {
|
||||
// console.log('Connected');
|
||||
// });
|
||||
// }
|
||||
|
||||
let cacheAdapter: NDKCacheAdapter | undefined;
|
||||
|
||||
if (browser) {
|
||||
cacheAdapter = new NDKCacheAdapterDexie({ dbName: 'gugara' });
|
||||
}
|
||||
|
||||
// export const ndkStore = new NDKSvelte({
|
||||
// explicitRelayUrls: [
|
||||
// "wss://purplepag.es",
|
||||
// "wss://relay.nostr.band",
|
||||
// "wss://nos.lol",
|
||||
// 'wss://relay.snort.social',
|
||||
// "wss://relay.damus.io",
|
||||
// "wss://nostr.wine",
|
||||
// "wss://bostr.bitcointxoko.com",
|
||||
// "ws://localhost:8080",
|
||||
// ],
|
||||
// explicitRelayUrls: relayUrls,
|
||||
// cacheAdapter: cacheAdapter,
|
||||
// });
|
||||
|
||||
// ndkStore.connect().then(() => console.log("NDK Connected"));
|
||||
|
||||
// Create a singleton instance that is the default export
|
||||
// const ndk = writable(ndkStore);
|
||||
|
||||
const ndkStore = new NDKSvelte({
|
||||
explicitRelayUrls: relayUrls,
|
||||
cacheAdapter: cacheAdapter,
|
||||
enableOutboxModel: true
|
||||
});
|
||||
|
||||
ndkStore.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({
|
||||
ndk: ndkStore
|
||||
});
|
||||
|
||||
ndkStore
|
||||
.connect()
|
||||
.catch((error: Error) => {
|
||||
console.error('Failed to connect to relays:', error);
|
||||
})
|
||||
.then(() => console.log('NDK Connected'));
|
||||
|
||||
const ndk = writable(ndkStore);
|
||||
|
||||
export const bunkerNDKStore = new NDK({
|
||||
explicitRelayUrls: ['wss://relay.nsec.app'],
|
||||
enableOutboxModel: true
|
||||
});
|
||||
bunkerNDKStore.connect().then(() => console.log('Bunker NDK Connected'));
|
||||
export const bunkerNdk = writable(bunkerNDKStore);
|
||||
|
||||
// export const user = ndk.getUser({ npub: PUBLIC_PUBKEY });
|
||||
|
||||
export default ndk;
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface BunkerConnection {
|
||||
pubkey: string;
|
||||
relayUrl: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export interface SignInError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
pubkey: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
|
||||
export function readingTime(text: string): number {
|
||||
const wpm = 225;
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return Math.ceil(words / wpm);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import NDK, {
|
||||
NDKNip07Signer,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
NDKUser
|
||||
} from '@nostr-dev-kit/ndk';
|
||||
|
||||
export const ndk = writable<NDK | null>(null);
|
||||
export const user = writable<NDKUser | null>(null);
|
||||
export const isAuthenticated = writable<boolean>(false);
|
||||
|
||||
const STORAGE_KEY = 'nostr_pubkey';
|
||||
|
||||
export enum SigninMethod {
|
||||
Nip07 = 'nip07',
|
||||
Nip46 = 'nip46',
|
||||
PK = 'pk'
|
||||
}
|
||||
|
||||
export async function initializeNDK() {
|
||||
const ndkInstance = new NDK({
|
||||
explicitRelayUrls: ['wss://nostr.wine', 'wss://nos.lol']
|
||||
});
|
||||
|
||||
await ndkInstance.connect();
|
||||
ndk.set(ndkInstance);
|
||||
console.log('NDK Connected');
|
||||
|
||||
// Check for stored public key
|
||||
await checkStoredUser(ndkInstance);
|
||||
}
|
||||
|
||||
async function checkStoredUser(ndkInstance: NDK) {
|
||||
const storedPubkey = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (storedPubkey) {
|
||||
try {
|
||||
// Create a new NDK instance WITH signer for authenticated users
|
||||
const signerNDK = new NDK({
|
||||
explicitRelayUrls: ['wss://nostr.wine', 'wss://nos.lol'],
|
||||
signer: new NDKNip07Signer()
|
||||
});
|
||||
|
||||
await signerNDK.connect();
|
||||
|
||||
// Create user from stored pubkey
|
||||
const ndkUser = new NDKUser({ pubkey: storedPubkey });
|
||||
ndkUser.ndk = signerNDK;
|
||||
|
||||
// Set the NDK instance with signer
|
||||
ndk.set(signerNDK);
|
||||
user.set(ndkUser);
|
||||
isAuthenticated.set(true);
|
||||
|
||||
console.log('User restored with signer:', storedPubkey);
|
||||
} catch (error) {
|
||||
console.error('Failed to restore user with signer:', error);
|
||||
// Fall back to non-authenticated state
|
||||
ndk.set(ndkInstance);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} else {
|
||||
// No stored user, use NDK without signer
|
||||
ndk.set(ndkInstance);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInWithNIP07() {
|
||||
try {
|
||||
const signerNDK = new NDK({
|
||||
explicitRelayUrls: ['wss://nostr.wine', 'wss://nos.lol'],
|
||||
signer: new NDKNip07Signer()
|
||||
});
|
||||
|
||||
await signerNDK.connect();
|
||||
|
||||
// This will prompt the user for permission
|
||||
const ndkUser = await signerNDK.signer!.user();
|
||||
|
||||
// Store the public key for future sessions
|
||||
localStorage.setItem(STORAGE_KEY, ndkUser.pubkey);
|
||||
|
||||
ndk.set(signerNDK);
|
||||
user.set(ndkUser);
|
||||
isAuthenticated.set(true);
|
||||
|
||||
console.log('User signed in:', ndkUser.pubkey);
|
||||
localStorage.setItem('nostrSigninMethod', SigninMethod.Nip07);
|
||||
return ndkUser;
|
||||
} catch (error) {
|
||||
console.error('NIP-07 sign in failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function userFromNip46(
|
||||
ndk: NDK,
|
||||
bunkerNdk: NDK,
|
||||
token?: string
|
||||
): Promise<NDKUser | null> {
|
||||
let localSigner: NDKPrivateKeySigner;
|
||||
let user: NDKUser | null = null;
|
||||
if (browser) {
|
||||
const storedKey = localStorage.getItem('Nip46LocalSignerPK');
|
||||
const targetNpub = localStorage.getItem('Nip46TargetNpub');
|
||||
// If we have a local PK and a target npub, try and sign in.
|
||||
if (storedKey && targetNpub) {
|
||||
console.log('stored key and target npub');
|
||||
localSigner = new NDKPrivateKeySigner(storedKey);
|
||||
const targetUser = ndk.getUser({ npub: targetNpub });
|
||||
const remoteSigner = new NDKNip46Signer(bunkerNdk, targetUser!.pubkey, localSigner);
|
||||
ndk.signer = remoteSigner;
|
||||
await remoteSigner.blockUntilReady();
|
||||
user = await remoteSigner.user();
|
||||
}
|
||||
// If we're missing one of the above but we have a token, try and create a new nsecBunker connection
|
||||
else if (token) {
|
||||
const localSigner = NDKPrivateKeySigner.generate();
|
||||
localStorage.setItem('Nip46LocalSignerPK', localSigner.privateKey as string);
|
||||
const remoteSigner = new NDKNip46Signer(bunkerNdk, token, localSigner);
|
||||
ndk.signer = remoteSigner;
|
||||
try {
|
||||
await remoteSigner.blockUntilReady();
|
||||
user = await remoteSigner.user();
|
||||
localStorage.setItem('Nip46TargetNpub', user.npub);
|
||||
} catch (error) {
|
||||
// toast.error(error as string);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
localStorage.setItem('nostrSigninMethod', SigninMethod.Nip46);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export function signOut() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
user.set(null);
|
||||
isAuthenticated.set(false);
|
||||
console.log('User signed out');
|
||||
|
||||
// Reinitialize NDK without signer
|
||||
initializeNDK();
|
||||
console.log('Reinitalised NDK');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user