feat: Add gift wrap infrastructure and inbox command

Core infrastructure for NIP-59 gift wrap support:

**Database Schema (v16):**
- Add decryptedGiftWraps table with indexed fields
- Add giftWrapErrors table for tracking failed decrypts
- Indexes: giftWrapId, sealPubkey, receivedAt, decryptedAt

**Gift Wrap Service (`gift-wrap.ts`):**
- Simple sync strategy: paginated loading + real-time subscription
- Uses createTimelineLoader for initial sync (loads all pages)
- Subscribes to new gift wraps via relay subscriptions
- On-demand decryption with Dexie caching
- Batch decrypt with progress tracking and error handling
- Integrates with applesauce GiftWrapsModel and unlockGiftWrap

**Inbox Command & Viewer:**
- `inbox` - View pending/decrypted/failed gift wraps
- `inbox --decrypt-pending` - Decrypt all pending
- `inbox --clear-failed` - Reset failed attempts
- Auto-syncs on mount using user's inbox relays (NIP-65)
- Shows stats: pending/decrypted/failed counts
- Paginated list of decrypted gift wraps with metadata
- Real-time updates via giftWrapManager state observable

**Integration:**
- Added "inbox" to AppId type
- Wired up InboxViewer in WindowRenderer (lazy loaded)
- Added command reconstructor for inbox commands
- Updated chat command description to mention NIP-17

No password/encryption layer yet - decrypted content stored in
plain Dexie tables. NIP-17 adapter implementation next.
This commit is contained in:
Claude
2026-01-14 19:56:39 +00:00
parent 5fa2a1c9b8
commit 01e5de882e
8 changed files with 733 additions and 4 deletions

View File

