a love letter to tangled (android, iOS, and a search API)

feat: bookmarks and authentication gates

+1231 -94
+1
.gitignore
··· 39 39 *.db-wal 40 40 41 41 .env 42 + apps/twisted/.env.local 42 43 __pycache__/
+6 -2
README.md
··· 28 28 just api-run-api 29 29 ``` 30 30 31 - To enable indexed search in the client, set `VITE_TWISTER_API_BASE_URL` in `apps/twisted/.env`. 31 + The committed `apps/twisted/.env` points at production. Use `apps/twisted/.env.local` 32 + for machine-local overrides such as a localhost API or OAuth callback. 32 33 33 34 ## Run Locally 34 35 ··· 94 95 2. `pnpm api:run:api` 95 96 3. `pnpm api:run:indexer` 96 97 97 - If you want the app to call the local API, set this in `apps/twisted/.env`: 98 + If you want the app to call the local API, put this in `apps/twisted/.env.local`: 98 99 99 100 ```bash 100 101 VITE_TWISTER_API_BASE_URL=http://localhost:8080 101 102 ``` 103 + 104 + Dev builds keep the current OAuth flow available. Production builds are read-only 105 + and hide auth entry points for now. 102 106 103 107 ### Local API DB 104 108
+5 -4
apps/twisted/.env.example
··· 1 - VITE_TWISTER_API_BASE_URL=http://127.0.0.1:8080 1 + VITE_TWISTER_API_BASE_URL=https://twister.stormlightlabs.org 2 2 3 3 # OAuth configuration 4 - # VITE_OAUTH_CLIENT_ID must be a publicly accessible URL (use a tunnel for local dev) 5 - # VITE_OAUTH_CLIENT_ID=https://your-tunnel.example.com/oauth/client-metadata.json 6 - VITE_OAUTH_REDIRECT_URI=http://127.0.0.1:5173/oauth-callback 4 + # Auth is currently exposed in dev builds only. Override these in .env.local 5 + # if you need local OAuth testing against localhost. 6 + VITE_OAUTH_CLIENT_ID=https://twister.stormlightlabs.org/oauth/client-metadata.json 7 + VITE_OAUTH_REDIRECT_URI=https://twister.stormlightlabs.org/oauth-callback
+18 -6
apps/twisted/README.md
··· 6 6 7 7 - Node.js 20+ 8 8 - pnpm 9 - - The Twister API running locally 9 + - The Twister API running locally if you want local API overrides 10 10 11 11 ## Running locally 12 12 ··· 14 14 # From the repo root, install dependencies 15 15 pnpm install 16 16 17 - # Copy env file and point it at your local Twister API 18 - cp apps/twisted/.env.example apps/twisted/.env 17 + # Local overrides live in .env.local 18 + cp apps/twisted/.env.example apps/twisted/.env.local 19 19 20 20 # Start the Vite dev server 21 21 cd apps/twisted ··· 26 26 27 27 ## Environment variables 28 28 29 - | Variable | Default | Description | 30 - | --------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- | 31 - | `VITE_TWISTER_API_BASE_URL` | `http://localhost:8080` | Base URL of the Twister API. All app requests (AT Protocol, Jetstream, Constellation) are proxied through this. | 29 + Committed `.env` defaults point at production: 30 + 31 + - `VITE_TWISTER_API_BASE_URL=https://twister.stormlightlabs.org` 32 + - `VITE_OAUTH_CLIENT_ID=https://twister.stormlightlabs.org/oauth/client-metadata.json` 33 + - `VITE_OAUTH_REDIRECT_URI=https://twister.stormlightlabs.org/oauth-callback` 34 + 35 + Use `.env.local` for local overrides: 36 + 37 + | Variable | Local example | Description | 38 + | --------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | 39 + | `VITE_TWISTER_API_BASE_URL` | `http://localhost:8080` | Base URL of the Twister API. All app requests (AT Protocol, Jetstream, Constellation) are proxied through this. | 40 + | `VITE_OAUTH_CLIENT_ID` | `http://127.0.0.1:8080/oauth/client-metadata.json` | Public OAuth metadata URL for local dev auth flows. | 41 + | `VITE_OAUTH_REDIRECT_URI` | `http://127.0.0.1:5173/oauth-callback` | Redirect URI used by the local Vite app during OAuth testing. | 42 + 43 + Production builds hide auth entry points for now. Dev builds keep OAuth enabled. 32 44 33 45 > All upstream requests — knot XRPC, PDS records, handle resolution, DID documents, 34 46 > Constellation backlink counts, and the Jetstream activity stream — are routed
+21 -4
apps/twisted/src/app/router/index.ts
··· 1 1 import { createRouter, createWebHistory } from "@ionic/vue-router"; 2 2 import type { RouteRecordRaw } from "vue-router"; 3 - 4 3 import TabsPage from "@/views/TabsPage.vue"; 4 + import { getIsDevAuthEnabled } from "@/core/auth/dev-access.ts"; 5 5 6 6 const routes: RouteRecordRaw[] = [ 7 7 { path: "/", redirect: "/tabs/home" }, 8 - { path: "/login", component: () => import("@/features/auth/LoginPage.vue") }, 9 - { path: "/oauth-callback", component: () => import("@/features/auth/OAuthCallbackPage.vue") }, 8 + { path: "/login", component: () => import("@/features/auth/LoginPage.vue"), meta: { requiresDevAuth: true } }, 9 + { 10 + path: "/oauth-callback", 11 + component: () => import("@/features/auth/OAuthCallbackPage.vue"), 12 + meta: { requiresDevAuth: true }, 13 + }, 10 14 { 11 15 path: "/tabs/", 12 16 component: TabsPage, ··· 45 49 component: () => import("@/features/repo/PullRequestDetailPage.vue"), 46 50 }, 47 51 { path: "activity/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") }, 48 - { path: "profile", component: () => import("@/features/profile/ProfilePage.vue") }, 52 + { 53 + path: "profile", 54 + component: () => import("@/features/profile/ProfilePage.vue"), 55 + meta: { requiresDevAuth: true }, 56 + }, 57 + { path: "bookmarks", component: () => import("@/features/bookmarks/BookmarksPage.vue") }, 58 + { path: "bookmarks/:bookmarkId", component: () => import("@/features/bookmarks/BookmarkDetailPage.vue") }, 49 59 { path: "profile/settings", redirect: "/tabs/settings" }, 50 60 { path: "settings", component: () => import("@/features/profile/SettingsPage.vue") }, 51 61 ], ··· 53 63 ]; 54 64 55 65 const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes }); 66 + 67 + router.beforeEach((to) => { 68 + if (to.meta.requiresDevAuth && !getIsDevAuthEnabled()) { 69 + return "/tabs/bookmarks"; 70 + } 71 + return true; 72 + }); 56 73 57 74 export default router;
+112
apps/twisted/src/components/bookmarks/StoredContentView.vue
··· 1 + <template> 2 + <div v-if="imageUrl" class="image-wrap"> 3 + <img :src="imageUrl" :alt="bookmark.title" class="image-preview" /> 4 + </div> 5 + <div v-else-if="showMarkdown" class="markdown-wrap"> 6 + <MarkdownRenderer :content="textContent" /> 7 + </div> 8 + <div v-else-if="highlightedHtml" class="code-render" v-html="highlightedHtml" /> 9 + <pre v-else-if="textContent" class="code-wrap"><code>{{ textContent }}</code></pre> 10 + <div v-else class="empty-copy">This saved item has no viewable content.</div> 11 + </template> 12 + 13 + <script setup lang="ts"> 14 + import { computed, onBeforeUnmount, ref, watch } from "vue"; 15 + import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 16 + import type { SavedFile, SavedString } from "@/domain/models/bookmark.js"; 17 + import { highlightCode } from "@/lib/syntax.js"; 18 + import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.js"; 19 + 20 + const props = defineProps<{ bookmark: SavedFile | SavedString }>(); 21 + 22 + const highlightedHtml = ref(""); 23 + const imageUrl = ref<string | null>(null); 24 + 25 + const textContent = computed(() => { 26 + return props.bookmark.kind === "string" ? props.bookmark.contents : props.bookmark.content; 27 + }); 28 + 29 + const displayName = computed(() => { 30 + return props.bookmark.kind === "string" ? props.bookmark.filename : props.bookmark.path; 31 + }); 32 + 33 + const showMarkdown = computed(() => { 34 + if (props.bookmark.kind === "string") return false; 35 + return props.bookmark.sourceKind === "readme" || /\.md$/i.test(props.bookmark.path); 36 + }); 37 + 38 + watch( 39 + () => [props.bookmark, showMarkdown.value] as const, 40 + async ([bookmark, markdown]) => { 41 + highlightedHtml.value = ""; 42 + 43 + if (imageUrl.value) { 44 + URL.revokeObjectURL(imageUrl.value); 45 + imageUrl.value = null; 46 + } 47 + 48 + if (bookmark.kind === "file" && bookmark.isBinary) { 49 + imageUrl.value = createObjectUrlFromBlobContent({ 50 + path: bookmark.path, 51 + content: bookmark.content, 52 + encoding: bookmark.encoding, 53 + isBinary: bookmark.isBinary, 54 + mimeType: bookmark.mimeType, 55 + size: bookmark.size, 56 + }); 57 + return; 58 + } 59 + 60 + if (!markdown && textContent.value) { 61 + highlightedHtml.value = (await highlightCode(textContent.value, displayName.value)) ?? ""; 62 + } 63 + }, 64 + { immediate: true }, 65 + ); 66 + 67 + onBeforeUnmount(() => { 68 + if (imageUrl.value) { 69 + URL.revokeObjectURL(imageUrl.value); 70 + } 71 + }); 72 + </script> 73 + 74 + <style scoped> 75 + .image-wrap, 76 + .markdown-wrap, 77 + .code-wrap { 78 + margin: 0; 79 + } 80 + 81 + .image-preview { 82 + display: block; 83 + width: 100%; 84 + height: auto; 85 + object-fit: contain; 86 + background: var(--t-surface-raised); 87 + } 88 + 89 + .code-wrap { 90 + padding: 16px; 91 + overflow-x: auto; 92 + font-family: var(--t-mono); 93 + font-size: 12px; 94 + line-height: 1.6; 95 + color: var(--t-text-primary); 96 + white-space: pre-wrap; 97 + word-break: break-word; 98 + } 99 + 100 + .code-render :deep(.shiki) { 101 + margin: 0; 102 + padding: 16px; 103 + background: transparent !important; 104 + white-space: pre-wrap; 105 + } 106 + 107 + .empty-copy { 108 + padding: 24px 16px; 109 + color: var(--t-text-muted); 110 + font-size: 14px; 111 + } 112 + </style>
+36
apps/twisted/src/core/auth/dev-access.ts
··· 1 + import { readonly, ref } from "vue"; 2 + import { isDevBuild } from "@/core/config/app.ts"; 3 + 4 + const STORAGE_KEY = "twisted-dev-auth-enabled"; 5 + 6 + const devAuthEnabled = ref(readStoredDevAuthEnabled()); 7 + 8 + export function useDevAuthFeatures() { 9 + return { 10 + canToggleDevAuth: isDevBuild, 11 + isDevAuthEnabled: readonly(devAuthEnabled), 12 + setDevAuthEnabled, 13 + }; 14 + } 15 + 16 + export function getIsDevAuthEnabled(): boolean { 17 + return devAuthEnabled.value; 18 + } 19 + 20 + export function setDevAuthEnabled(value: boolean): void { 21 + if (!isDevBuild) return; 22 + 23 + devAuthEnabled.value = value; 24 + 25 + if (typeof window !== "undefined") { 26 + window.localStorage.setItem(STORAGE_KEY, value ? "true" : "false"); 27 + } 28 + } 29 + 30 + function readStoredDevAuthEnabled(): boolean { 31 + if (!isDevBuild) return false; 32 + if (typeof window === "undefined") return true; 33 + 34 + const stored = window.localStorage.getItem(STORAGE_KEY); 35 + return stored !== "false"; 36 + }
+3 -6
apps/twisted/src/core/auth/oauth-config.ts
··· 8 8 WellKnownHandleResolver, 9 9 DohJsonHandleResolver, 10 10 } from "@atcute/identity-resolver"; 11 + import { oauthClientId, oauthRedirectUri } from "@/core/config/app.js"; 11 12 12 13 const isNative = typeof window !== "undefined" && !window.location.origin.startsWith("http"); 13 14 const baseUrl = isNative ? "io.ionic.starter://" : window.location.origin; 14 - const clientId = 15 - (!isNative && import.meta.env.VITE_OAUTH_CLIENT_ID?.trim()) || 16 - `${baseUrl}/client-metadata.json`; 17 - const redirectUri = 18 - (!isNative && import.meta.env.VITE_OAUTH_REDIRECT_URI?.trim()) || 19 - `${baseUrl}/oauth-callback`; 15 + const clientId = (!isNative && oauthClientId) || `${baseUrl}/client-metadata.json`; 16 + const redirectUri = (!isNative && oauthRedirectUri) || `${baseUrl}/oauth-callback`; 20 17 21 18 const didResolver = new CompositeDidDocumentResolver({ 22 19 methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() },
+175
apps/twisted/src/core/bookmarks/service.ts
··· 1 + import { computed, readonly, shallowRef } from "vue"; 2 + import { del, get, set } from "idb-keyval"; 3 + import type { RepoSummary } from "@/domain/models/repo.js"; 4 + import type { StringSummary } from "@/domain/models/string.js"; 5 + import type { BlobContent } from "@/services/tangled/normalizers.js"; 6 + import type { BookmarkItem, BookmarkKind, BookmarkedRepo, SavedFile, SavedString } from "@/domain/models/bookmark.js"; 7 + 8 + const BOOKMARKS_KEY = "twisted-bookmarks"; 9 + 10 + type BookmarkStorage = { load(): Promise<BookmarkItem[]>; save(items: BookmarkItem[]): Promise<void> }; 11 + 12 + const state = shallowRef<BookmarkItem[]>([]); 13 + let loaded = false; 14 + let loadPromise: Promise<BookmarkItem[]> | null = null; 15 + const defaultStorage: BookmarkStorage = { 16 + async load() { 17 + return ((await get<BookmarkItem[]>(BOOKMARKS_KEY)) ?? []).map(normalizeBookmarkItem); 18 + }, 19 + async save(items) { 20 + if (items.length === 0) { 21 + await del(BOOKMARKS_KEY); 22 + return; 23 + } 24 + await set(BOOKMARKS_KEY, items); 25 + }, 26 + }; 27 + let storage: BookmarkStorage = defaultStorage; 28 + 29 + function normalizeBookmarkItem(item: BookmarkItem): BookmarkItem { 30 + return item; 31 + } 32 + 33 + function sortItems(items: BookmarkItem[]): BookmarkItem[] { 34 + return [...items].sort((a, b) => b.savedAt.localeCompare(a.savedAt)); 35 + } 36 + 37 + async function persist(items: BookmarkItem[]): Promise<BookmarkItem[]> { 38 + const next = sortItems(items); 39 + state.value = next; 40 + loaded = true; 41 + await storage.save(next); 42 + return next; 43 + } 44 + 45 + export async function ensureBookmarksLoaded(): Promise<BookmarkItem[]> { 46 + if (loaded) return state.value; 47 + if (!loadPromise) { 48 + loadPromise = storage.load().then((items) => { 49 + state.value = sortItems(items); 50 + loaded = true; 51 + loadPromise = null; 52 + return state.value; 53 + }); 54 + } 55 + return loadPromise; 56 + } 57 + 58 + export function useBookmarks() { 59 + void ensureBookmarksLoaded(); 60 + return { bookmarks: readonly(state), hasBookmarks: computed(() => state.value.length > 0) }; 61 + } 62 + 63 + export function buildRepoBookmarkId(atUri: string): string { 64 + return `repo:${atUri}`; 65 + } 66 + 67 + export function buildStringBookmarkId(atUri: string): string { 68 + return `string:${atUri}`; 69 + } 70 + 71 + export function buildFileBookmarkId(owner: string, repo: string, branch: string, path: string): string { 72 + return `file:${owner}/${repo}/${branch}/${path}`; 73 + } 74 + 75 + export async function listBookmarks(kind?: BookmarkKind): Promise<BookmarkItem[]> { 76 + const items = await ensureBookmarksLoaded(); 77 + return kind ? items.filter((item) => item.kind === kind) : items; 78 + } 79 + 80 + export async function getBookmark(id: string): Promise<BookmarkItem | undefined> { 81 + return (await ensureBookmarksLoaded()).find((item) => item.id === id); 82 + } 83 + 84 + export async function isBookmarked(id: string): Promise<boolean> { 85 + return (await ensureBookmarksLoaded()).some((item) => item.id === id); 86 + } 87 + 88 + export function hasBookmark(id: string): boolean { 89 + return state.value.some((item) => item.id === id); 90 + } 91 + 92 + export async function removeBookmark(id: string): Promise<void> { 93 + await persist((await ensureBookmarksLoaded()).filter((item) => item.id !== id)); 94 + } 95 + 96 + export async function removeRepoBookmark(id: string): Promise<void> { 97 + await removeBookmark(id); 98 + } 99 + 100 + export async function saveRepoBookmark(repo: RepoSummary): Promise<BookmarkedRepo> { 101 + const now = new Date().toISOString(); 102 + const bookmark: BookmarkedRepo = { 103 + id: buildRepoBookmarkId(repo.atUri), 104 + kind: "repo", 105 + title: `${repo.ownerHandle}/${repo.name}`, 106 + ownerHandle: repo.ownerHandle, 107 + repoName: repo.name, 108 + atUri: repo.atUri, 109 + savedAt: now, 110 + lastFetchedAt: now, 111 + repo, 112 + }; 113 + await persist([bookmark, ...(await ensureBookmarksLoaded()).filter((item) => item.id !== bookmark.id)]); 114 + return bookmark; 115 + } 116 + 117 + export async function saveStringBookmark(ownerHandle: string, stringItem: StringSummary): Promise<SavedString> { 118 + const now = new Date().toISOString(); 119 + const bookmark: SavedString = { 120 + id: buildStringBookmarkId(stringItem.atUri), 121 + kind: "string", 122 + title: stringItem.filename, 123 + ownerHandle, 124 + atUri: stringItem.atUri, 125 + filename: stringItem.filename, 126 + description: stringItem.description, 127 + contents: stringItem.contents, 128 + createdAt: stringItem.createdAt, 129 + savedAt: now, 130 + lastFetchedAt: now, 131 + }; 132 + await persist([bookmark, ...(await ensureBookmarksLoaded()).filter((item) => item.id !== bookmark.id)]); 133 + return bookmark; 134 + } 135 + 136 + export async function saveFileBookmark(file: SavedFileInput): Promise<SavedFile> { 137 + const now = new Date().toISOString(); 138 + const bookmark: SavedFile = { ...file, savedAt: now, lastFetchedAt: now }; 139 + await persist([bookmark, ...(await ensureBookmarksLoaded()).filter((item) => item.id !== bookmark.id)]); 140 + return bookmark; 141 + } 142 + 143 + type SavedFileInput = Omit<SavedFile, "savedAt" | "lastFetchedAt">; 144 + 145 + export function createSavedFileInput( 146 + ownerHandle: string, 147 + repoName: string, 148 + branch: string, 149 + path: string, 150 + blob: BlobContent, 151 + sourceKind: "file" | "readme", 152 + ): SavedFileInput { 153 + return { 154 + id: buildFileBookmarkId(ownerHandle, repoName, branch, path), 155 + kind: "file", 156 + title: path.split("/").pop() ?? path, 157 + ownerHandle, 158 + repoName, 159 + branch, 160 + path, 161 + sourceKind, 162 + content: blob.content, 163 + encoding: blob.encoding, 164 + isBinary: blob.isBinary, 165 + mimeType: blob.mimeType, 166 + size: blob.size, 167 + }; 168 + } 169 + 170 + export function resetBookmarkStateForTests(nextStorage?: BookmarkStorage): void { 171 + state.value = []; 172 + loaded = false; 173 + loadPromise = null; 174 + storage = nextStorage ?? defaultStorage; 175 + }
+46
apps/twisted/src/core/config/app.ts
··· 1 + type AppEnv = { 2 + DEV: boolean; 3 + VITE_TWISTER_API_BASE_URL?: string; 4 + VITE_OAUTH_CLIENT_ID?: string; 5 + VITE_OAUTH_REDIRECT_URI?: string; 6 + }; 7 + 8 + type AppConfig = { 9 + isDevBuild: boolean; 10 + isProductionReadOnly: boolean; 11 + twisterApiBaseUrl: string; 12 + hasTwisterApi: boolean; 13 + oauthClientId?: string; 14 + oauthRedirectUri?: string; 15 + }; 16 + 17 + const DEFAULT_API_BASE_URL = "https://twister.stormlightlabs.org"; 18 + 19 + export function normalizeConfiguredUrl(value?: string): string | undefined { 20 + const trimmed = value?.trim(); 21 + if (!trimmed) return undefined; 22 + return trimmed.replace(/\/+$/, ""); 23 + } 24 + 25 + export function createAppConfig(env: AppEnv): AppConfig { 26 + const twisterApiBaseUrl = normalizeConfiguredUrl(env.VITE_TWISTER_API_BASE_URL) ?? DEFAULT_API_BASE_URL; 27 + const oauthClientId = normalizeConfiguredUrl(env.VITE_OAUTH_CLIENT_ID); 28 + const oauthRedirectUri = normalizeConfiguredUrl(env.VITE_OAUTH_REDIRECT_URI); 29 + 30 + return { 31 + isDevBuild: env.DEV, 32 + isProductionReadOnly: !env.DEV, 33 + twisterApiBaseUrl, 34 + hasTwisterApi: twisterApiBaseUrl.length > 0, 35 + oauthClientId, 36 + oauthRedirectUri, 37 + }; 38 + } 39 + 40 + export const appConfig = createAppConfig(import.meta.env); 41 + export const isDevBuild = appConfig.isDevBuild; 42 + export const isProductionReadOnly = appConfig.isProductionReadOnly; 43 + export const twisterApiBaseUrl = appConfig.twisterApiBaseUrl; 44 + export const hasTwisterApi = appConfig.hasTwisterApi; 45 + export const oauthClientId = appConfig.oauthClientId; 46 + export const oauthRedirectUri = appConfig.oauthRedirectUri;
+2 -3
apps/twisted/src/core/config/project.ts
··· 1 - const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "http://127.0.0.1:8080"; 1 + import { hasTwisterApi, twisterApiBaseUrl } from "./app.ts"; 2 2 3 - export const twisterApiBaseUrl = rawTwisterApiBaseUrl.replace(/\/+$/, ""); 4 - export const hasTwisterApi = twisterApiBaseUrl.length > 0; 3 + export { hasTwisterApi, twisterApiBaseUrl }; 5 4 6 5 export function getTwisterApiUrl(path: string): string { 7 6 if (!hasTwisterApi) {
+2 -1
apps/twisted/src/core/query/client.ts
··· 1 1 import { QueryClient } from "@tanstack/vue-query"; 2 + import { isDevBuild } from "@/core/config/app.ts"; 2 3 3 - const isDev = import.meta.env.DEV; 4 + const isDev = isDevBuild; 4 5 5 6 export const queryClient = new QueryClient({ 6 7 defaultOptions: {
+40
apps/twisted/src/domain/models/bookmark.ts
··· 1 + import type { RepoSummary } from "./repo.ts"; 2 + 3 + export type BookmarkKind = "repo" | "string" | "file"; 4 + 5 + type BookmarkBase = { id: string; kind: BookmarkKind; title: string; savedAt: string; lastFetchedAt: string }; 6 + 7 + export type BookmarkedRepo = BookmarkBase & { 8 + kind: "repo"; 9 + ownerHandle: string; 10 + repoName: string; 11 + atUri: string; 12 + repo: RepoSummary; 13 + }; 14 + 15 + export type SavedString = BookmarkBase & { 16 + kind: "string"; 17 + ownerHandle: string; 18 + atUri: string; 19 + filename: string; 20 + description?: string; 21 + contents: string; 22 + createdAt: string; 23 + }; 24 + 25 + export type SavedFile = BookmarkBase & { 26 + kind: "file"; 27 + ownerHandle: string; 28 + repoName: string; 29 + branch: string; 30 + path: string; 31 + sourceAtUri?: string; 32 + sourceKind: "file" | "readme"; 33 + content: string; 34 + encoding: "utf-8" | "base64"; 35 + isBinary: boolean; 36 + mimeType?: string; 37 + size?: number; 38 + }; 39 + 40 + export type BookmarkItem = BookmarkedRepo | SavedString | SavedFile;
+77
apps/twisted/src/features/bookmarks/BookmarkDetailPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-header :translucent="true"> 4 + <ion-toolbar> 5 + <ion-buttons slot="start"> 6 + <ion-back-button default-href="/tabs/bookmarks" /> 7 + </ion-buttons> 8 + <ion-title>{{ bookmark?.title || "Saved Item" }}</ion-title> 9 + </ion-toolbar> 10 + </ion-header> 11 + 12 + <ion-content :fullscreen="true"> 13 + <EmptyState 14 + v-if="!bookmark" 15 + :icon="bookmarkOutline" 16 + title="Saved item not found" 17 + message="This bookmark may have been removed from local storage." /> 18 + 19 + <template v-else> 20 + <div class="detail-meta"> 21 + <div class="meta-title">{{ bookmark.title }}</div> 22 + <div class="meta-copy">{{ metaLine }}</div> 23 + </div> 24 + <StoredContentView :bookmark="bookmark" /> 25 + </template> 26 + </ion-content> 27 + </ion-page> 28 + </template> 29 + 30 + <script setup lang="ts"> 31 + import { computed, onMounted } from "vue"; 32 + import { useRoute } from "vue-router"; 33 + import { IonBackButton, IonButtons, IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from "@ionic/vue"; 34 + import { bookmarkOutline } from "ionicons/icons"; 35 + import StoredContentView from "@/components/bookmarks/StoredContentView.vue"; 36 + import EmptyState from "@/components/common/EmptyState.vue"; 37 + import type { SavedFile, SavedString } from "@/domain/models/bookmark.ts"; 38 + import { ensureBookmarksLoaded, useBookmarks } from "@/core/bookmarks/service.ts"; 39 + 40 + const route = useRoute(); 41 + const { bookmarks } = useBookmarks(); 42 + 43 + onMounted(() => { 44 + void ensureBookmarksLoaded(); 45 + }); 46 + 47 + const bookmark = computed(() => { 48 + const id = decodeURIComponent(String(route.params.bookmarkId ?? "")); 49 + const item = bookmarks.value.find((entry) => entry.id === id); 50 + return item?.kind === "repo" ? undefined : (item as SavedFile | SavedString | undefined); 51 + }); 52 + 53 + const metaLine = computed(() => { 54 + if (!bookmark.value) return ""; 55 + if (bookmark.value.kind === "string") return `${bookmark.value.ownerHandle} · ${bookmark.value.filename}`; 56 + return `${bookmark.value.ownerHandle}/${bookmark.value.repoName} · ${bookmark.value.path}`; 57 + }); 58 + </script> 59 + 60 + <style scoped> 61 + .detail-meta { 62 + padding: 16px; 63 + border-bottom: 1px solid var(--t-border); 64 + } 65 + 66 + .meta-title { 67 + font-size: 16px; 68 + font-weight: 700; 69 + color: var(--t-text-primary); 70 + } 71 + 72 + .meta-copy { 73 + margin-top: 6px; 74 + font-size: 12px; 75 + color: var(--t-text-muted); 76 + } 77 + </style>
+134
apps/twisted/src/features/bookmarks/BookmarksPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-header :translucent="true"> 4 + <ion-toolbar> 5 + <ion-title>Bookmarks</ion-title> 6 + </ion-toolbar> 7 + </ion-header> 8 + 9 + <ion-content :fullscreen="true"> 10 + <ion-header collapse="condense"> 11 + <ion-toolbar> 12 + <ion-title size="large">Bookmarks</ion-title> 13 + </ion-toolbar> 14 + </ion-header> 15 + 16 + <ion-segment v-model="activeKind" class="kind-segment"> 17 + <ion-segment-button value="repo">Repos</ion-segment-button> 18 + <ion-segment-button value="string">Strings</ion-segment-button> 19 + <ion-segment-button value="file">Files</ion-segment-button> 20 + </ion-segment> 21 + 22 + <EmptyState 23 + v-if="filteredItems.length === 0" 24 + :icon="bookmarkOutline" 25 + title="Nothing saved yet" 26 + message="Save repos, strings, files, or READMEs to keep them here for offline viewing." /> 27 + 28 + <ion-list v-else lines="inset" class="bookmark-list"> 29 + <ion-item v-for="item in filteredItems" :key="item.id" button @click="openItem(item)"> 30 + <ion-label> 31 + <div class="item-title">{{ item.title }}</div> 32 + <div class="item-meta">{{ itemMeta(item) }}</div> 33 + <p class="item-copy">{{ itemCopy(item) }}</p> 34 + </ion-label> 35 + <ion-button slot="end" fill="clear" color="danger" @click.stop="removeItem(item.id)"> Remove </ion-button> 36 + </ion-item> 37 + </ion-list> 38 + </ion-content> 39 + </ion-page> 40 + </template> 41 + 42 + <script setup lang="ts"> 43 + import { computed, ref } from "vue"; 44 + import { useRouter } from "vue-router"; 45 + import { 46 + IonButton, 47 + IonContent, 48 + IonHeader, 49 + IonItem, 50 + IonLabel, 51 + IonList, 52 + IonPage, 53 + IonSegment, 54 + IonSegmentButton, 55 + IonTitle, 56 + IonToolbar, 57 + } from "@ionic/vue"; 58 + import { bookmarkOutline } from "ionicons/icons"; 59 + import EmptyState from "@/components/common/EmptyState.vue"; 60 + import type { BookmarkItem, BookmarkKind } from "@/domain/models/bookmark.ts"; 61 + import { removeBookmark, useBookmarks } from "@/core/bookmarks/service.ts"; 62 + 63 + const router = useRouter(); 64 + const activeKind = ref<BookmarkKind>("repo"); 65 + const { bookmarks } = useBookmarks(); 66 + 67 + const filteredItems = computed(() => bookmarks.value.filter((item) => item.kind === activeKind.value)); 68 + 69 + function itemMeta(item: BookmarkItem): string { 70 + if (item.kind === "repo") return `${item.ownerHandle}/${item.repoName}`; 71 + if (item.kind === "string") return `${item.ownerHandle} · ${relativeTime(item.savedAt)}`; 72 + return `${item.ownerHandle}/${item.repoName} · ${item.path}`; 73 + } 74 + 75 + function itemCopy(item: BookmarkItem): string { 76 + if (item.kind === "repo") return item.repo.description || "Saved repo metadata available offline."; 77 + if (item.kind === "string") return item.description || preview(item.contents); 78 + return `${item.sourceKind === "readme" ? "README" : "Saved file"} · ${relativeTime(item.savedAt)}`; 79 + } 80 + 81 + function openItem(item: BookmarkItem) { 82 + if (item.kind === "repo") { 83 + router.push(`/tabs/home/repo/${item.ownerHandle}/${item.repoName}`); 84 + return; 85 + } 86 + router.push(`/tabs/bookmarks/${encodeURIComponent(item.id)}`); 87 + } 88 + 89 + async function removeItem(id: string) { 90 + await removeBookmark(id); 91 + } 92 + 93 + function preview(text: string): string { 94 + return text.length > 96 ? `${text.slice(0, 96)}...` : text; 95 + } 96 + 97 + function relativeTime(iso: string): string { 98 + const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); 99 + if (minutes < 1) return "just now"; 100 + if (minutes < 60) return `${minutes}m ago`; 101 + const hours = Math.floor(minutes / 60); 102 + if (hours < 24) return `${hours}h ago`; 103 + return `${Math.floor(hours / 24)}d ago`; 104 + } 105 + </script> 106 + 107 + <style scoped> 108 + .kind-segment { 109 + padding: 12px 12px 8px; 110 + } 111 + 112 + .bookmark-list { 113 + background: transparent; 114 + } 115 + 116 + .item-title { 117 + font-size: 14px; 118 + font-weight: 700; 119 + color: var(--t-text-primary); 120 + } 121 + 122 + .item-meta { 123 + margin-top: 4px; 124 + font-size: 12px; 125 + color: var(--t-text-muted); 126 + } 127 + 128 + .item-copy { 129 + margin: 6px 0 0; 130 + font-size: 13px; 131 + line-height: 1.45; 132 + color: var(--t-text-secondary); 133 + } 134 + </style>
+20 -8
apps/twisted/src/features/profile/ProfilePage.vue
··· 4 4 <ion-toolbar> 5 5 <ion-title class="profile-title mono">{{ profile?.handle || "Profile" }}</ion-title> 6 6 <ion-buttons v-if="authStore.isAuthenticated" slot="end"> 7 + <ion-button @click="goToBookmarks"> 8 + <ion-icon slot="icon-only" :icon="bookmarkOutline" /> 9 + </ion-button> 7 10 <ion-button @click="goToSettings"> 8 11 <ion-icon slot="icon-only" :icon="settingsOutline" /> 9 12 </ion-button> ··· 25 28 <ion-avatar class="avatar"> 26 29 <img v-if="profile?.avatar" :src="profile.avatar" :alt="`${profile.handle} avatar`" class="avatar-image" /> 27 30 <div v-else class="avatar-fallback" :style="{ background: avatarColor(profile?.handle || '') }"> 28 - {{ initials(profile?.handle || '') }} 31 + {{ initials(profile?.handle || "") }} 29 32 </div> 30 33 </ion-avatar> 31 34 ··· 106 109 message="You haven't created any repositories yet." /> 107 110 </template> 108 111 109 - <UserStrings v-else-if="section === 'strings'" :strings="strings" :is-loading="stringsQuery.isPending.value" /> 112 + <UserStrings 113 + v-else-if="section === 'strings'" 114 + :strings="strings" 115 + :owner-handle="profile?.handle || identifier" 116 + :is-loading="stringsQuery.isPending.value" /> 110 117 111 118 <RepoIssues 112 119 v-else-if="section === 'issues'" ··· 207 214 codeSlashOutline, 208 215 logInOutline, 209 216 logOutOutline, 217 + bookmarkOutline, 210 218 settingsOutline, 211 219 personOutline, 212 220 locationOutline, ··· 221 229 import RepoIssues from "@/features/repo/RepoIssues.vue"; 222 230 import RepoPRs from "@/features/repo/RepoPRs.vue"; 223 231 import UserStrings from "@/features/profile/UserStrings.vue"; 224 - import { useAuthStore } from "@/core/auth/store.js"; 232 + import { useAuthStore } from "@/core/auth/store.ts"; 225 233 import { 226 234 useActorProfile, 227 235 useUserRepos, ··· 229 237 useUserIssues, 230 238 useUserPullRequests, 231 239 useUserFollowing, 232 - } from "@/services/tangled/queries.js"; 233 - import { useIndexedProfileSummary } from "@/services/project-api/queries.js"; 234 - import type { IssueSummary } from "@/domain/models/issue.js"; 235 - import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 236 - import type { RepoSummary } from "@/domain/models/repo.js"; 240 + } from "@/services/tangled/queries.ts"; 241 + import { useIndexedProfileSummary } from "@/services/project-api/queries.ts"; 242 + import type { IssueSummary } from "@/domain/models/issue.ts"; 243 + import type { PullRequestSummary } from "@/domain/models/pull-request.ts"; 244 + import type { RepoSummary } from "@/domain/models/repo.ts"; 237 245 238 246 const router = useRouter(); 239 247 const authStore = useAuthStore(); ··· 312 320 313 321 function goToSettings() { 314 322 router.push("/tabs/settings"); 323 + } 324 + 325 + function goToBookmarks() { 326 + router.push("/tabs/bookmarks"); 315 327 } 316 328 317 329 async function switchToAccount(did: `did:${string}:${string}`) {
+85 -3
apps/twisted/src/features/profile/SettingsPage.vue
··· 46 46 <p class="helper-copy">{{ themeHelperCopy }}</p> 47 47 </section> 48 48 49 + <section v-if="isDevAuthEnabled" class="settings-card"> 50 + <div class="section-head"> 51 + <div> 52 + <p class="section-label">Bookmarks</p> 53 + <h2 class="section-title">Saved content</h2> 54 + </div> 55 + </div> 56 + 57 + <p class="helper-copy"> 58 + Dev builds keep Profile for auth flows, so saved repos, strings, files, and READMEs live on a separate page. 59 + </p> 60 + 61 + <ion-button expand="block" @click="goToBookmarks">Open bookmarks</ion-button> 62 + </section> 63 + 64 + <section v-if="canToggleDevAuth" class="settings-card"> 65 + <div class="section-head"> 66 + <div> 67 + <p class="section-label">Local Dev</p> 68 + <h2 class="section-title">Auth features</h2> 69 + </div> 70 + </div> 71 + 72 + <ion-item lines="none" class="toggle-row"> 73 + <ion-label> 74 + <div class="toggle-title">Enable auth UI</div> 75 + <p class="toggle-copy"> 76 + Turn this off to force the app into bookmark-only mode locally without changing your build config. 77 + </p> 78 + </ion-label> 79 + <ion-toggle slot="end" :checked="isDevAuthEnabled" @ionChange="handleDevAuthToggle" /> 80 + </ion-item> 81 + </section> 82 + 49 83 <section class="settings-card danger-card"> 50 84 <div class="section-head"> 51 85 <div> ··· 84 118 85 119 <script setup lang="ts"> 86 120 import { computed, ref } from "vue"; 121 + import { useRouter } from "vue-router"; 87 122 import { 88 123 IonPage, 89 124 IonHeader, ··· 94 129 IonSegmentButton, 95 130 IonLabel, 96 131 IonButton, 132 + IonItem, 97 133 IonIcon, 134 + IonToggle, 98 135 IonAlert, 99 136 IonToast, 100 137 } from "@ionic/vue"; 101 138 import { trashOutline } from "ionicons/icons"; 102 - import { clearAppCache } from "@/core/query/cache.js"; 103 - import { setThemePreference, useThemePreference } from "@/core/theme/preferences.js"; 104 - import type { ThemePreference } from "@/core/theme/preferences.js"; 139 + import { useAuthStore } from "@/core/auth/store.ts"; 140 + import { useDevAuthFeatures } from "@/core/auth/dev-access.ts"; 141 + import { clearAppCache } from "@/core/query/cache.ts"; 142 + import { setThemePreference, useThemePreference } from "@/core/theme/preferences.ts"; 143 + import type { ThemePreference } from "@/core/theme/preferences.ts"; 105 144 145 + const router = useRouter(); 146 + const authStore = useAuthStore(); 147 + const { canToggleDevAuth, isDevAuthEnabled, setDevAuthEnabled } = useDevAuthFeatures(); 106 148 const { themePreference, resolvedTheme, isSystemTheme } = useThemePreference(); 107 149 108 150 const showClearConfirm = ref(false); ··· 156 198 function isThemePreference(value: unknown): value is ThemePreference { 157 199 return value === "system" || value === "light" || value === "dark"; 158 200 } 201 + 202 + function goToBookmarks() { 203 + router.push("/tabs/bookmarks"); 204 + } 205 + 206 + async function handleDevAuthToggle(event: CustomEvent<{ checked: boolean }>) { 207 + const enabled = !!event.detail.checked; 208 + setDevAuthEnabled(enabled); 209 + 210 + if (enabled) { 211 + authStore.initialize(); 212 + await authStore.restoreSession(); 213 + toastMessage.value = "Auth features enabled for local dev."; 214 + } else { 215 + router.replace("/tabs/bookmarks"); 216 + toastMessage.value = "Auth features hidden. Bookmarks mode is active."; 217 + } 218 + 219 + isToastOpen.value = true; 220 + } 159 221 </script> 160 222 161 223 <style scoped> ··· 242 304 .theme-segment { 243 305 margin-top: 18px; 244 306 --background: transparent; 307 + } 308 + 309 + .toggle-row { 310 + --background: transparent; 311 + --padding-start: 0; 312 + --inner-padding-end: 0; 313 + margin-top: 10px; 314 + } 315 + 316 + .toggle-title { 317 + font-size: 14px; 318 + font-weight: 600; 319 + color: var(--t-text-primary); 320 + } 321 + 322 + .toggle-copy { 323 + margin: 6px 0 0; 324 + font-size: 13px; 325 + line-height: 1.5; 326 + color: var(--t-text-secondary); 245 327 } 246 328 247 329 .helper-copy {
+10 -6
apps/twisted/src/features/profile/UserProfilePage.vue
··· 107 107 message="This user hasn't created any repositories yet." /> 108 108 </template> 109 109 110 - <UserStrings v-else-if="section === 'strings'" :strings="strings" :is-loading="stringsQuery.isPending.value" /> 110 + <UserStrings 111 + v-else-if="section === 'strings'" 112 + :strings="strings" 113 + :owner-handle="handle" 114 + :is-loading="stringsQuery.isPending.value" /> 111 115 112 116 <RepoIssues 113 117 v-else-if="section === 'issues'" ··· 182 186 useUserIssues, 183 187 useUserPullRequests, 184 188 useUserFollowing, 185 - } from "@/services/tangled/queries.js"; 186 - import { useIndexedProfileSummary } from "@/services/project-api/queries.js"; 187 - import type { IssueSummary } from "@/domain/models/issue.js"; 188 - import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 189 - import type { RepoSummary } from "@/domain/models/repo.js"; 189 + } from "@/services/tangled/queries.ts"; 190 + import { useIndexedProfileSummary } from "@/services/project-api/queries.ts"; 191 + import type { IssueSummary } from "@/domain/models/issue.ts"; 192 + import type { PullRequestSummary } from "@/domain/models/pull-request.ts"; 193 + import type { RepoSummary } from "@/domain/models/repo.ts"; 190 194 191 195 const route = useRoute(); 192 196 const router = useRouter();
+27 -3
apps/twisted/src/features/profile/UserStrings.vue
··· 15 15 <p v-if="stringItem.description" class="string-description">{{ stringItem.description }}</p> 16 16 <pre class="string-preview">{{ preview(stringItem.contents) }}</pre> 17 17 </ion-label> 18 + <ion-button slot="end" fill="clear" size="small" @click.stop="toggleSave(stringItem)"> 19 + {{ isSaved(stringItem.atUri) ? "Saved" : "Save" }} 20 + </ion-button> 18 21 </ion-item> 19 22 </ion-list> 20 23 ··· 28 31 </template> 29 32 30 33 <script setup lang="ts"> 31 - import { IonItem, IonLabel, IonList, IonSpinner } from "@ionic/vue"; 34 + import { IonButton, IonItem, IonLabel, IonList, IonSpinner, toastController } from "@ionic/vue"; 32 35 import { documentTextOutline } from "ionicons/icons"; 33 36 import EmptyState from "@/components/common/EmptyState.vue"; 34 - import type { StringSummary } from "@/domain/models/string.js"; 37 + import type { StringSummary } from "@/domain/models/string.ts"; 38 + import { buildStringBookmarkId, hasBookmark, removeBookmark, saveStringBookmark } from "@/core/bookmarks/service.ts"; 35 39 36 - defineProps<{ strings: StringSummary[]; isLoading?: boolean }>(); 40 + const props = defineProps<{ strings: StringSummary[]; ownerHandle: string; isLoading?: boolean }>(); 37 41 38 42 function preview(contents: string): string { 39 43 return contents.length > 280 ? `${contents.slice(0, 280)}...` : contents; ··· 48 52 if (hours > 0) return `${hours}h ago`; 49 53 if (minutes > 0) return `${minutes}m ago`; 50 54 return "just now"; 55 + } 56 + 57 + function isSaved(atUri: string): boolean { 58 + return hasBookmark(buildStringBookmarkId(atUri)); 59 + } 60 + 61 + async function toggleSave(stringItem: StringSummary) { 62 + const id = buildStringBookmarkId(stringItem.atUri); 63 + if (hasBookmark(id)) { 64 + await removeBookmark(id); 65 + await presentToast("String removed."); 66 + return; 67 + } 68 + await saveStringBookmark(props.ownerHandle, stringItem); 69 + await presentToast("String saved."); 70 + } 71 + 72 + async function presentToast(message: string) { 73 + const toast = await toastController.create({ message, duration: 1800, color: "success" }); 74 + await toast.present(); 51 75 } 52 76 </script> 53 77
+71 -6
apps/twisted/src/features/repo/RepoDetailPage.vue
··· 8 8 <ion-title class="repo-title"> 9 9 <span class="owner">{{ owner }}/</span>{{ repo?.name ?? repoName }} 10 10 </ion-title> 11 + <ion-buttons slot="end"> 12 + <ion-button :disabled="!repo" @click="toggleRepoSave"> 13 + <ion-icon slot="icon-only" :icon="repoSaved ? bookmark : bookmarkOutline" /> 14 + </ion-button> 15 + </ion-buttons> 11 16 </ion-toolbar> 12 17 <ion-toolbar> 13 18 <ion-segment v-model="segment" class="detail-segment"> ··· 42 47 v-if="segment === 'overview'" 43 48 :repo="repo" 44 49 :commits="commits" 45 - :markdown-context="markdownContext" /> 50 + :markdown-context="markdownContext" 51 + :readme-path="readmePath" 52 + :is-readme-saved="readmeSaved" 53 + @toggle-readme-save="toggleReadmeSave" /> 46 54 <RepoFiles v-else-if="segment === 'files'" :owner="owner" :repo="repoName" :branch="defaultBranch" /> 47 55 <RepoIssues 48 56 v-else-if="segment === 'issues'" ··· 68 76 IonToolbar, 69 77 IonTitle, 70 78 IonContent, 79 + IonButton, 71 80 IonButtons, 72 81 IonBackButton, 82 + IonIcon, 73 83 IonSegment, 74 84 IonSegmentButton, 85 + toastController, 75 86 } from "@ionic/vue"; 76 - import { alertCircleOutline } from "ionicons/icons"; 87 + import { alertCircleOutline, bookmark, bookmarkOutline } from "ionicons/icons"; 77 88 import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 78 89 import EmptyState from "@/components/common/EmptyState.vue"; 79 90 import RepoOverview from "./RepoOverview.vue"; ··· 88 99 useRepoLog, 89 100 useRepoIssues, 90 101 useRepoPRs, 91 - } from "@/services/tangled/queries.js"; 92 - import { useRepoStarCount } from "@/services/constellation/queries.js"; 93 - import type { RepoDetail } from "@/domain/models/repo.js"; 94 - import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 102 + } from "@/services/tangled/queries.ts"; 103 + import { useRepoStarCount } from "@/services/constellation/queries.ts"; 104 + import type { RepoDetail } from "@/domain/models/repo.ts"; 105 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.ts"; 106 + import { 107 + buildFileBookmarkId, 108 + buildRepoBookmarkId, 109 + createSavedFileInput, 110 + hasBookmark, 111 + removeBookmark, 112 + removeRepoBookmark, 113 + saveFileBookmark, 114 + saveRepoBookmark, 115 + } from "@/core/bookmarks/service.ts"; 95 116 96 117 const route = useRoute(); 97 118 const router = useRouter(); ··· 159 180 160 181 const repoAtUri = computed(() => recordQuery.data.value?.atUri ?? ""); 161 182 const hasAtUri = computed(() => !!repoAtUri.value); 183 + const readmePath = computed(() => readmeQuery.data.value?.path ?? ""); 162 184 163 185 const starCountQuery = useRepoStarCount(repoAtUri, { enabled: hasAtUri }); 164 186 ··· 181 203 const err = recordQuery.error.value; 182 204 return err instanceof Error ? err.message : "An unexpected error occurred."; 183 205 }); 206 + const repoBookmarkId = computed(() => (repo.value ? buildRepoBookmarkId(repo.value.atUri) : "")); 207 + const repoSaved = computed(() => !!repoBookmarkId.value && hasBookmark(repoBookmarkId.value)); 208 + const readmeBookmarkId = computed(() => { 209 + if (!owner.value || !repoName.value || !defaultBranch.value || !readmePath.value) return ""; 210 + return buildFileBookmarkId(owner.value, repoName.value, defaultBranch.value, readmePath.value); 211 + }); 212 + const readmeSaved = computed(() => !!readmeBookmarkId.value && hasBookmark(readmeBookmarkId.value)); 184 213 185 214 function openIssue(issue: { rkey: string }) { 186 215 router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/issues/${issue.rkey}?tab=issues`); ··· 188 217 189 218 function openPullRequest(pr: { rkey: string }) { 190 219 router.push(`${tabPrefix.value}/repo/${owner.value}/${repoName.value}/pulls/${pr.rkey}?tab=prs`); 220 + } 221 + 222 + async function toggleRepoSave() { 223 + if (!repo.value || !repoBookmarkId.value) return; 224 + if (repoSaved.value) { 225 + await removeRepoBookmark(repoBookmarkId.value); 226 + await presentToast("Removed from bookmarks."); 227 + return; 228 + } 229 + await saveRepoBookmark(repo.value); 230 + await presentToast("Repo saved."); 231 + } 232 + 233 + async function toggleReadmeSave() { 234 + if (!repo.value || !defaultBranch.value || !readmePath.value || !repo.value.readme) return; 235 + if (readmeSaved.value) { 236 + await removeBookmark(readmeBookmarkId.value); 237 + await presentToast("README removed."); 238 + return; 239 + } 240 + await saveFileBookmark( 241 + createSavedFileInput( 242 + owner.value, 243 + repoName.value, 244 + defaultBranch.value, 245 + readmePath.value, 246 + { path: readmePath.value, content: repo.value.readme, encoding: "utf-8", isBinary: false }, 247 + "readme", 248 + ), 249 + ); 250 + await presentToast("README saved."); 251 + } 252 + 253 + async function presentToast(message: string) { 254 + const toast = await toastController.create({ message, duration: 1800, color: "success" }); 255 + await toast.present(); 191 256 } 192 257 </script> 193 258
+55 -5
apps/twisted/src/features/repo/RepoFiles.vue
··· 6 6 {{ selectedFile ? "Files" : "Up" }} 7 7 </ion-button> 8 8 <span class="file-path mono">{{ selectedFile ? selectedFile.path : currentPath }}</span> 9 + <ion-button 10 + v-if="selectedFile && blobQuery.data.value" 11 + fill="clear" 12 + size="small" 13 + class="save-btn" 14 + @click="toggleFileSave"> 15 + {{ fileSaved ? "Saved" : "Save" }} 16 + </ion-button> 9 17 </div> 10 18 11 19 <template v-if="selectedFile"> ··· 65 73 66 74 <script setup lang="ts"> 67 75 import { ref, computed, watch, onBeforeUnmount } from "vue"; 68 - import { IonList, IonButton, IonIcon } from "@ionic/vue"; 76 + import { IonList, IonButton, IonIcon, toastController } from "@ionic/vue"; 69 77 import { folderOpenOutline, alertCircleOutline, arrowBackOutline, documentOutline } from "ionicons/icons"; 70 78 import FileTreeItem from "@/components/repo/FileTreeItem.vue"; 71 79 import EmptyState from "@/components/common/EmptyState.vue"; 72 80 import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 73 - import { useRepoBlob, useRepoTree } from "@/services/tangled/queries.js"; 74 - import type { RepoFile } from "@/domain/models/repo.js"; 75 - import { highlightCode } from "@/lib/syntax.js"; 76 - import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.js"; 81 + import { 82 + buildFileBookmarkId, 83 + createSavedFileInput, 84 + hasBookmark, 85 + removeBookmark, 86 + saveFileBookmark, 87 + } from "@/core/bookmarks/service.ts"; 88 + import { useRepoBlob, useRepoTree } from "@/services/tangled/queries.ts"; 89 + import type { RepoFile } from "@/domain/models/repo.ts"; 90 + import { highlightCode } from "@/lib/syntax.ts"; 91 + import { createObjectUrlFromBlobContent } from "@/services/tangled/repo-assets.ts"; 77 92 78 93 const props = defineProps<{ owner: string; repo: string; branch: string }>(); 79 94 ··· 102 117 103 118 const filePath = computed(() => selectedFile.value?.path ?? ""); 104 119 const isFileSelected = computed(() => !!selectedFile.value && selectedFile.value.type === "file"); 120 + const fileBookmarkId = computed(() => { 121 + if (!selectedFile.value || !props.branch) return ""; 122 + return buildFileBookmarkId(props.owner, props.repo, props.branch, selectedFile.value.path); 123 + }); 124 + const fileSaved = computed(() => !!fileBookmarkId.value && hasBookmark(fileBookmarkId.value)); 105 125 106 126 const blobQuery = useRepoBlob( 107 127 computed(() => props.owner), ··· 173 193 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 174 194 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 175 195 } 196 + 197 + async function toggleFileSave() { 198 + if (!selectedFile.value || !blobQuery.data.value || !props.branch || !fileBookmarkId.value) return; 199 + if (fileSaved.value) { 200 + await removeBookmark(fileBookmarkId.value); 201 + await presentToast("File removed."); 202 + return; 203 + } 204 + await saveFileBookmark( 205 + createSavedFileInput( 206 + props.owner, 207 + props.repo, 208 + props.branch, 209 + selectedFile.value.path, 210 + blobQuery.data.value, 211 + "file", 212 + ), 213 + ); 214 + await presentToast("File saved."); 215 + } 216 + 217 + async function presentToast(message: string) { 218 + const toast = await toastController.create({ message, duration: 1800, color: "success" }); 219 + await toast.present(); 220 + } 176 221 </script> 177 222 178 223 <style scoped> ··· 196 241 .back-btn { 197 242 --color: var(--t-accent); 198 243 flex-shrink: 0; 244 + } 245 + 246 + .save-btn { 247 + --color: var(--t-accent); 248 + margin: 0 0 0 auto; 199 249 } 200 250 201 251 .file-path {
+38 -7
apps/twisted/src/features/repo/RepoOverview.vue
··· 42 42 43 43 <!-- README --> 44 44 <div class="section"> 45 - <h3 class="section-label">README</h3> 45 + <div class="section-head"> 46 + <h3 class="section-label">README</h3> 47 + <ion-button 48 + v-if="readmePath && repo.readme" 49 + fill="clear" 50 + size="small" 51 + class="save-button" 52 + @click="$emit('toggleReadmeSave')"> 53 + {{ isReadmeSaved ? "Saved" : "Save" }} 54 + </ion-button> 55 + </div> 46 56 <MarkdownRenderer v-if="repo.readme" :content="repo.readme" :repo-context="markdownContext" /> 47 57 <EmptyState v-else :icon="documentOutline" title="No README" message="This repo doesn't have a README yet." /> 48 58 </div> ··· 63 73 64 74 <script setup lang="ts"> 65 75 import { computed } from "vue"; 66 - import { IonIcon, IonChip } from "@ionic/vue"; 76 + import { IonButton, IonIcon, IonChip } from "@ionic/vue"; 67 77 import { starOutline, gitBranchOutline, codeOutline, documentOutline } from "ionicons/icons"; 68 78 import MarkdownRenderer from "@/components/repo/MarkdownRenderer.vue"; 69 79 import EmptyState from "@/components/common/EmptyState.vue"; 70 - import type { RepoDetail } from "@/domain/models/repo.js"; 71 - import type { CommitEntry } from "@/services/tangled/queries.js"; 72 - import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 80 + import type { RepoDetail } from "@/domain/models/repo.ts"; 81 + import type { CommitEntry } from "@/services/tangled/queries.ts"; 82 + import type { RepoAssetContext } from "@/services/tangled/repo-assets.ts"; 73 83 74 - const props = defineProps<{ repo: RepoDetail; commits?: CommitEntry[]; markdownContext?: RepoAssetContext }>(); 84 + defineEmits<{ toggleReadmeSave: [] }>(); 85 + 86 + const props = defineProps<{ 87 + repo: RepoDetail; 88 + commits?: CommitEntry[]; 89 + markdownContext?: RepoAssetContext; 90 + readmePath?: string; 91 + isReadmeSaved?: boolean; 92 + }>(); 75 93 76 94 function relativeTime(iso: string): string { 77 95 const d = new Date(iso); ··· 180 198 margin-top: 20px; 181 199 } 182 200 201 + .section-head { 202 + display: flex; 203 + align-items: center; 204 + justify-content: space-between; 205 + gap: 12px; 206 + margin: 0 16px 10px; 207 + } 208 + 183 209 .section-label { 184 210 font-size: 11px; 185 211 font-weight: 600; 186 212 text-transform: uppercase; 187 213 letter-spacing: 0.07em; 188 214 color: var(--t-text-muted); 189 - margin: 0 16px 10px; 215 + margin: 0; 216 + } 217 + 218 + .save-button { 219 + --color: var(--t-accent); 220 + margin: 0; 190 221 } 191 222 192 223 .lang-list {
+14 -7
apps/twisted/src/main.ts
··· 1 1 import { createApp } from "vue"; 2 2 import App from "./App.vue"; 3 - import router from "@/app/router/index.js"; 3 + import router from "@/app/router/index.ts"; 4 4 5 5 import { IonicVue } from "@ionic/vue"; 6 6 import { createPinia } from "pinia"; 7 7 import { VueQueryPlugin } from "@tanstack/vue-query"; 8 - import { queryClient } from "./core/query/client.js"; 8 + import { queryClient } from "./core/query/client.ts"; 9 9 import { persistQueryClient } from "@tanstack/query-persist-client-core"; 10 - import { createIdbPersister } from "./core/query/persister.js"; 11 - import { initializeThemePreference } from "./core/theme/preferences.js"; 12 - import { useAuthStore } from "./core/auth/store.js"; 10 + import { createIdbPersister } from "./core/query/persister.ts"; 11 + import { initializeThemePreference } from "./core/theme/preferences.ts"; 12 + import { useAuthStore } from "./core/auth/store.ts"; 13 + import { ensureBookmarksLoaded } from "./core/bookmarks/service.ts"; 14 + import { getIsDevAuthEnabled } from "./core/auth/dev-access.ts"; 13 15 14 16 import "@ionic/vue/css/core.css"; 15 17 import "@ionic/vue/css/normalize.css"; ··· 36 38 import "./theme/variables.css"; 37 39 38 40 initializeThemePreference(); 41 + void ensureBookmarksLoaded(); 39 42 40 43 if (import.meta.env.DEV) { 41 44 void createIdbPersister().removeClient(); ··· 47 50 const app = createApp(App).use(IonicVue).use(router).use(pinia).use(VueQueryPlugin, { queryClient }); 48 51 49 52 const authStore = useAuthStore(pinia); 50 - authStore.initialize(); 53 + if (getIsDevAuthEnabled()) { 54 + authStore.initialize(); 55 + } 51 56 52 57 router.isReady().then(async () => { 53 - await authStore.restoreSession(); 58 + if (getIsDevAuthEnabled()) { 59 + await authStore.restoreSession(); 60 + } 54 61 app.mount("#app"); 55 62 });
+23 -4
apps/twisted/src/views/TabsPage.vue
··· 15 15 <ion-icon :icon="pulseOutline" /> 16 16 <ion-label>Activity</ion-label> 17 17 </ion-tab-button> 18 - <ion-tab-button tab="profile" href="/tabs/profile"> 19 - <ion-icon :icon="personOutline" /> 20 - <ion-label>Profile</ion-label> 18 + <ion-tab-button :tab="profileTab.tab" :href="profileTab.href"> 19 + <ion-icon :icon="profileTab.icon" /> 20 + <ion-label>{{ profileTab.label }}</ion-label> 21 21 </ion-tab-button> 22 22 <ion-tab-button tab="settings" href="/tabs/settings"> 23 23 <ion-icon :icon="settingsOutline" /> ··· 30 30 31 31 <script setup lang="ts"> 32 32 import { IonPage, IonTabs, IonRouterOutlet, IonTabBar, IonTabButton, IonIcon, IonLabel } from "@ionic/vue"; 33 - import { homeOutline, searchOutline, pulseOutline, personOutline, settingsOutline } from "ionicons/icons"; 33 + import { 34 + homeOutline, 35 + searchOutline, 36 + pulseOutline, 37 + personOutline, 38 + settingsOutline, 39 + bookmarkOutline, 40 + } from "ionicons/icons"; 41 + import { computed } from "vue"; 42 + import { useDevAuthFeatures } from "@/core/auth/dev-access.ts"; 43 + 44 + const { isDevAuthEnabled } = useDevAuthFeatures(); 45 + 46 + const profileTab = computed(() => { 47 + if (isDevAuthEnabled.value) { 48 + return { tab: "profile", href: "/tabs/profile", label: "Profile", icon: personOutline }; 49 + } 50 + 51 + return { tab: "bookmarks", href: "/tabs/bookmarks", label: "Bookmarks", icon: bookmarkOutline }; 52 + }); 34 53 </script>
+2
apps/twisted/src/vite-env.d.ts
··· 4 4 5 5 interface ImportMetaEnv { 6 6 readonly VITE_TWISTER_API_BASE_URL?: string; 7 + readonly VITE_OAUTH_CLIENT_ID?: string; 8 + readonly VITE_OAUTH_REDIRECT_URI?: string; 7 9 } 8 10 9 11 interface ImportMeta {
+25
apps/twisted/tests/unit/app-config.spec.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { createAppConfig, normalizeConfiguredUrl } from "@/core/config/app.ts"; 3 + 4 + describe("app config", () => { 5 + it("enables auth in dev", () => { 6 + const config = createAppConfig({ DEV: true, VITE_TWISTER_API_BASE_URL: "http://127.0.0.1:8080/" }); 7 + 8 + expect(config.isDevBuild).toBe(true); 9 + expect(config.isProductionReadOnly).toBe(false); 10 + expect(config.twisterApiBaseUrl).toBe("http://127.0.0.1:8080"); 11 + }); 12 + 13 + it("disables auth in production", () => { 14 + const config = createAppConfig({ DEV: false, VITE_TWISTER_API_BASE_URL: "https://twister.stormlightlabs.org/" }); 15 + 16 + expect(config.isDevBuild).toBe(false); 17 + expect(config.isProductionReadOnly).toBe(true); 18 + expect(config.twisterApiBaseUrl).toBe("https://twister.stormlightlabs.org"); 19 + }); 20 + 21 + it("normalizes configured urls", () => { 22 + expect(normalizeConfiguredUrl("https://example.com///")).toBe("https://example.com"); 23 + expect(normalizeConfiguredUrl("")).toBeUndefined(); 24 + }); 25 + });
+103
apps/twisted/tests/unit/bookmark-service.spec.ts
··· 1 + import { describe, expect, it, beforeEach } from "vitest"; 2 + import type { BookmarkItem } from "@/domain/models/bookmark.ts"; 3 + import { 4 + buildFileBookmarkId, 5 + buildRepoBookmarkId, 6 + buildStringBookmarkId, 7 + createSavedFileInput, 8 + getBookmark, 9 + hasBookmark, 10 + listBookmarks, 11 + removeBookmark, 12 + resetBookmarkStateForTests, 13 + saveFileBookmark, 14 + saveRepoBookmark, 15 + saveStringBookmark, 16 + } from "@/core/bookmarks/service.ts"; 17 + 18 + function memoryStorage(seed: BookmarkItem[] = []) { 19 + let items = [...seed]; 20 + return { 21 + async load() { 22 + return [...items]; 23 + }, 24 + async save(next: BookmarkItem[]) { 25 + items = [...next]; 26 + }, 27 + }; 28 + } 29 + 30 + describe("bookmark service", () => { 31 + beforeEach(() => { 32 + resetBookmarkStateForTests(memoryStorage()); 33 + }); 34 + 35 + it("saves, lists, and removes mixed bookmark kinds", async () => { 36 + await saveRepoBookmark({ 37 + atUri: "at://did:plc:test/sh.tangled.repo/demo", 38 + rkey: "demo", 39 + ownerDid: "did:plc:test", 40 + ownerHandle: "alice.test", 41 + name: "demo", 42 + description: "demo repo", 43 + knot: "knot.test", 44 + }); 45 + await saveStringBookmark("alice.test", { 46 + atUri: "at://did:plc:test/sh.tangled.string/snippet", 47 + rkey: "snippet", 48 + filename: "main.ts", 49 + contents: "console.log('hi')", 50 + createdAt: "2026-03-25T10:00:00Z", 51 + }); 52 + await saveFileBookmark( 53 + createSavedFileInput( 54 + "alice.test", 55 + "demo", 56 + "main", 57 + "README.md", 58 + { path: "README.md", content: "# Demo", encoding: "utf-8", isBinary: false }, 59 + "readme", 60 + ), 61 + ); 62 + 63 + expect((await listBookmarks()).map((item) => item.kind).sort()).toEqual(["file", "repo", "string"]); 64 + expect(await listBookmarks("repo")).toHaveLength(1); 65 + expect(await listBookmarks("string")).toHaveLength(1); 66 + expect(await listBookmarks("file")).toHaveLength(1); 67 + 68 + const repoId = buildRepoBookmarkId("at://did:plc:test/sh.tangled.repo/demo"); 69 + await removeBookmark(repoId); 70 + 71 + expect(await getBookmark(repoId)).toBeUndefined(); 72 + expect(await listBookmarks("repo")).toHaveLength(0); 73 + }); 74 + 75 + it("overwrites existing bookmarks and keeps reactive lookup current", async () => { 76 + const stringId = buildStringBookmarkId("at://did:plc:test/sh.tangled.string/snippet"); 77 + 78 + await saveStringBookmark("alice.test", { 79 + atUri: "at://did:plc:test/sh.tangled.string/snippet", 80 + rkey: "snippet", 81 + filename: "main.ts", 82 + contents: "one", 83 + createdAt: "2026-03-25T10:00:00Z", 84 + }); 85 + await saveStringBookmark("alice.test", { 86 + atUri: "at://did:plc:test/sh.tangled.string/snippet", 87 + rkey: "snippet", 88 + filename: "main.ts", 89 + contents: "two", 90 + createdAt: "2026-03-25T10:00:00Z", 91 + }); 92 + 93 + expect(hasBookmark(stringId)).toBe(true); 94 + expect((await getBookmark(stringId))?.kind).toBe("string"); 95 + expect((await getBookmark(stringId))?.kind === "string" && (await getBookmark(stringId))?.contents).toBe("two"); 96 + }); 97 + 98 + it("builds stable file bookmark ids", () => { 99 + expect(buildFileBookmarkId("alice.test", "demo", "main", "docs/README.md")).toBe( 100 + "file:alice.test/demo/main/docs/README.md", 101 + ); 102 + }); 103 + });
-10
apps/twisted/tests/unit/example.spec.ts
··· 1 - import { mount } from '@vue/test-utils' 2 - import HomePage from '@/views/HomePage.vue' 3 - import { describe, expect, test } from 'vitest' 4 - 5 - describe('HomePage.vue', () => { 6 - test('renders home vue', () => { 7 - const wrapper = mount(HomePage) 8 - expect(wrapper.text()).toMatch('Ready to create an app?') 9 - }) 10 - })
+48
apps/twisted/tests/unit/tabs-page.spec.ts
··· 1 + import { mount } from "@vue/test-utils"; 2 + import { afterEach, describe, expect, it, vi } from "vitest"; 3 + import { ref } from "vue"; 4 + 5 + async function loadTabsPage(isDevAuthEnabled: boolean) { 6 + vi.resetModules(); 7 + vi.doMock("@/core/auth/dev-access.ts", () => ({ 8 + useDevAuthFeatures: () => ({ 9 + canToggleDevAuth: true, 10 + isDevAuthEnabled: ref(isDevAuthEnabled), 11 + setDevAuthEnabled: vi.fn(), 12 + }), 13 + })); 14 + const module = await import("@/views/TabsPage.vue"); 15 + return module.default; 16 + } 17 + 18 + const globalStubs = { 19 + "ion-page": { template: "<div><slot /></div>" }, 20 + "ion-tabs": { template: "<div><slot /></div>" }, 21 + "ion-router-outlet": { template: "<div />" }, 22 + "ion-tab-bar": { template: "<div><slot /></div>" }, 23 + "ion-tab-button": { template: "<button><slot /></button>" }, 24 + "ion-icon": { template: "<i />" }, 25 + "ion-label": { template: "<span><slot /></span>" }, 26 + }; 27 + 28 + describe("TabsPage", () => { 29 + afterEach(() => { 30 + vi.resetModules(); 31 + vi.doUnmock("@/core/auth/dev-access.ts"); 32 + }); 33 + 34 + it("shows Bookmarks in production mode", async () => { 35 + const TabsPage = await loadTabsPage(false); 36 + const wrapper = mount(TabsPage, { global: { stubs: globalStubs } }); 37 + 38 + expect(wrapper.text()).toContain("Bookmarks"); 39 + expect(wrapper.text()).not.toContain("Profile"); 40 + }); 41 + 42 + it("shows Profile in dev mode", async () => { 43 + const TabsPage = await loadTabsPage(true); 44 + const wrapper = mount(TabsPage, { global: { stubs: globalStubs } }); 45 + 46 + expect(wrapper.text()).toContain("Profile"); 47 + }); 48 + });
+28 -6
docs/reference/app.md
··· 1 1 --- 2 2 title: Mobile App Reference 3 - updated: 2026-03-24 3 + updated: 2026-03-25 4 4 --- 5 5 6 6 Twisted is an Ionic Vue mobile app for browsing Tangled, a git hosting platform built on the AT Protocol. It targets iOS and Android via Capacitor (no web target). ··· 20 20 21 21 Three-layer design: 22 22 23 - **Presentation** — Vue components and pages using Ionic's component library. Five-tab navigation: Home, Explore, Activity, Profile (visible tabs) plus Repo (pushed route). Repo detail uses segmented tabs: Overview, Files, Issues, PRs. 23 + **Presentation** — Vue components and pages using Ionic's component library. Five-tab navigation: Home, Explore, Activity, Bookmarks/Profile, Settings. Repo detail uses segmented tabs: Overview, Files, Issues, PRs. 24 24 25 25 **Domain** — TypeScript types modeling the app's data: UserSummary, RepoSummary, RepoDetail, RepoFile, PullRequestSummary, IssueSummary, ActivityItem. These are app-internal representations, decoupled from API response shapes. 26 26 ··· 49 49 - **Twister API** — Search and index-backed summaries (when available). 50 50 - **Constellation** — Social signal counts and backlinks (stars, followers, reactions). 51 51 52 - Knots serve XRPC endpoints for git operations. The appview at `tangled.org` returns HTML only (no JSON API), so the app goes directly to knots for git data and PDS for AT Protocol records. 52 + The app calls the Twister API for app data. Twister proxies knot and PDS reads, 53 + handle resolution, Constellation counts, and the Jetstream activity stream. 53 54 54 55 ## Completed Features 55 56 56 57 ### Navigation & Shell (Phase 1) 57 58 58 - Five-tab layout with Vue Router, skeleton loaders, placeholder pages. Design system components: RepoCard, UserCard, ActivityCard, FileTreeItem, EmptyState, ErrorBoundary, SkeletonLoader, MarkdownRenderer. 59 + Five-tab layout with Vue Router, skeleton loaders, placeholder pages, and a 60 + local Bookmarks area for repos, strings, and saved files. 59 61 60 62 ### Public Browsing (Phase 2) 61 63 62 - All read-only browsing works without authentication: 64 + All release-mode browsing works without authentication: 63 65 64 66 **Repository browsing** — Metadata display, README rendering (markdown), file tree navigation, file viewer with syntax context, commit log with pagination, branch listing. 65 67 ··· 69 71 70 72 **Pull Requests** — List view with status filter (open/closed/merged), detail view with comments. 71 73 72 - **Caching** — TanStack Query configured with per-data-type stale times. Persistence via Dexie (IndexedDB) — works in Capacitor's WebView on device and in the browser during local dev. 74 + **Caching** — TanStack Query configured with per-data-type stale times. 75 + Persistence currently uses IndexedDB via `idb-keyval`, with a separate local 76 + bookmarks store for saved repos, strings, files, and READMEs. 77 + 78 + ## Storage Migration 79 + 80 + IndexedDB is the current implementation for cache and bookmark persistence, but 81 + it should be treated as an interim step. 82 + 83 + - Local cache and offline-content storage should migrate toward SQLite so larger 84 + saved payloads, better inspection, and more explicit schema management are 85 + available on-device. 86 + - Sensitive auth/session material should migrate toward Ionic secure storage 87 + instead of living in browser-style storage primitives. 88 + - Until that migration lands, IndexedDB should stay limited to non-sensitive 89 + cached data and user-saved offline reading content. 90 + 91 + ## Auth Split 92 + 93 + Production builds are read-only and hide auth entry points. Dev builds keep the 94 + current OAuth flow available for testing future authenticated features. 73 95 74 96 ## Routing 75 97
+4 -3
docs/roadmap.md
··· 102 102 103 103 **Depends on:** App: Search & Discovery (for cache persistence of search/feed data) 104 104 105 - - [ ] Dexie setup with database schema (query cache + pinned content tables) 106 - - [ ] TanStack Query persister backed by Dexie 105 + - [x] IndexedDB-backed local cache and bookmark storage for first release 106 + - [ ] Migrate offline cache and saved-content storage from IndexedDB to SQLite 107 + - [ ] TanStack Query persister backed by SQLite or equivalent app-managed store 107 108 - [ ] Pinned content store (save/unsave files for offline reading) 108 109 - [ ] Pinned files UI (list, pin/unpin actions on file viewer, last-fetched timestamp) 109 110 - [ ] Offline detection and banner 110 - - [ ] Secure token storage (Capacitor Secure Storage) 111 + - [ ] Migrate auth/session data to Ionic secure storage 111 112 - [ ] Cache eviction (per-type limits and TTL, pinned content exempt) 112 113 - [ ] List virtualization for large datasets 113 114 - [ ] Lazy-load avatars, prefetch on hover