import { A, useParams, useNavigate } from '@solidjs/router';
import { createQuery, useQueryClient } from '@tanstack/solid-query';
import { Show, Switch, Match, type Component, createSignal, createMemo, createEffect, onCleanup } from 'solid-js';
import type { ResourceUri } from '@atcute/lexicons/syntax';
import { Avatar, ErrorState, inputStyles, textareaStyles } from '../components/common';
import { CodeView, CommentComposer, CommentThreadsSection, StringPageSkeleton, RepoFormSkeleton } from '../components/repo';
import {
getString,
deleteString,
updateString,
createString,
listStringComments,
createStringComment,
getStringStarSummary,
createStringStar,
deleteStringStar,
type StringComment,
} from '../lib/api/strings';
import { formatRelativeTime, getErrorMessage } from '../lib/repo-utils';
import { useAuth } from '../lib/auth';
import { Pencil, Trash2, Star, LoaderCircle, ArrowUp, X } from 'lucide-solid';
import clsx from 'clsx';
interface StringCommentThread {
item: StringComment;
replies: StringComment[];
}
const buildStringCommentThreads = (comments: StringComment[]): StringCommentThread[] => {
const byUri = new Map(comments.map((comment) => [comment.uri, comment]));
const topLevel: StringComment[] = [];
const repliesByRoot = new Map<string, StringComment[]>();
const findRoot = (comment: StringComment): StringComment | null => {
let current = comment;
const visited = new Set<string>([comment.uri]);
while (true) {
const replyToUri = typeof current.value.replyTo === 'string'
? current.value.replyTo
: (current.value.replyTo as { uri?: string } | undefined)?.uri;
if (!replyToUri) {
return current;
}
const parent = byUri.get(replyToUri as ResourceUri);
if (!parent) {
return current;
}
if (visited.has(parent.uri)) {
return current === comment ? null : current;
}
visited.add(parent.uri);
current = parent;
}
};
for (const comment of comments) {
const root = findRoot(comment);
if (!root || root.uri === comment.uri) {
topLevel.push(comment);
} else {
repliesByRoot.set(root.uri, [...(repliesByRoot.get(root.uri) ?? []), comment]);
}
}
return topLevel.map((item) => ({
item,
replies: repliesByRoot.get(item.uri) ?? [],
}));
};
export const StringPage: Component = () => {
const params = useParams<{ actor: string; rkey: string }>();
const auth = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Deleting Signal
const [deleting, setDeleting] = createSignal(false);
// Star working Signal
const [starWorking, setStarWorking] = createSignal(false);
// Comment signals
const [commentBody, setCommentBody] = createSignal('');
const [commentWorking, setCommentWorking] = createSignal(false);
const [commentError, setCommentError] = createSignal<string | null>(null);
const stringQuery = createQuery(() => ({
queryKey: ['string-detail', params.actor, params.rkey],
queryFn: () => getString(params.actor, params.rkey),
}));
const starSummaryQuery = createQuery(() => ({
queryKey: ['string-star-summary', stringQuery.data?.record.uri],
enabled: !!stringQuery.data?.record.uri,
queryFn: () => getStringStarSummary(stringQuery.data!.record.uri, auth.currentDid()),
}));
const commentsQuery = createQuery(() => ({
queryKey: ['string-comments', stringQuery.data?.record.uri],
enabled: !!stringQuery.data?.record.uri,
queryFn: () => listStringComments(stringQuery.data!.record.uri),
}));
const commentThreads = createMemo(() => {
const comments = commentsQuery.data ?? [];
return buildStringCommentThreads(comments);
});
const handleDelete = async () => {
const agent = auth.agent();
const data = stringQuery.data;
if (!agent || !data) return;
if (!confirm(`are you sure you want to delete the string "${data.record.value.filename}"?`)) {
return;
}
setDeleting(true);
try {
await deleteString(agent, params.rkey);
const ownerId = data.owner.handle || data.owner.did;
navigate(`/${ownerId}?tab=strings`);
} catch (err) {
alert(getErrorMessage(err));
} finally {
setDeleting(false);
}
};
const handleStarToggle = async () => {
const agent = auth.agent();
const summary = starSummaryQuery.data;
const record = stringQuery.data?.record;
if (!agent || !summary || !record || starWorking()) return;
setStarWorking(true);
try {
if (summary.isStarred && summary.currentUserStarRkey) {
await deleteStringStar(agent, summary.currentUserStarRkey);
} else {
await createStringStar(agent, record.uri);
}
await queryClient.invalidateQueries({ queryKey: ['string-star-summary', record.uri] });
} catch (err) {
console.error('failed to toggle star', err);
} finally {
setStarWorking(false);
}
};
const handleCommentSubmit = async (e: SubmitEvent) => {
e.preventDefault();
const agent = auth.agent();
const record = stringQuery.data?.record;
if (!agent || !record || !commentBody().trim() || commentWorking()) return;
setCommentWorking(true);
setCommentError(null);
try {
await createStringComment(agent, record.uri, record.cid, commentBody());
setCommentBody('');
await queryClient.invalidateQueries({ queryKey: ['string-comments', record.uri] });
} catch (err) {
setCommentError(getErrorMessage(err));
} finally {
setCommentWorking(false);
}
};
const starBusy = () => starWorking() || starSummaryQuery.isLoading;
return (
<Switch>
<Match when={stringQuery.isLoading}>
<StringPageSkeleton />
</Match>
<Match when={stringQuery.error}>
<div class="p-6 bg-white dark:bg-gray-800 rounded shadow-sm">
<ErrorState message={getErrorMessage(stringQuery.error).toLowerCase()} />
</div>
</Match>
<Match when={stringQuery.data}>
{(data) => {
const { owner, record } = data();
const lineCount = () => record.value.contents.split('\n').length;
const byteCount = () => new TextEncoder().encode(record.value.contents).length;
const formattedSize = () => {
const bytes = byteCount();
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
};
const timeAgo = () => formatRelativeTime(record.value.createdAt);
const [rawUrl, setRawUrl] = createSignal<string>();
createEffect(() => {
const webBlob = new Blob([record.value.contents], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(webBlob);
setRawUrl(url);
onCleanup(() => URL.revokeObjectURL(url));
});
return (
<div class="flex flex-col gap-4">
<section id="string-header" class="mb-2 py-2 px-4 dark:text-white">
<div class="text-lg flex flex-col sm:flex-row items-start gap-4 justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
<A href={`/${owner.handle || owner.did}`} class="flex items-center gap-1 hover:underline text-black dark:text-white font-medium">
<Avatar did={owner.did} size="size-6" />
<span>{owner.handle || owner.did}</span>
</A>
<span class="select-none text-gray-400">/</span>
<A href={`/strings/${owner.handle || owner.did}/${record.rkey}`} class="font-bold hover:underline text-black dark:text-white">
{record.value.filename}
</A>
</div>
<span class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 dark:text-gray-300">
<Show when={record.value.description} fallback={<span class="italic">this string has no description</span>}>
{record.value.description}
</Show>
</span>
</div>
<div class="w-full sm:w-fit flex items-center gap-2 z-auto">
<Show when={auth.currentDid() === owner.did}>
<A
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group border border-gray-200 dark:border-gray-700 rounded-sm px-3 py-1.5 bg-white dark:bg-gray-800"
href={`/strings/${owner.handle || owner.did}/${record.rkey}/edit`}
>
<Pencil class="w-4 h-4" />
<span class="hidden md:inline">edit</span>
</A>
<button
class="btn text-sm text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-2 group border border-gray-200 dark:border-gray-700 rounded-sm px-3 py-1.5 bg-white dark:bg-gray-800"
onClick={handleDelete}
disabled={deleting()}
>
<Show when={deleting()} fallback={<Trash2 class="w-4 h-4" />}>
<LoaderCircle class="w-4 h-4 animate-spin" />
</Show>
<span class="hidden md:inline">delete</span>
</button>
</Show>
<Show when={starSummaryQuery.data}>
<div class="btn-group w-full sm:w-auto flex">
<button
class="btn-group-item active flex-1 gap-1 group flex items-center justify-center"
disabled={starBusy()}
onClick={handleStarToggle}
>
<Show when={starWorking()} fallback={
<Star class={clsx("w-4 h-4 shrink-0", starSummaryQuery.data?.isStarred ? "fill-current text-yellow-500" : "")} />
}>
<LoaderCircle class="w-4 h-4 shrink-0 animate-spin" />
</Show>
<span>{starSummaryQuery.data?.isStarred ? 'unstar' : 'star'}</span>
</button>
<span class="btn-group-item cursor-default flex items-center justify-center px-3">
{starSummaryQuery.data?.count ?? 0}
</span>
</div>
</Show>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white shadow-sm">
<div class="flex flex-col md:flex-row md:justify-between md:items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
<span>
{record.value.filename}
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
<span>
{timeAgo()}
</span>
</span>
<div>
<span>{lineCount()} lines</span>
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
<span>{formattedSize()}</span>
<Show when={rawUrl()}>
<span class="select-none px-1 md:px-2 [&:before]:content-['·']"></span>
<a href={rawUrl()!} target="_blank" rel="noreferrer" class="hover:underline">view raw</a>
</Show>
</div>
</div>
<div class="overflow-x-auto overflow-y-hidden relative">
<CodeView id="blob-contents" text={record.value.contents} wrap={false} path={record.value.filename} />
</div>
</section>
{/* Comments section */}
<div class="flex flex-col gap-4 px-2 md:px-0">
<Show when={commentsQuery.data}>
<CommentThreadsSection
threads={commentThreads()}
authorDid={owner.did}
onReplySubmit={async (body, parent) => {
const agent = auth.agent();
if (!agent) return;
await createStringComment(
agent,
record.uri,
record.cid,
body,
{ uri: parent.uri as ResourceUri, cid: parent.cid ?? '' }
);
await queryClient.invalidateQueries({ queryKey: ['string-comments', record.uri] });
}}
/>
</Show>
<Show when={auth.agent()}>
<CommentComposer
value={commentBody()}
onValueChange={setCommentBody}
onSubmit={handleCommentSubmit}
working={commentWorking()}
error={commentError()}
placeholder="add to the discussion. markdown is supported."
submitLabel="comment"
/>
</Show>
</div>
</div>
);
}}
</Match>
</Switch>
);
};
export const NewStringPage: Component = () => {
const auth = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [filename, setFilename] = createSignal('');
const [description, setDescription] = createSignal('');
const [contents, setContents] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [saveError, setSaveError] = createSignal<string | null>(null);
const lines = () => {
const val = contents();
return val === '' ? 0 : val.split('\n').length;
};
const bytes = () => {
return new TextEncoder().encode(contents()).length;
};
const handlePublish = async (e: SubmitEvent) => {
e.preventDefault();
const agent = auth.agent();
if (!agent) return;
setSaving(true);
setSaveError(null);
try {
const result = await createString(agent, {
filename: filename(),
description: description(),
contents: contents(),
});
await queryClient.invalidateQueries({ queryKey: ['profile-strings', auth.currentDid()] });
const ownerId = auth.currentDid();
navigate(`/strings/${ownerId}/${result.rkey}`);
} catch (err) {
setSaveError(getErrorMessage(err));
} finally {
setSaving(false);
}
};
return (
<Show
when={auth.currentDid()}
fallback={
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded p-6 relative flex gap-2 items-center dark:text-white">
please log in to create a new string.
</div>
}
>
<div class="px-6 py-2 mb-4">
<p class="text-xl font-bold dark:text-white mb-1">create a new string</p>
<p class="text-gray-600 dark:text-gray-400 mb-1">store and share code snippets with ease.</p>
</div>
<form
onSubmit={handlePublish}
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded border border-gray-200 dark:border-gray-700"
>
{saveError() && <p class="text-red-500 dark:text-red-400 text-sm mb-2">{saveError()}</p>}
<div class="flex flex-col md:flex-row md:items-center gap-2">
<input
type="text"
id="filename"
name="filename"
placeholder="filename"
required
value={filename()}
onInput={(e) => setFilename(e.currentTarget.value)}
class={clsx(inputStyles(), 'md:max-w-64')}
/>
<input
type="text"
id="description"
name="description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="description ..."
class={clsx(inputStyles(), 'flex-1')}
/>
</div>
<textarea
name="content"
id="content-textarea"
wrap="off"
class={clsx(textareaStyles(), 'font-mono text-sm')}
rows={20}
spellcheck={false}
placeholder="paste your string here!"
value={contents()}
onInput={(e) => setContents(e.currentTarget.value)}
required
/>
<div class="flex justify-between items-center">
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
<span id="line-count">{lines()} line{lines() !== 1 ? 's' : ''}</span>
<span class="select-none px-1 [&:before]:content-['·']"></span>
<span id="byte-count">{bytes()} byte{bytes() !== 1 ? 's' : ''}</span>
</div>
<div id="actions" class="flex gap-2 items-center">
<button
type="submit"
id="new-button"
class="w-fit btn-create group inline-flex items-center gap-2 text-white"
disabled={saving()}
>
<span class="inline-flex items-center gap-2">
<ArrowUp class="w-4 h-4" />
publish
</span>
<Show when={saving()}>
<span class="pl-2 inline-block">
<LoaderCircle class="w-4 h-4 animate-spin" />
</span>
</Show>
</button>
</div>
</div>
</form>
</Show>
);
};
export const EditStringPage: Component = () => {
const params = useParams<{ actor: string; rkey: string }>();
const auth = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [filename, setFilename] = createSignal('');
const [description, setDescription] = createSignal('');
const [contents, setContents] = createSignal('');
const [saving, setSaving] = createSignal(false);
const [saveError, setSaveError] = createSignal<string | null>(null);
const stringQuery = createQuery(() => ({
queryKey: ['string-detail', params.actor, params.rkey],
queryFn: () => getString(params.actor, params.rkey),
}));
createEffect(() => {
const data = stringQuery.data;
if (data) {
setFilename(data.record.value.filename);
setDescription(data.record.value.description ?? '');
setContents(data.record.value.contents);
}
});
const isOwner = () => {
const data = stringQuery.data;
return data && auth.currentDid() === data.owner.did;
};
const lines = () => {
const val = contents();
return val === '' ? 0 : val.split('\n').length;
};
const bytes = () => {
return new TextEncoder().encode(contents()).length;
};
const handleSave = async (e: SubmitEvent) => {
e.preventDefault();
const agent = auth.agent();
const data = stringQuery.data;
if (!agent || !data) return;
setSaving(true);
setSaveError(null);
try {
await updateString(
agent,
params.rkey,
{
filename: filename(),
description: description(),
contents: contents(),
},
data.record.cid,
);
await queryClient.invalidateQueries({ queryKey: ['string-detail', params.actor, params.rkey] });
navigate(`/strings/${params.actor}/${params.rkey}`);
} catch (err) {
setSaveError(getErrorMessage(err));
} finally {
setSaving(false);
}
};
return (
<Switch>
<Match when={stringQuery.isLoading}>
<RepoFormSkeleton titleWidth="w-32" />
</Match>
<Match when={stringQuery.error}>
<div class="p-6 bg-white dark:bg-gray-800 rounded shadow-sm">
<ErrorState message={getErrorMessage(stringQuery.error).toLowerCase()} />
</div>
</Match>
<Match when={stringQuery.data && !isOwner()}>
<div class="p-6 bg-white dark:bg-gray-800 rounded shadow-sm">
<ErrorState message="you are not authorized to edit this string." />
</div>
</Match>
<Match when={stringQuery.data && isOwner()}>
<div>
<div class="px-6 py-2 mb-4">
<p class="text-xl font-bold dark:text-white">edit string</p>
</div>
<form
onSubmit={handleSave}
class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded border border-gray-200 dark:border-gray-700"
>
{saveError() && <p class="text-red-500 dark:text-red-400 text-sm mb-2">{saveError()}</p>}
<div class="flex flex-col md:flex-row md:items-center gap-2">
<input
type="text"
id="filename"
name="filename"
placeholder="filename"
required
value={filename()}
onInput={(e) => setFilename(e.currentTarget.value)}
class={clsx(inputStyles(), 'md:max-w-64')}
/>
<input
type="text"
id="description"
name="description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
placeholder="description ..."
class={clsx(inputStyles(), 'flex-1')}
/>
</div>
<textarea
name="content"
id="content-textarea"
wrap="off"
class={clsx(textareaStyles(), 'font-mono text-sm')}
rows={20}
spellcheck={false}
placeholder="paste your string here!"
value={contents()}
onInput={(e) => setContents(e.currentTarget.value)}
required
/>
<div class="flex justify-between items-center">
<div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400">
<span id="line-count">{lines()} line{lines() !== 1 ? 's' : ''}</span>
<span class="select-none px-1 [&:before]:content-['·']"></span>
<span id="byte-count">{bytes()} byte{bytes() !== 1 ? 's' : ''}</span>
</div>
<div id="actions" class="flex gap-2 items-center">
<A
class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
href={`/strings/${params.actor}/${params.rkey}`}
>
<X class="size-4" />
<span class="hidden md:inline">cancel</span>
</A>
<button
type="submit"
id="new-button"
class="w-fit btn-create group inline-flex items-center gap-2 text-white"
disabled={saving()}
>
<span class="inline-flex items-center gap-2">
<ArrowUp class="w-4 h-4" />
publish
</span>
<Show when={saving()}>
<span class="pl-2 inline-block">
<LoaderCircle class="w-4 h-4 animate-spin" />
</span>
</Show>
</button>
</div>
</div>
</form>
</div>
</Match>
</Switch>
);
};
export const RawStringPage: Component = () => {
const params = useParams<{ actor: string; rkey: string }>();
const stringQuery = createQuery(() => ({
queryKey: ['string-detail', params.actor, params.rkey],
queryFn: () => getString(params.actor, params.rkey),
}));
return (
<Switch>
<Match when={stringQuery.isLoading}>
<div class="p-4 font-mono text-sm">loading raw content...</div>
</Match>
<Match when={stringQuery.error}>
<div class="p-4 font-mono text-sm text-red-500">
failed to load: {getErrorMessage(stringQuery.error).toLowerCase()}
</div>
</Match>
<Match when={stringQuery.data}>
{(data) => (
<pre style="white-space: pre-wrap; word-wrap: break-word; font-family: monospace;" class="p-4 bg-white dark:bg-gray-950 dark:text-white">
{data().record.value.contents}
</pre>
)}
</Match>
</Switch>
);
};