mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
chore: apply prettier formatting fixes
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,10 @@ export class DeleteEventAction {
|
||||
type = "delete-event";
|
||||
label = "Delete Event";
|
||||
|
||||
async execute(item: { event?: NostrEvent }, reason: string = ""): Promise<void> {
|
||||
async execute(
|
||||
item: { event?: NostrEvent },
|
||||
reason: string = "",
|
||||
): Promise<void> {
|
||||
if (!item.event) throw new Error("Item has no event to delete");
|
||||
|
||||
const account = accountManager.active;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function ConflictResolutionDialog({
|
||||
created_at: networkSpellbook.event?.created_at || 0,
|
||||
content: networkSpellbook.content,
|
||||
id: networkSpellbook.event?.id || "",
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const authorProfile = useProfile(networkSpellbook.event?.pubkey);
|
||||
@@ -89,12 +89,16 @@ export function ConflictResolutionDialog({
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="size-4" />
|
||||
<span>{formatDate(comparison.differences.lastModified.local)}</span>
|
||||
<span>
|
||||
{formatDate(comparison.differences.lastModified.local)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Layers className="size-4" />
|
||||
<span>{comparison.differences.workspaceCount.local} workspaces</span>
|
||||
<span>
|
||||
{comparison.differences.workspaceCount.local} workspaces
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
@@ -128,7 +132,9 @@ export function ConflictResolutionDialog({
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="size-4" />
|
||||
<span>{formatDate(comparison.differences.lastModified.network)}</span>
|
||||
<span>
|
||||
{formatDate(comparison.differences.lastModified.network)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
@@ -140,7 +146,9 @@ export function ConflictResolutionDialog({
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Layout className="size-4" />
|
||||
<span>{comparison.differences.windowCount.network} windows</span>
|
||||
<span>
|
||||
{comparison.differences.windowCount.network} windows
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{authorProfile && (
|
||||
|
||||
@@ -118,85 +118,85 @@ export function LayoutControls() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{/* Layouts Section */}
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Layout Presets
|
||||
</div>
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.minSlots;
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Layout Presets
|
||||
</div>
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.minSlots;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleApplyPreset(preset.id)}
|
||||
disabled={!canApply}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Placement Section */}
|
||||
<div className="px-2 py-1.5 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Placement
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Window insertion</div>
|
||||
</div>
|
||||
{insertionModes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = layoutConfig.insertionMode === mode.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1">{mode.label}</span>
|
||||
{isActive && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Split Ratio Section */}
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Split Ratio
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
|
||||
</span>
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleApplyPreset(preset.id)}
|
||||
disabled={!canApply}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default split for new windows
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[displayedSplitPercentage]}
|
||||
onValueChange={([value]) => setLocalSplitPercentage(value)}
|
||||
onValueCommit={([value]) => {
|
||||
updateLayoutConfig({ splitPercentage: value });
|
||||
setLocalSplitPercentage(null); // Clear local state after persist
|
||||
}}
|
||||
min={20}
|
||||
max={80}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Placement Section */}
|
||||
<div className="px-2 py-1.5 space-y-0.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
Placement
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="text-xs text-muted-foreground">Window insertion</div>
|
||||
</div>
|
||||
{insertionModes.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = layoutConfig.insertionMode === mode.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={mode.id}
|
||||
onClick={() => updateLayoutConfig({ insertionMode: mode.id })}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1">{mode.label}</span>
|
||||
{isActive && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Split Ratio Section */}
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-muted-foreground">
|
||||
Split Ratio
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{displayedSplitPercentage}/{100 - displayedSplitPercentage}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Default split for new windows
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[displayedSplitPercentage]}
|
||||
onValueChange={([value]) => setLocalSplitPercentage(value)}
|
||||
onValueCommit={([value]) => {
|
||||
updateLayoutConfig({ splitPercentage: value });
|
||||
setLocalSplitPercentage(null); // Clear local state after persist
|
||||
}}
|
||||
min={20}
|
||||
max={80}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
Loader2,
|
||||
Mail,
|
||||
Send,
|
||||
List,
|
||||
Rows3,
|
||||
Braces,
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useReqTimeline } from "@/hooks/useReqTimeline";
|
||||
@@ -71,6 +74,12 @@ import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
|
||||
import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoizedCompactEventRow } from "./nostr/CompactEventRow";
|
||||
import { MemoizedJsonEventRow } from "./nostr/JsonEventRow";
|
||||
|
||||
// View mode type
|
||||
type ViewMode = "list" | "compact" | "json";
|
||||
|
||||
// Memoized FeedEvent to prevent unnecessary re-renders during scroll
|
||||
const MemoizedFeedEvent = memo(
|
||||
@@ -747,6 +756,7 @@ export default function ReqViewer({
|
||||
const [exportFilename, setExportFilename] = useState("");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
|
||||
// Freeze timeline after EOSE to prevent auto-scrolling on new events
|
||||
const [freezePoint, setFreezePoint] = useState<string | null>(null);
|
||||
@@ -1105,6 +1115,61 @@ export default function ReqViewer({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-0.5 border-l border-border pl-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
viewMode === "list"
|
||||
? "text-foreground bg-muted"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label="List view"
|
||||
>
|
||||
<List className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>List view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setViewMode("compact")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
viewMode === "compact"
|
||||
? "text-foreground bg-muted"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label="Compact view"
|
||||
>
|
||||
<Rows3 className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Compact view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setViewMode("json")}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
viewMode === "json"
|
||||
? "text-foreground bg-muted"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label="JSON view"
|
||||
>
|
||||
<Braces className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>JSON view</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Query (Clickable) */}
|
||||
<button
|
||||
onClick={() => setShowQuery(!showQuery)}
|
||||
@@ -1198,9 +1263,16 @@ export default function ReqViewer({
|
||||
style={{ height: "100%" }}
|
||||
data={visibleEvents}
|
||||
computeItemKey={(_index, item) => item.id}
|
||||
itemContent={(_index, event) => (
|
||||
<MemoizedFeedEvent event={event} />
|
||||
)}
|
||||
itemContent={(_index, event) => {
|
||||
switch (viewMode) {
|
||||
case "compact":
|
||||
return <MemoizedCompactEventRow event={event} />;
|
||||
case "json":
|
||||
return <MemoizedJsonEventRow event={event} />;
|
||||
default:
|
||||
return <MemoizedFeedEvent event={event} />;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,11 @@ interface ShareFormat {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
getValue: (event: NostrEvent, spellbook: ParsedSpellbook, actor: string) => string;
|
||||
getValue: (
|
||||
event: NostrEvent,
|
||||
spellbook: ParsedSpellbook,
|
||||
actor: string,
|
||||
) => string;
|
||||
}
|
||||
|
||||
interface ShareSpellbookDialogProps {
|
||||
@@ -48,7 +52,8 @@ export function ShareSpellbookDialog({
|
||||
id: "web",
|
||||
label: "Web Link",
|
||||
description: "Share as a web URL that anyone can open",
|
||||
getValue: (_e, s, a) => `${window.location.origin}/preview/${a}/${s.slug}`,
|
||||
getValue: (_e, s, a) =>
|
||||
`${window.location.origin}/preview/${a}/${s.slug}`,
|
||||
},
|
||||
{
|
||||
id: "naddr",
|
||||
|
||||
@@ -300,7 +300,10 @@ export function SpellsViewer() {
|
||||
// 1. If published, send Nostr Kind 5
|
||||
if (isPublic && spell.event) {
|
||||
toast.promise(
|
||||
new DeleteEventAction().execute({ event: spell.event }, "Deleted by user in Grimoire"),
|
||||
new DeleteEventAction().execute(
|
||||
{ event: spell.event },
|
||||
"Deleted by user in Grimoire",
|
||||
),
|
||||
{
|
||||
loading: "Sending Nostr deletion request...",
|
||||
success: "Deletion request broadcasted",
|
||||
|
||||
@@ -224,8 +224,8 @@ export function SpellDetailRenderer({ event }: BaseEventProps) {
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{spell.name && (
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-2xl font-bold hover:underline cursor-pointer"
|
||||
>
|
||||
{spell.name}
|
||||
|
||||
@@ -145,7 +145,12 @@ function LayoutVisualizer({
|
||||
}
|
||||
|
||||
// Branch node - split
|
||||
if (node && typeof node === "object" && "first" in node && "second" in node) {
|
||||
if (
|
||||
node &&
|
||||
typeof node === "object" &&
|
||||
"first" in node &&
|
||||
"second" in node
|
||||
) {
|
||||
const isRow = node.direction === "row";
|
||||
const splitPercentage = node.splitPercentage ?? 50; // Default to 50/50 if not specified
|
||||
|
||||
@@ -160,10 +165,24 @@ function LayoutVisualizer({
|
||||
minWidth: isRow ? "80px" : "40px",
|
||||
}}
|
||||
>
|
||||
<div style={{ flexGrow: splitPercentage, minHeight: "40px", minWidth: "40px", display: "flex" }}>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: splitPercentage,
|
||||
minHeight: "40px",
|
||||
minWidth: "40px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{renderLayout(node.first)}
|
||||
</div>
|
||||
<div style={{ flexGrow: 100 - splitPercentage, minHeight: "40px", minWidth: "40px", display: "flex" }}>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 100 - splitPercentage,
|
||||
minHeight: "40px",
|
||||
minWidth: "40px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{renderLayout(node.second)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,9 +384,7 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
className="flex flex-col gap-3 p-4 rounded-xl border border-border bg-card/50"
|
||||
>
|
||||
{ws.label && (
|
||||
<span className="font-bold text-sm">
|
||||
{ws.label}
|
||||
</span>
|
||||
<span className="font-bold text-sm">{ws.label}</span>
|
||||
)}
|
||||
|
||||
{ws.layout && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Kind9Renderer } from "./ChatMessageRenderer";
|
||||
import { Kind20Renderer } from "./PictureRenderer";
|
||||
import { Kind21Renderer } from "./VideoRenderer";
|
||||
import { Kind22Renderer } from "./ShortVideoRenderer";
|
||||
import { VoiceMessageRenderer } from "./VoiceMessageRenderer";
|
||||
import { Kind1063Renderer } from "./FileMetadataRenderer";
|
||||
import { Kind1337Renderer } from "./CodeSnippetRenderer";
|
||||
import { Kind1337DetailRenderer } from "./CodeSnippetDetailRenderer";
|
||||
@@ -63,6 +64,8 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
22: Kind22Renderer, // Short Video (NIP-71)
|
||||
1063: Kind1063Renderer, // File Metadata (NIP-94)
|
||||
1111: Kind1111Renderer, // Post (NIP-22)
|
||||
1222: VoiceMessageRenderer, // Voice Message (NIP-A0)
|
||||
1244: VoiceMessageRenderer, // Voice Message Reply (NIP-A0)
|
||||
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
|
||||
1617: PatchRenderer, // Patch (NIP-34)
|
||||
1618: PullRequestRenderer, // Pull Request (NIP-34)
|
||||
@@ -78,6 +81,8 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
|
||||
30023: Kind30023Renderer, // Long-form Article
|
||||
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
|
||||
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
|
||||
34236: Kind22Renderer, // Vertical Video (NIP-71 legacy)
|
||||
30617: RepositoryRenderer, // Repository (NIP-34)
|
||||
30618: RepositoryStateRenderer, // Repository State (NIP-34)
|
||||
30777: SpellbookRenderer, // Spellbook (Grimoire)
|
||||
@@ -185,5 +190,6 @@ export { Kind9Renderer } from "./ChatMessageRenderer";
|
||||
export { Kind20Renderer } from "./PictureRenderer";
|
||||
export { Kind21Renderer } from "./VideoRenderer";
|
||||
export { Kind22Renderer } from "./ShortVideoRenderer";
|
||||
export { VoiceMessageRenderer } from "./VoiceMessageRenderer";
|
||||
export { Kind1063Renderer } from "./FileMetadataRenderer";
|
||||
export { Kind9735Renderer } from "./ZapReceiptRenderer";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -29,8 +29,8 @@ const Alert = React.forwardRef<
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
@@ -41,8 +41,8 @@ const AlertTitle = React.forwardRef<
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
@@ -53,7 +53,7 @@ const AlertDescription = React.forwardRef<
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
||||
@@ -47,7 +47,9 @@ const storage = createJSONStorage<GrimoireState>(() => ({
|
||||
const storedVersion = parsed.__version || 5;
|
||||
|
||||
if (storedVersion < CURRENT_VERSION) {
|
||||
console.log(`[Storage] State version outdated (v${storedVersion}), migrating...`);
|
||||
console.log(
|
||||
`[Storage] State version outdated (v${storedVersion}), migrating...`,
|
||||
);
|
||||
const migrated = migrateState(parsed);
|
||||
localStorage.setItem(key, JSON.stringify(migrated));
|
||||
toast.success("State Updated", {
|
||||
@@ -58,7 +60,9 @@ const storage = createJSONStorage<GrimoireState>(() => ({
|
||||
}
|
||||
|
||||
if (!validateState(parsed)) {
|
||||
console.warn("[Storage] State validation failed, resetting to initial state");
|
||||
console.warn(
|
||||
"[Storage] State validation failed, resetting to initial state",
|
||||
);
|
||||
toast.error("State Corrupted", {
|
||||
description: "Your state was corrupted and has been reset.",
|
||||
duration: 5000,
|
||||
@@ -98,39 +102,39 @@ export const grimoireStateAtom = atomWithStorage<GrimoireState>(
|
||||
const internalTemporaryStateAtom = atom<GrimoireState | null>(null);
|
||||
|
||||
// Types for dispatch actions
|
||||
type StateAction =
|
||||
| { type: 'UPDATE', updater: (prev: GrimoireState) => GrimoireState }
|
||||
| { type: 'START_TEMP', spellbook?: ParsedSpellbook }
|
||||
| { type: 'APPLY_TEMP' }
|
||||
| { type: 'DISCARD_TEMP' };
|
||||
type StateAction =
|
||||
| { type: "UPDATE"; updater: (prev: GrimoireState) => GrimoireState }
|
||||
| { type: "START_TEMP"; spellbook?: ParsedSpellbook }
|
||||
| { type: "APPLY_TEMP" }
|
||||
| { type: "DISCARD_TEMP" };
|
||||
|
||||
// Derived atom that handles the switching logic and updates
|
||||
const activeGrimoireStateAtom = atom(
|
||||
(get) => get(internalTemporaryStateAtom) || get(grimoireStateAtom),
|
||||
(get, set, action: StateAction) => {
|
||||
if (action.type === 'UPDATE') {
|
||||
if (action.type === "UPDATE") {
|
||||
const temp = get(internalTemporaryStateAtom);
|
||||
if (temp) {
|
||||
set(internalTemporaryStateAtom, action.updater(temp));
|
||||
} else {
|
||||
set(grimoireStateAtom, action.updater);
|
||||
}
|
||||
} else if (action.type === 'START_TEMP') {
|
||||
} else if (action.type === "START_TEMP") {
|
||||
const current = get(grimoireStateAtom);
|
||||
const next = action.spellbook
|
||||
const next = action.spellbook
|
||||
? SpellbookManager.loadSpellbook(current, action.spellbook)
|
||||
: { ...current };
|
||||
set(internalTemporaryStateAtom, next);
|
||||
} else if (action.type === 'APPLY_TEMP') {
|
||||
} else if (action.type === "APPLY_TEMP") {
|
||||
const temp = get(internalTemporaryStateAtom);
|
||||
if (temp) {
|
||||
set(grimoireStateAtom, temp);
|
||||
set(internalTemporaryStateAtom, null);
|
||||
}
|
||||
} else if (action.type === 'DISCARD_TEMP') {
|
||||
} else if (action.type === "DISCARD_TEMP") {
|
||||
set(internalTemporaryStateAtom, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The Hook
|
||||
@@ -140,9 +144,12 @@ export const useGrimoire = () => {
|
||||
const isTemporary = useAtomValue(internalTemporaryStateAtom) !== null;
|
||||
const browserLocale = useLocale();
|
||||
|
||||
const setState = useCallback((updater: (prev: GrimoireState) => GrimoireState) => {
|
||||
dispatch({ type: 'UPDATE', updater });
|
||||
}, [dispatch]);
|
||||
const setState = useCallback(
|
||||
(updater: (prev: GrimoireState) => GrimoireState) => {
|
||||
dispatch({ type: "UPDATE", updater });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Initialize locale from browser if not set
|
||||
useEffect(() => {
|
||||
@@ -153,96 +160,180 @@ export const useGrimoire = () => {
|
||||
|
||||
const createWorkspace = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(prev.workspaces);
|
||||
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(
|
||||
prev.workspaces,
|
||||
);
|
||||
return Logic.createWorkspace(prev, nextNumber);
|
||||
});
|
||||
}, [setState]);
|
||||
|
||||
const createWorkspaceWithNumber = useCallback((number: number) => {
|
||||
setState((prev) => {
|
||||
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
|
||||
const shouldDeleteCurrent = currentWorkspace && currentWorkspace.windowIds.length === 0 && Object.keys(prev.workspaces).length > 1;
|
||||
const baseState = shouldDeleteCurrent ? Logic.deleteWorkspace(prev, prev.activeWorkspaceId) : prev;
|
||||
return Logic.createWorkspace(baseState, number);
|
||||
});
|
||||
}, [setState]);
|
||||
const createWorkspaceWithNumber = useCallback(
|
||||
(number: number) => {
|
||||
setState((prev) => {
|
||||
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
|
||||
const shouldDeleteCurrent =
|
||||
currentWorkspace &&
|
||||
currentWorkspace.windowIds.length === 0 &&
|
||||
Object.keys(prev.workspaces).length > 1;
|
||||
const baseState = shouldDeleteCurrent
|
||||
? Logic.deleteWorkspace(prev, prev.activeWorkspaceId)
|
||||
: prev;
|
||||
return Logic.createWorkspace(baseState, number);
|
||||
});
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const addWindow = useCallback((appId: AppId, props: any, commandString?: string, customTitle?: string, spellId?: string) => {
|
||||
setState((prev) => Logic.addWindow(prev, { appId, props, commandString, customTitle, spellId }));
|
||||
}, [setState]);
|
||||
const addWindow = useCallback(
|
||||
(
|
||||
appId: AppId,
|
||||
props: any,
|
||||
commandString?: string,
|
||||
customTitle?: string,
|
||||
spellId?: string,
|
||||
) => {
|
||||
setState((prev) =>
|
||||
Logic.addWindow(prev, {
|
||||
appId,
|
||||
props,
|
||||
commandString,
|
||||
customTitle,
|
||||
spellId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateWindow = useCallback((windowId: string, updates: Partial<Pick<WindowInstance, "props" | "title" | "customTitle" | "commandString" | "appId">>) => {
|
||||
setState((prev) => Logic.updateWindow(prev, windowId, updates));
|
||||
}, [setState]);
|
||||
const updateWindow = useCallback(
|
||||
(
|
||||
windowId: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
WindowInstance,
|
||||
"props" | "title" | "customTitle" | "commandString" | "appId"
|
||||
>
|
||||
>,
|
||||
) => {
|
||||
setState((prev) => Logic.updateWindow(prev, windowId, updates));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const removeWindow = useCallback((id: string) => {
|
||||
setState((prev) => Logic.removeWindow(prev, id));
|
||||
}, [setState]);
|
||||
const removeWindow = useCallback(
|
||||
(id: string) => {
|
||||
setState((prev) => Logic.removeWindow(prev, id));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const moveWindowToWorkspace = useCallback((windowId: string, targetWorkspaceId: string) => {
|
||||
setState((prev) => Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId));
|
||||
}, [setState]);
|
||||
const moveWindowToWorkspace = useCallback(
|
||||
(windowId: string, targetWorkspaceId: string) => {
|
||||
setState((prev) =>
|
||||
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
|
||||
);
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateLayout = useCallback((layout: any) => {
|
||||
setState((prev) => Logic.updateLayout(prev, layout));
|
||||
}, [setState]);
|
||||
const updateLayout = useCallback(
|
||||
(layout: any) => {
|
||||
setState((prev) => Logic.updateLayout(prev, layout));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveWorkspace = useCallback((id: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev.workspaces[id] || prev.activeWorkspaceId === id) return prev;
|
||||
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
|
||||
const shouldDeleteCurrent = currentWorkspace && currentWorkspace.windowIds.length === 0 && Object.keys(prev.workspaces).length > 1;
|
||||
const baseState = shouldDeleteCurrent ? Logic.deleteWorkspace(prev, prev.activeWorkspaceId) : prev;
|
||||
return { ...baseState, activeWorkspaceId: id };
|
||||
});
|
||||
}, [setState]);
|
||||
const setActiveWorkspace = useCallback(
|
||||
(id: string) => {
|
||||
setState((prev) => {
|
||||
if (!prev.workspaces[id] || prev.activeWorkspaceId === id) return prev;
|
||||
const currentWorkspace = prev.workspaces[prev.activeWorkspaceId];
|
||||
const shouldDeleteCurrent =
|
||||
currentWorkspace &&
|
||||
currentWorkspace.windowIds.length === 0 &&
|
||||
Object.keys(prev.workspaces).length > 1;
|
||||
const baseState = shouldDeleteCurrent
|
||||
? Logic.deleteWorkspace(prev, prev.activeWorkspaceId)
|
||||
: prev;
|
||||
return { ...baseState, activeWorkspaceId: id };
|
||||
});
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveAccount = useCallback((pubkey: string | undefined) => {
|
||||
setState((prev) => Logic.setActiveAccount(prev, pubkey));
|
||||
}, [setState]);
|
||||
const setActiveAccount = useCallback(
|
||||
(pubkey: string | undefined) => {
|
||||
setState((prev) => Logic.setActiveAccount(prev, pubkey));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveAccountRelays = useCallback((relays: RelayInfo[]) => {
|
||||
setState((prev) => Logic.setActiveAccountRelays(prev, relays));
|
||||
}, [setState]);
|
||||
const setActiveAccountRelays = useCallback(
|
||||
(relays: RelayInfo[]) => {
|
||||
setState((prev) => Logic.setActiveAccountRelays(prev, relays));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateLayoutConfig = useCallback((layoutConfig: Partial<LayoutConfig>) => {
|
||||
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig));
|
||||
}, [setState]);
|
||||
const updateLayoutConfig = useCallback(
|
||||
(layoutConfig: Partial<LayoutConfig>) => {
|
||||
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const applyPresetLayout = useCallback((preset: any) => {
|
||||
setState((prev) => Logic.applyPresetLayout(prev, preset));
|
||||
}, [setState]);
|
||||
const applyPresetLayout = useCallback(
|
||||
(preset: any) => {
|
||||
setState((prev) => Logic.applyPresetLayout(prev, preset));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateWorkspaceLabel = useCallback((workspaceId: string, label: string | undefined) => {
|
||||
setState((prev) => Logic.updateWorkspaceLabel(prev, workspaceId, label));
|
||||
}, [setState]);
|
||||
const updateWorkspaceLabel = useCallback(
|
||||
(workspaceId: string, label: string | undefined) => {
|
||||
setState((prev) => Logic.updateWorkspaceLabel(prev, workspaceId, label));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const reorderWorkspaces = useCallback((orderedIds: string[]) => {
|
||||
setState((prev) => Logic.reorderWorkspaces(prev, orderedIds));
|
||||
}, [setState]);
|
||||
const reorderWorkspaces = useCallback(
|
||||
(orderedIds: string[]) => {
|
||||
setState((prev) => Logic.reorderWorkspaces(prev, orderedIds));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setCompactModeKinds = useCallback((kinds: number[]) => {
|
||||
setState((prev) => Logic.setCompactModeKinds(prev, kinds));
|
||||
}, [setState]);
|
||||
const setCompactModeKinds = useCallback(
|
||||
(kinds: number[]) => {
|
||||
setState((prev) => Logic.setCompactModeKinds(prev, kinds));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const loadSpellbook = useCallback((spellbook: ParsedSpellbook) => {
|
||||
setState((prev) => SpellbookManager.loadSpellbook(prev, spellbook));
|
||||
}, [setState]);
|
||||
const loadSpellbook = useCallback(
|
||||
(spellbook: ParsedSpellbook) => {
|
||||
setState((prev) => SpellbookManager.loadSpellbook(prev, spellbook));
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
const clearActiveSpellbook = useCallback(() => {
|
||||
setState((prev) => Logic.clearActiveSpellbook(prev));
|
||||
}, [setState]);
|
||||
|
||||
const switchToTemporary = useCallback((spellbook?: ParsedSpellbook) => {
|
||||
dispatch({ type: 'START_TEMP', spellbook });
|
||||
}, [dispatch]);
|
||||
const switchToTemporary = useCallback(
|
||||
(spellbook?: ParsedSpellbook) => {
|
||||
dispatch({ type: "START_TEMP", spellbook });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const applyTemporaryToPersistent = useCallback(() => {
|
||||
dispatch({ type: 'APPLY_TEMP' });
|
||||
dispatch({ type: "APPLY_TEMP" });
|
||||
}, [dispatch]);
|
||||
|
||||
const discardTemporary = useCallback(() => {
|
||||
dispatch({ type: 'DISCARD_TEMP' });
|
||||
dispatch({ type: "DISCARD_TEMP" });
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
@@ -271,4 +362,4 @@ export const useGrimoire = () => {
|
||||
applyTemporaryToPersistent,
|
||||
discardTemporary,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ImetaEntry {
|
||||
x?: string; // SHA-256 hash
|
||||
size?: string; // file size in bytes
|
||||
fallback?: string[]; // fallback URLs
|
||||
duration?: number; // audio/video duration in seconds (NIP-A0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,11 @@ export function parseImetaTag(tag: string[]): ImetaEntry | null {
|
||||
} else if (key === "fallback") {
|
||||
if (!entry.fallback) entry.fallback = [];
|
||||
entry.fallback.push(value);
|
||||
} else if (key === "duration") {
|
||||
const parsed = parseFloat(value);
|
||||
if (!isNaN(parsed)) {
|
||||
entry.duration = parsed;
|
||||
}
|
||||
} else {
|
||||
(entry as any)[key] = value;
|
||||
}
|
||||
@@ -136,6 +142,22 @@ export function isAudioMime(mime?: string): boolean {
|
||||
return mime.startsWith("audio/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to MM:SS or H:MM:SS format
|
||||
*/
|
||||
export function formatDuration(seconds?: number): string | null {
|
||||
if (seconds === undefined || seconds < 0) return null;
|
||||
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Dexie, Table } from "dexie";
|
||||
import { RelayInformation } from "../types/nip11";
|
||||
import { normalizeRelayURL } from "../lib/relay-url";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import type { SpellEvent, SpellbookContent, SpellbookEvent } from "@/types/spell";
|
||||
import type {
|
||||
SpellEvent,
|
||||
SpellbookContent,
|
||||
SpellbookEvent,
|
||||
} from "@/types/spell";
|
||||
|
||||
export interface Profile extends ProfileContent {
|
||||
pubkey: string;
|
||||
@@ -334,4 +338,4 @@ export const relayLivenessStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
export default db;
|
||||
|
||||
Reference in New Issue
Block a user