diff --git a/src/pages/search.tsx b/src/pages/search.tsx
index 69a77b4..315ff15 100644
--- a/src/pages/search.tsx
+++ b/src/pages/search.tsx
@@ -10,6 +10,7 @@ import {
Tag,
UserRound,
X,
+ LineSquiggle,
} from 'lucide-solid';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
@@ -134,6 +135,9 @@ const SearchResultIcon: Component<{ hit: SearchHit }> = (props) => (
+
+
+
@@ -342,6 +346,11 @@ const ResultTitle: Component<{ hit: SearchHit }> = (props) => {
{props.hit.author.handle}
+
+
+ {props.hit.author.handle || props.hit.author.did}/{title()}
+
+
diff --git a/src/pages/string.tsx b/src/pages/string.tsx
new file mode 100644
index 0000000..6488a2d
--- /dev/null
+++ b/src/pages/string.tsx
@@ -0,0 +1,639 @@
+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, LoadingState, inputStyles, textareaStyles } from '../components/common';
+import { CodeView, CommentComposer, CommentThreadsSection } 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
();
+
+ const findRoot = (comment: StringComment): StringComment | null => {
+ let current = comment;
+ const visited = new Set([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(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 (
+
+
+
+
+
+
+
+
+
+
+ {(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();
+ 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 (
+
+
+
+
+
+
+ {record.value.filename}
+
+
+ {timeAgo()}
+
+
+
+
{lineCount()} lines
+
+
{formattedSize()}
+
+
+ view raw
+
+
+
+
+
+
+
+
+ {/* Comments section */}
+
+
+
+
+
+
+
+
+
+
+ );
+ }}
+
+
+ );
+};
+
+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(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 (
+
+ please log in to create a new string.
+
+ }
+ >
+
+
+ );
+};
+
+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