@@ -0,0 +1,327 @@
import { useEffect, useState } from "react";
import { use$ } from "applesauce-react/hooks";
import {
Package,
Loader2,
AlertCircle,
CheckCircle2,
Trash2,
} from "lucide-react";
import giftWrapManager from "@/services/gift-wrap";
import accountManager from "@/services/accounts";
import db, { DecryptedGiftWrap } from "@/services/db";
import { getInboxes } from "applesauce-core/helpers";
interface InboxViewerProps {
action?: "decrypt-pending" | "clear-failed" | null;
}
export default function InboxViewer({ action }: InboxViewerProps) {
const account = use$(accountManager.active$);
const syncState = use$(giftWrapManager.state);
const [decrypted, setDecrypted] = useState<DecryptedGiftWrap[]>([]);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const [decrypting, setDecrypting] = useState(false);
const pubkey = account?.pubkey;
// Load decrypted gift wraps from Dexie
useEffect(() => {
if (!pubkey) return;
db.decryptedGiftWraps
.orderBy("receivedAt")
.reverse()
.offset(page * 50)
.limit(50)
.toArray()
.then(setDecrypted);
}, [pubkey, page, syncState.decryptedCount]);
// Initial sync on mount
useEffect(() => {
if (!pubkey) return;
const syncGiftWraps = async () => {
setLoading(true);
try {
// Get inbox relays from user's relay list
const inboxRelays = Array.from(
await getInboxes(pubkey).then((set) => set || new Set()),
);
// Fallback to default relays if no inbox relays
const relays =
inboxRelays.length > 0
? inboxRelays
: [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
];
await giftWrapManager.syncAll(pubkey, relays);
// Subscribe to new gift wraps
giftWrapManager.subscribeToNew(pubkey, relays);
} catch (error) {
console.error("[InboxViewer] Sync error:", error);
} finally {
setLoading(false);
}
};
syncGiftWraps();
return () => {
giftWrapManager.unsubscribe(pubkey);
};
}, [pubkey]);
// Handle action flags
useEffect(() => {
if (!action || !pubkey || !account) return;
const handleAction = async () => {
if (action === "decrypt-pending") {
await handleDecryptAll();
} else if (action === "clear-failed") {
await giftWrapManager.clearErrors();
await giftWrapManager.updateCounts(pubkey);
}
};
handleAction();
}, [action, pubkey, account]);
const handleDecryptAll = async () => {
if (!pubkey || !account) return;
setDecrypting(true);
try {
// Get pending gift wrap IDs
const pending = await new Promise<string[]>((resolve) => {
giftWrapManager.getPendingCount(pubkey).subscribe((set) => {
// Get IDs from EventStore
const ids = Array.from(set as any).map((e: any) => e.id);
resolve(ids);
});
});
if (pending.length === 0) {
console.log("[InboxViewer] No pending gift wraps to decrypt");
setDecrypting(false);
return;
}
console.log(`[InboxViewer] Decrypting ${pending.length} gift wraps...`);
// Decrypt batch
for await (const result of giftWrapManager.decryptBatch(
pending,
account,
)) {
if (result.status === "success") {
// Refresh decrypted list
const updated = await db.decryptedGiftWraps
.orderBy("receivedAt")
.reverse()
.offset(page * 50)
.limit(50)
.toArray();
setDecrypted(updated);
}
}
// Update counts
await giftWrapManager.updateCounts(pubkey);
console.log("[InboxViewer] Batch decrypt complete");
} catch (error) {
console.error("[InboxViewer] Decrypt error:", error);
} finally {
setDecrypting(false);
}
};
const handleClearAll = async () => {
if (!confirm("Clear all decrypted gift wraps? This cannot be undone.")) {
return;
}
await giftWrapManager.clearDecrypted();
setDecrypted([]);
if (pubkey) {
await giftWrapManager.updateCounts(pubkey);
}
};
if (!account || !pubkey) {
return (
<div className="flex items-center justify-center h-full text-base-content/50">
<div className="text-center space-y-4">
<Package className="w-12 h-12 mx-auto opacity-50" />
<p>No active account. Please login to view gift wraps.</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header with status */}
<div className="flex flex-col gap-4 p-4 border-b border-base-300">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Package className="w-5 h-5" />
Gift Wrap Inbox
</h2>
{loading && (
<div className="flex items-center gap-2 text-sm text-base-content/60">
<Loader2 className="w-4 h-4 animate-spin" />
Syncing...
</div>
)}
</div>
{/* Stats */}
<div className="flex gap-4 flex-wrap">
<div className="flex items-center gap-2 px-3 py-2 bg-warning/10 text-warning rounded-lg">
<Package className="w-4 h-4" />
<span className="font-medium">{syncState.pendingCount}</span>
<span className="text-sm">Pending</span>
</div>
<div className="flex items-center gap-2 px-3 py-2 bg-success/10 text-success rounded-lg">
<CheckCircle2 className="w-4 h-4" />
<span className="font-medium">{syncState.decryptedCount}</span>
<span className="text-sm">Decrypted</span>
</div>
{syncState.failedCount > 0 && (
<div className="flex items-center gap-2 px-3 py-2 bg-error/10 text-error rounded-lg">
<AlertCircle className="w-4 h-4" />
<span className="font-medium">{syncState.failedCount}</span>
<span className="text-sm">Failed</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 flex-wrap">
<button
onClick={handleDecryptAll}
disabled={decrypting || syncState.pendingCount === 0}
className="btn btn-sm btn-primary"
>
{decrypting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Decrypting...
</>
) : (
<>Decrypt All Pending ({syncState.pendingCount})</>
)}
</button>
{syncState.failedCount > 0 && (
<button
onClick={() => giftWrapManager.clearErrors()}
className="btn btn-sm btn-ghost"
>
Clear Failed Attempts
</button>
)}
{syncState.decryptedCount > 0 && (
<button
onClick={handleClearAll}
className="btn btn-sm btn-ghost text-error"
>
<Trash2 className="w-4 h-4" />
Clear All Decrypted
</button>
)}
</div>
</div>
{/* Decrypted gift wraps list */}
<div className="flex-1 overflow-y-auto">
{decrypted.length === 0 ? (
<div className="flex items-center justify-center h-full text-base-content/50">
<div className="text-center space-y-4">
<Package className="w-12 h-12 mx-auto opacity-50" />
<p>No decrypted gift wraps yet.</p>
{syncState.pendingCount > 0 && (
<button
onClick={handleDecryptAll}
disabled={decrypting}
className="btn btn-primary btn-sm"
>
Decrypt {syncState.pendingCount} Pending
</button>
)}
</div>
</div>
) : (
<div className="divide-y divide-base-300">
{decrypted.map((wrap) => (
<div key={wrap.giftWrapId} className="p-4 hover:bg-base-200/50">
<div className="flex items-start gap-3">
<Package className="w-5 h-5 text-base-content/40 mt-1" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">
Kind {wrap.rumor.kind}
</span>
{wrap.sealPubkey && (
<span className="text-xs text-base-content/60 font-mono">
from {wrap.sealPubkey.slice(0, 8)}...
</span>
)}
</div>
<p className="text-sm text-base-content/80 line-clamp-2">
{wrap.rumor.content.slice(0, 200)}
{wrap.rumor.content.length > 200 && "..."}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-base-content/60">
<span>
Received:{" "}
{new Date(wrap.receivedAt * 1000).toLocaleString()}
</span>
<span>
Decrypted:{" "}
{new Date(wrap.decryptedAt * 1000).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Load more button */}
{decrypted.length >= 50 && (
<div className="p-4 text-center">
<button
onClick={() => setPage((p) => p + 1)}
className="btn btn-sm btn-ghost"
>
Load More
</button>
</div>
)}
</div>
{/* Footer with sync info */}
{syncState.lastSyncAt > 0 && (
<div className="p-2 text-xs text-center text-base-content/50 border-t border-base-300">
Last synced: {new Date(syncState.lastSyncAt).toLocaleString()}
</div>
)}
</div>
);
}

View File

@@ -33,6 +33,9 @@ const ChatViewer = lazy(() =>
const GroupListViewer = lazy(() =>
import("./GroupListViewer").then((m) => ({ default: m.GroupListViewer })),
);
const InboxViewer = lazy(() =>
import("./InboxViewer").then((m) => ({ default: m.default })),
);
const SpellsViewer = lazy(() =>
import("./SpellsViewer").then((m) => ({ default: m.SpellsViewer })),
);
@@ -192,6 +195,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
);
}
break;
case "inbox":
content = <InboxViewer action={window.props.action} />;
break;
case "spells":
content = <SpellsViewer />;
break;

View File

@@ -139,6 +139,19 @@ export function reconstructCommand(window: WindowInstance): string {
return "chat";
}
case "inbox": {
// Reconstruct inbox command with action flags
const { action } = props;
if (action === "decrypt-pending") {
return "inbox --decrypt-pending";
} else if (action === "clear-failed") {
return "inbox --clear-failed";
}
return "inbox";
}
default:
return appId; // Fallback to just the command name
}

View File

@@ -87,6 +87,22 @@ export interface LocalSpellbook {
deletedAt?: number;
}
export interface DecryptedGiftWrap {
giftWrapId: string; // kind 1059 event ID (primary key)
rumorId: string; // Unwrapped rumor ID
rumor: NostrEvent; // Actual unsigned event (JSON)
sealPubkey: string; // Who sent it (from seal)
decryptedAt: number; // When we decrypted
receivedAt: number; // Gift wrap created_at (for sorting)
}
export interface GiftWrapDecryptionError {
giftWrapId: string; // kind 1059 event ID (primary key)
attemptCount: number; // Number of failed decrypt attempts
lastAttempt: number; // Timestamp of last attempt
errorMessage: string; // Error message from last attempt
}
class GrimoireDb extends Dexie {
profiles!: Table<Profile>;
nip05!: Table<Nip05>;
@@ -98,6 +114,8 @@ class GrimoireDb extends Dexie {
blossomServers!: Table<CachedBlossomServerList>;
spells!: Table<LocalSpell>;
spellbooks!: Table<LocalSpellbook>;
decryptedGiftWraps!: Table<DecryptedGiftWrap>;
giftWrapErrors!: Table<GiftWrapDecryptionError>;
constructor(name: string) {
super(name);
@@ -333,6 +351,22 @@ class GrimoireDb extends Dexie {
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
});
// Version 16: Add gift wrap storage
this.version(16).stores({
profiles: "&pubkey",
nip05: "&nip05",
nips: "&id",
relayInfo: "&url",
relayAuthPreferences: "&url",
relayLists: "&pubkey, updatedAt",
relayLiveness: "&url",
blossomServers: "&pubkey, updatedAt",
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
decryptedGiftWraps: "&giftWrapId, sealPubkey, receivedAt, decryptedAt",
giftWrapErrors: "&giftWrapId, lastAttempt",
});
}
}

313
src/services/gift-wrap.ts Normal file
View File

@@ -0,0 +1,313 @@
import { BehaviorSubject, map, Subscription } from "rxjs";
import { createTimelineLoader } from "applesauce-loaders/loaders";
import { onlyEvents, mapEventsToStore } from "applesauce-core";
import { unlockGiftWrap, getGiftWrapSeal } from "applesauce-common/helpers";
import { GiftWrapsModel } from "applesauce-common/models";
import type { Signer } from "applesauce-signers";
import type { NostrEvent } from "@/types/nostr";
import eventStore from "./event-store";
import pool from "./relay-pool";
import db from "./db";
import { getEventsForFilters } from "nostr-idb";
/**
* Gift wrap sync state
*/
export interface GiftWrapSyncState {
syncing: boolean;
pendingCount: number;
decryptedCount: number;
failedCount: number;
totalCount: number;
lastSyncAt: number;
}
/**
* Manager for gift wrap syncing and decryption
*
* Simple strategy:
* 1. Load all gift wraps using paginated timeline loader
* 2. Subscribe to new gift wraps in real-time
* 3. Decrypt on demand and cache in Dexie
* 4. Track counts and state
*/
class GiftWrapManager {
private state$ = new BehaviorSubject<GiftWrapSyncState>({
syncing: false,
pendingCount: 0,
decryptedCount: 0,
failedCount: 0,
totalCount: 0,
lastSyncAt: 0,
});
private subscriptions = new Map<string, Subscription>();
/**
* Get observable of gift wrap sync state
*/
get state() {
return this.state$.asObservable();
}
/**
* Sync all gift wraps for a pubkey from relays
* Loads all pages until timeline is exhausted
*/
async syncAll(pubkey: string, relays: string[]): Promise<void> {
if (this.state$.value.syncing) {
console.log("[GiftWrap] Already syncing, skipping...");
return;
}
this.updateState({ syncing: true });
console.log(
`[GiftWrap] Starting sync for ${pubkey} on ${relays.length} relays`,
);
try {
// Create timeline loader with cache fallback
const timeline = createTimelineLoader(
pool,
relays,
{ kinds: [1059], "#p": [pubkey], limit: 100 },
{
eventStore,
cache: (filters) => getEventsForFilters(await db.open(), filters),
},
);
// Load pages until no more events
let page = 0;
let hasMore = true;
while (hasMore) {
const result = await new Promise<NostrEvent[]>((resolve) => {
const events: NostrEvent[] = [];
timeline().subscribe({
next: (event) => events.push(event),
complete: () => resolve(events),
error: (err) => {
console.error("[GiftWrap] Timeline error:", err);
resolve(events);
},
});
});
page++;
hasMore = result.length > 0;
if (hasMore) {
console.log(
`[GiftWrap] Loaded page ${page}: ${result.length} events`,
);
}
}
console.log(`[GiftWrap] Sync complete: loaded ${page} pages`);
await this.updateCounts(pubkey);
this.updateState({ lastSyncAt: Date.now() });
} catch (error) {
console.error("[GiftWrap] Sync error:", error);
} finally {
this.updateState({ syncing: false });
}
}
/**
* Subscribe to new gift wraps in real-time
*/
subscribeToNew(pubkey: string, relays: string[]): void {
const key = pubkey;
// Cleanup existing subscription
this.subscriptions.get(key)?.unsubscribe();
console.log(`[GiftWrap] Subscribing to new gift wraps for ${pubkey}`);
// Subscribe to each relay
const subs = relays.map((relay) =>
pool
.relay(relay)
.subscription({
kinds: [1059],
"#p": [pubkey],
since: Math.floor(Date.now() / 1000),
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: (event) => {
console.log("[GiftWrap] New gift wrap received:", event.id);
this.updateCounts(pubkey);
},
error: (err) => console.error("[GiftWrap] Subscription error:", err),
}),
);
// Store combined subscription
const combined = new Subscription();
subs.forEach((sub) => combined.add(sub));
this.subscriptions.set(key, combined);
}
/**
* Unsubscribe from gift wrap updates
*/
unsubscribe(pubkey: string): void {
this.subscriptions.get(pubkey)?.unsubscribe();
this.subscriptions.delete(pubkey);
}
/**
* Update counts from EventStore and Dexie
*/
async updateCounts(pubkey: string): Promise<void> {
// Get pending count from applesauce model
const pending = await new Promise<number>((resolve) => {
eventStore
.model(GiftWrapsModel, pubkey, true)
.pipe(map((set) => set.size))
.subscribe(resolve);
});
// Get decrypted count from Dexie
const decrypted = await db.decryptedGiftWraps.count();
// Get failed count from Dexie
const failed = await db.giftWrapErrors.count();
// Total is pending + decrypted
const total = pending + decrypted;
this.updateState({
pendingCount: pending,
decryptedCount: decrypted,
failedCount: failed,
totalCount: total,
});
}
/**
* Get observable of pending gift wrap count
*/
getPendingCount(pubkey: string) {
return eventStore
.model(GiftWrapsModel, pubkey, true)
.pipe(map((set) => set.size));
}
/**
* Decrypt a single gift wrap
* Returns cached result if already decrypted
*/
async decryptOne(giftWrapId: string, signer: Signer): Promise<NostrEvent> {
// Check cache first
const cached = await db.decryptedGiftWraps.get(giftWrapId);
if (cached) {
console.log("[GiftWrap] Using cached decryption:", giftWrapId);
return cached.rumor;
}
// Check if previously failed
const error = await db.giftWrapErrors.get(giftWrapId);
if (error && error.attemptCount >= 3) {
throw new Error(
`Max decrypt attempts exceeded (${error.attemptCount}): ${error.errorMessage}`,
);
}
// Get gift wrap from EventStore
const gift = eventStore.event(giftWrapId);
if (!gift) {
throw new Error(`Gift wrap not found: ${giftWrapId}`);
}
try {
console.log("[GiftWrap] Decrypting:", giftWrapId);
const rumor = await unlockGiftWrap(gift, signer);
// Cache decrypted rumor
await db.decryptedGiftWraps.add({
giftWrapId: gift.id,
rumorId: rumor.id,
rumor,
sealPubkey: getGiftWrapSeal(gift)?.pubkey || "",
decryptedAt: Math.floor(Date.now() / 1000),
receivedAt: gift.created_at,
});
console.log("[GiftWrap] Decrypted successfully:", giftWrapId);
return rumor;
} catch (err) {
const errorMessage = String(err);
console.error("[GiftWrap] Decryption failed:", giftWrapId, errorMessage);
// Track error
await db.giftWrapErrors.put({
giftWrapId,
attemptCount: (error?.attemptCount || 0) + 1,
lastAttempt: Math.floor(Date.now() / 1000),
errorMessage,
});
throw err;
}
}
/**
* Batch decrypt gift wraps with progress tracking
*/
async *decryptBatch(
giftWrapIds: string[],
signer: Signer,
): AsyncGenerator<{
id: string;
status: "success" | "error";
rumor?: NostrEvent;
error?: string;
}> {
console.log(`[GiftWrap] Batch decrypting ${giftWrapIds.length} gift wraps`);
for (const id of giftWrapIds) {
try {
const rumor = await this.decryptOne(id, signer);
yield { id, status: "success", rumor };
} catch (err) {
yield { id, status: "error", error: String(err) };
}
// Small delay to avoid blocking UI
await new Promise((resolve) => setTimeout(resolve, 50));
}
console.log("[GiftWrap] Batch decrypt complete");
}
/**
* Clear all failed decryption errors
*/
async clearErrors(): Promise<void> {
await db.giftWrapErrors.clear();
console.log("[GiftWrap] Cleared all decryption errors");
}
/**
* Clear all decrypted gift wraps from cache
*/
async clearDecrypted(): Promise<void> {
await db.decryptedGiftWraps.clear();
await db.giftWrapErrors.clear();
console.log("[GiftWrap] Cleared all decrypted gift wraps");
}
/**
* Update state (partial update)
*/
private updateState(partial: Partial<GiftWrapSyncState>): void {
this.state$.next({ ...this.state$.value, ...partial });
}
}
// Export singleton instance
const giftWrapManager = new GiftWrapManager();
export default giftWrapManager;

View File

@@ -17,6 +17,7 @@ export type AppId =
| "debug"
| "conn"
| "chat"
| "inbox"
| "spells"
| "spellbooks"
| "blossom"

View File

@@ -351,21 +351,22 @@ export const manPages: Record<string, ManPageEntry> = {
section: "1",
synopsis: "chat <identifier>",
description:
"Join and participate in Nostr chat conversations. Supports NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.",
"Join and participate in Nostr chat conversations. Supports NIP-17 private DMs, NIP-29 relay-based groups, NIP-53 live activity chat, and multi-room group list interface. For private DMs, provide npub/nprofile/hex pubkey. For NIP-29 groups, use format 'relay'group-id' where relay is the WebSocket URL (wss:// prefix optional). For NIP-53 live activities, pass the naddr of a kind 30311 live event. For multi-room interface, pass the naddr of a kind 10009 group list event.",
options: [
{
flag: "<identifier>",
description:
"NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
"npub/nprofile/hex (NIP-17), NIP-29 group (relay'group-id), NIP-53 live activity (naddr1... kind 30311), or group list (naddr1... kind 10009)",
},
],
examples: [
"chat npub1... Open NIP-17 private DM",
"chat relay.example.com'bitcoin-dev Join NIP-29 relay group",
"chat wss://nos.lol'welcome Join NIP-29 group with explicit protocol",
"chat naddr1...30311... Join NIP-53 live activity chat",
"chat naddr1...10009... Open multi-room group list interface",
],
seeAlso: ["profile", "open", "req", "live"],
seeAlso: ["inbox", "profile", "open", "req", "live"],
appId: "chat",
category: "Nostr",
argParser: async (args: string[]) => {
@@ -376,6 +377,40 @@ export const manPages: Record<string, ManPageEntry> = {
};
},
},
inbox: {
name: "inbox",
section: "1",
synopsis: "inbox [--decrypt-pending | --clear-failed]",
description:
"View and manage encrypted gift wrap messages (NIP-59). Shows pending, decrypted, and failed gift wraps. Gift wraps are used for private messages (NIP-17) and other private events. Use --decrypt-pending to decrypt all pending gift wraps, or --clear-failed to reset failed decryption attempts.",
options: [
{
flag: "--decrypt-pending",
description: "Decrypt all pending gift wraps",
},
{
flag: "--clear-failed",
description: "Clear failed decryption attempts",
},
],
examples: [
"inbox Open inbox viewer",
"inbox --decrypt-pending Decrypt all pending gift wraps",
"inbox --clear-failed Clear failed decryption attempts",
],
seeAlso: ["chat", "profile"],
appId: "inbox",
category: "Nostr",
argParser: (args: string[]) => {
const action = args.includes("--decrypt-pending")
? "decrypt-pending"
: args.includes("--clear-failed")
? "clear-failed"
: null;
return { action };
},
defaultProps: { action: null },
},
profile: {
name: "profile",
section: "1",

View File

@@ -1 +1 @@
{"root":["./vite.config.ts"],"version":"5.6.3"}
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}