offline-first, p2p synced, atproto enabled, feed reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

cleanups and basic styles

+179 -131
+1 -1
src/app/components/breadcrumbs.tsx
··· 50 50 <Show when={index() > 0} children={<li aria-hidden="true">/</li>} /> 51 51 <li> 52 52 <Show when={crumb.href} fallback={<span>{crumb.label}</span>}> 53 - <A href={crumb.href!}>{crumb.label}</A> 53 + {(href) => <A href={href()}>{crumb.label}</A>} 54 54 </Show> 55 55 </li> 56 56 </>
+4 -3
src/app/components/feed-row.tsx
··· 9 9 const feedline = useFeedlineDatabase() 10 10 const feedurl = createMemo(() => encodeURIComponent(props.feed.url)) 11 11 12 - const entries = makeSignalQuery( 13 - async () => await feedline.entries.where('feedurl').equals(props.feed.url).count(), 14 - ) 12 + // eslint-disable-next-line solid/reactivity 13 + const entries = makeSignalQuery(async () => { 14 + return await feedline.entries.where('feedurl').equals(props.feed.url).count() 15 + }) 15 16 16 17 return ( 17 18 <li>
+43 -6
src/app/components/logo.tsx
··· 1 - import { ComponentProps, VoidProps } from "solid-js"; 1 + import {ComponentProps} from 'solid-js' 2 2 3 3 export function Logo(props: ComponentProps<'svg'>) { 4 4 return ( ··· 6 6 <title>feedline.at</title> 7 7 <g id="logomark"> 8 8 <g transform="rotate(-180, 400, 300)"> 9 - <path id="outer-beam" stroke-width="40" stroke="currentColor" fill="none" d="m242.71695,140.94978c246.88738,5.659 306.05873,87.00719 304.01834,326.10013" /> 10 - <path id="middle-beam" stroke-width="40" stroke="currentColor" fill="none" d="m243.60313,228.8047c176.30383,4.11259 218.55847,63.23105 217.10141,236.98793" /> 11 - <path id="inner-beam" stroke-width="50" stroke="currentColor" fill="none" d="m243.60313,323.17414c109.42636,2.438 135.65251,37.48428 134.74816,140.48986" /> 9 + <path 10 + id="outer-beam" 11 + stroke-width="40" 12 + stroke="currentColor" 13 + fill="none" 14 + d="m242.71695,140.94978c246.88738,5.659 306.05873,87.00719 304.01834,326.10013" 15 + /> 16 + <path 17 + id="middle-beam" 18 + stroke-width="40" 19 + stroke="currentColor" 20 + fill="none" 21 + d="m243.60313,228.8047c176.30383,4.11259 218.55847,63.23105 217.10141,236.98793" 22 + /> 23 + <path 24 + id="inner-beam" 25 + stroke-width="50" 26 + stroke="currentColor" 27 + fill="none" 28 + d="m243.60313,323.17414c109.42636,2.438 135.65251,37.48428 134.74816,140.48986" 29 + /> 12 30 </g> 13 31 14 - <ellipse fill="currentColor" stroke="currentColor" stroke-width="65" cx="156.71432" cy="415.54991" rx="30" ry="30" id="circle"/> 32 + <ellipse 33 + fill="currentColor" 34 + stroke="currentColor" 35 + stroke-width="65" 36 + cx="156.71432" 37 + cy="415.54991" 38 + rx="30" 39 + ry="30" 40 + id="circle" 41 + /> 15 42 </g> 16 - <rect rx="20" stroke-width="20" id="border" height="480" width="568" y="80" x="46" stroke="currentColor" fill="none"/> 43 + <rect 44 + rx="20" 45 + stroke-width="20" 46 + id="border" 47 + height="480" 48 + width="568" 49 + y="80" 50 + x="46" 51 + stroke="currentColor" 52 + fill="none" 53 + /> 17 54 </svg> 18 55 ) 19 56 }
+3 -3
src/app/components/navigation-page.tsx
··· 1 - import {Accessor, JSX, ParentProps, mergeProps} from 'solid-js' 1 + import {JSX, ParentProps, mergeProps} from 'solid-js' 2 2 3 3 import styles from './navigation-page.module.css' 4 4 5 5 export type NavigationPageProps = ParentProps & JSX.HTMLAttributes<HTMLElement> 6 6 7 7 export function NavigationPage(_props: NavigationPageProps) { 8 - const props = mergeProps(_props, {class: styles['nav-page'] + ' ' + _props.class}) 9 - return <article {...props} /> 8 + const classname = () => styles['nav-page'] + ' ' + (_props.class ?? '') 9 + return <article {...mergeProps(_props, {class: classname()})} /> 10 10 }
+11 -14
src/app/components/virtual-list.tsx
··· 10 10 } 11 11 12 12 export function createVirtualList<T>(props: VirtualListProps<T>) { 13 - const overscan = props.overscan ?? 2 14 - 15 13 // calculate how much of the scroll belongs to the list (after header has consumed it's part) 16 14 17 - const offsetY = createMemo(() => (props.offsetY && props.offsetY()) || 0) 18 - const listScrollY = createMemo(() => Math.max(0, props.scrollY() - offsetY())) 15 + const overscan = createMemo(() => props.overscan ?? 2) 16 + const listScrollY = createMemo(() => 17 + Math.max(0, props.scrollY() - ((props.offsetY && props.offsetY()) || 0)), 18 + ) 19 19 20 20 // based on the scroll and heights, where should we slice? 21 - 22 - const firstIndex = createMemo(() => Math.max(0, Math.floor(listScrollY() / props.rowHeight) - overscan)) 23 - 21 + const firstIndex = createMemo(() => Math.max(0, Math.floor(listScrollY() / props.rowHeight) - overscan())) 24 22 const lastIndex = createMemo(() => { 25 23 const count = Math.ceil(props.viewportHeight() / props.rowHeight) 26 - return Math.min(props.items.length, Math.floor(listScrollY() / props.rowHeight) + count + overscan) 24 + return Math.min(props.items.length, Math.floor(listScrollY() / props.rowHeight) + count + overscan()) 27 25 }) 28 26 29 - return { 30 - items: createMemo(() => props.items.slice(firstIndex(), lastIndex())), 31 - firstIndex: firstIndex, 27 + // the slice everything up 28 + const items = createMemo(() => props.items.slice(firstIndex(), lastIndex())) 29 + const offsetY = createMemo(() => firstIndex() * props.rowHeight) 30 + const listHeight = createMemo(() => offsetY() + props.items.length * props.rowHeight) 32 31 33 - offsetY: createMemo(() => firstIndex() * props.rowHeight), 34 - listHeight: createMemo(() => offsetY() + props.items.length * props.rowHeight), 35 - } 32 + return {items, firstIndex, offsetY, listHeight} 36 33 }
+1 -3
src/app/index.tsx
··· 1 1 import {Route, Router} from '@solidjs/router' 2 - import {Match, Show, Switch} from 'solid-js' 2 + import {Match, Switch} from 'solid-js' 3 3 import {render} from 'solid-js/web' 4 4 5 5 import {FeedlineContextProvider} from './context/feedline' ··· 32 32 33 33 function Homepage() { 34 34 const bp = useBreakpoints() 35 - 36 - return <Dashboard /> 37 35 38 36 // on mobile, the sidenav _is_ the homepage 39 37 // on desktop, dashboard is the homepage, because sidenav is, well, sidenav
+3 -2
src/app/layout/drawer/context.tsx
··· 6 6 view: unknown, 7 7 state: DrawerState, 8 8 ) => undefined | boolean | Promise<undefined | boolean> 9 + 9 10 export type DrawerBeforeClose = () => undefined | boolean | Promise<undefined | boolean> 10 11 11 12 export type DrawerContextValue = { ··· 28 29 setState, 29 30 view, 30 31 setView, 31 - open: async (view: unknown, state?: DrawerState) => { 32 + open: (view: unknown, state?: DrawerState) => { 32 33 setView(view) 33 34 setState(state ?? 'midi') 34 35 }, 35 - close: async () => { 36 + close: () => { 36 37 setState('hidden') 37 38 }, 38 39 } satisfies DrawerContextValue
-2
src/app/layout/drawer/index.module.css
··· 13 13 padding-left: 0; 14 14 } 15 15 16 - background: #007; 17 - 18 16 user-select: none; 19 17 touch-action: none; 20 18
+2 -11
src/app/layout/drawer/index.tsx
··· 1 1 import {useBeforeLeave} from '@solidjs/router' 2 2 import {DragGesture} from '@use-gesture/vanilla' 3 3 import {AnimationPlaybackControls, animate} from 'motion' 4 - import { 5 - Accessor, 6 - ParentProps, 7 - Show, 8 - createContext, 9 - createEffect, 10 - createSignal, 11 - on, 12 - onCleanup, 13 - useContext, 14 - } from 'solid-js' 4 + import {ParentProps, Show, createEffect, on, onCleanup} from 'solid-js' 15 5 import {onMount} from 'solid-js' 16 6 import {Portal} from 'solid-js/web' 17 7 ··· 102 92 103 93 createEffect( 104 94 on(drawer.state, (newstate, prevstate) => { 95 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 105 96 if (!drawerref) return 106 97 107 98 // on initial, make sure we get a defined height
+3 -13
src/app/layout/index.module.css
··· 35 35 36 36 > header { 37 37 grid-area: header; 38 - background: #700; 39 - 40 38 z-index: 1; 41 39 42 40 display: flex; ··· 58 56 59 57 > main { 60 58 grid-area: content; 61 - background: #070; 62 - 63 59 z-index: 0; 64 60 65 61 position: relative; ··· 84 80 padding: 0; 85 81 } 86 82 87 - &.slide-back { 88 - background: #030; 89 - } 90 - &.slide-forward { 91 - background: #090; 92 - } 83 + &.slide-back {} 84 + &.slide-forward {} 93 85 } 94 86 } 95 87 ··· 101 93 @media (--tablet) { 102 94 & { 103 95 grid-area: sidebar; 104 - background: #770; 96 + z-index: 0; 105 97 106 98 padding-left: var(--safe-left); 107 99 overflow-y: auto; ··· 113 105 114 106 > footer { 115 107 grid-area: drawer; 116 - background: #777; 117 - 118 108 z-index: 3; 119 109 120 110 padding-left: var(--safe-left);
+2 -2
src/app/layout/index.tsx
··· 1 1 import {Breadcrumbs} from '#app/components/breadcrumbs' 2 + import {Logo} from '#app/components/logo.jsx' 2 3 import {useBreakpoints} from '#app/primitives/breakpoints' 3 - import {ParentProps, Show, Suspense} from 'solid-js' 4 + import {ParentProps, Show} from 'solid-js' 4 5 5 6 import {Drawer} from './drawer' 6 7 import {DrawerContextProvider} from './drawer/context' 7 8 import styles from './index.module.css' 8 9 import {MainTransition} from './main/transition' 9 10 import SideNav from './sidenav' 10 - import { Logo } from '#app/components/logo.jsx' 11 11 12 12 export default function Layout(props: ParentProps) { 13 13 const bp = useBreakpoints()
+4 -3
src/app/layout/sidenav/index.tsx
··· 58 58 const feedline = useFeedlineDatabase() 59 59 const feedurl = createMemo(() => encodeURIComponent(props.feed.url)) 60 60 61 - const entries = makeSignalQuery( 62 - async () => await feedline.entries.where('feedurl').equals(props.feed.url).count(), 63 - ) 61 + // eslint-disable-next-line solid/reactivity 62 + const entries = makeSignalQuery(async () => { 63 + return await feedline.entries.where('feedurl').equals(props.feed.url).count() 64 + }) 64 65 65 66 return ( 66 67 <li>
+6 -6
src/app/pages/dashboard.tsx
··· 1 - import {NavigationPage} from '#app/components/navigation-page.jsx' 2 - import {FeedRow} from '../components/feed-row' 1 + import {FeedRow} from '#app/components/feed-row' 2 + import {NavigationPage} from '#app/components/navigation-page' 3 + import {useRealmIdentity} from '#app/context/realm-identity' 4 + import {DebugNukeButton} from '#app/debug/nuke-database.button' 5 + import {makeStoreQuery} from '#app/primitives/database' 3 6 import {useFeedlineDatabase, useFeedlineDispatch} from '../context/feedline' 4 - import {useRealmIdentity} from '../context/realm-identity' 5 - import {DebugNukeButton} from '../debug/nuke-database.button' 6 - import {makeStoreQuery} from '../primitives/database' 7 7 import {makeEventListenerStack} from '@solid-primitives/event-listener' 8 8 import {For, createResource, createSignal, onCleanup} from 'solid-js' 9 9 10 - import {RealmPeer} from '#realm/client/peer.js' 10 + import {RealmPeer} from '#realm/client/peer' 11 11 import {IdentID} from '#realm/schema/brands' 12 12 13 13 function PeerLabel(props: {identid: IdentID; peer: RealmPeer}) {
+45 -32
src/app/pages/entry.tsx
··· 4 4 import {useFeedlineDatabase} from '../context/feedline' 5 5 import {RouteSectionProps} from '@solidjs/router' 6 6 import {liveQuery} from 'dexie' 7 - import {For, Show, Suspense, createEffect, createMemo, createSignal, on, onCleanup} from 'solid-js' 7 + import {For, Show, createEffect, createMemo, createSignal, on, onCleanup} from 'solid-js' 8 8 9 9 import {EntryPatchAction} from '#feedline/schema/actions-entry.js' 10 10 import {Entry} from '#feedline/schema/entry.js' ··· 64 64 {(entry) => ( 65 65 <article> 66 66 <header> 67 - {entry().imageUrl && <img src={entry().imageUrl!} alt={entry().imageLabel || ''} width="400" />} 67 + <Show when={entry().imageUrl}> 68 + {(url) => <img src={url()} alt={entry().imageLabel || ''} width="400" />} 69 + </Show> 68 70 <h1>{entry().title}</h1> 69 71 <Show when={date()}>{(date) => <time>{date()}</time>}</Show> 70 - {entry().linkUrl && ( 71 - <a href={entry().linkUrl ?? entry().feedurl} target="_blank" rel="noopener noreferrer"> 72 - View original 73 - </a> 74 - )} 72 + <Show when={entry().linkUrl}> 73 + {(url) => ( 74 + <a href={url()} target="_blank" rel="noopener noreferrer"> 75 + View original 76 + </a> 77 + )} 78 + </Show> 75 79 </header> 76 80 77 81 <button onClick={setTitle}>Change Title</button> 78 82 79 - {entry().enclosure && ( 80 - <section> 81 - <h2>Audio</h2> 82 - <audio controls src={entry().enclosure!.url} preload="metadata"> 83 - Your browser does not support audio playback. 84 - </audio> 85 - {entry().podcast?.duration && ( 86 - <p> 87 - Duration: {Math.floor((entry().podcast?.duration || 0) / 60)}m{' '} 88 - {Math.floor((entry().podcast?.duration || 0) % 60)}s 89 - </p> 90 - )} 91 - </section> 92 - )} 83 + <Show when={entry().enclosure}> 84 + {(enclosure) => ( 85 + <section> 86 + <h2>Audio</h2> 87 + <audio controls src={enclosure().url} preload="metadata"> 88 + Your browser does not support audio playback. 89 + </audio> 90 + <Show when={entry().podcast}> 91 + {(podcast) => ( 92 + <p> 93 + Duration: 94 + {Math.floor((podcast().duration || 0) / 60)}m{' '} 95 + {Math.floor((entry().podcast?.duration || 0) % 60)}s 96 + </p> 97 + )} 98 + </Show> 99 + </section> 100 + )} 101 + </Show> 93 102 94 - {!entry().content && entry().snippet && ( 95 - <section> 96 - <iframe srcdoc={entry().snippet!} /> 97 - </section> 98 - )} 103 + <Show when={!entry().content && entry().snippet}> 104 + {(snippet) => ( 105 + <section> 106 + <iframe srcdoc={snippet()} /> 107 + </section> 108 + )} 109 + </Show> 99 110 100 - {entry().content && ( 101 - <section> 102 - <h2>Content</h2> 103 - <iframe srcdoc={entry().content!} /> 104 - </section> 105 - )} 111 + <Show when={entry().content}> 112 + {(content) => ( 113 + <section> 114 + <h2>Content</h2> 115 + <iframe srcdoc={content()} /> 116 + </section> 117 + )} 118 + </Show> 106 119 107 120 {entry().links.length > 0 && ( 108 121 <section>
-1
src/app/pages/feed/.#index.tsx
··· 1 - jonathan@buzz.492312:1764524568
-3
src/app/pages/feed/index.module.css
··· 9 9 top: 0; 10 10 z-index: 1; 11 11 12 - background: #779; 13 12 max-height: 200px; 14 13 min-height: 80px; 15 14 /* height driven by js animation */ ··· 23 22 position: relative; 24 23 25 24 .entry-list-empty-item { 26 - color: #333; 27 25 } 28 26 29 27 .entry-list-item { 30 - background-color: pink; 31 28 } 32 29 } 33 30 }
+7 -6
src/app/pages/feed/index.tsx
··· 6 6 import { 7 7 Accessor, 8 8 For, 9 - JSX, 10 9 Show, 11 10 Suspense, 12 11 createMemo, ··· 105 104 return ( 106 105 <A href={`/feeds/${feedurl()}/${guid()}`}> 107 106 <h3>{props.entry.title || props.entry.guid}</h3> 108 - 109 - {date && <time>{date()}</time>} 107 + <time>{date()}</time> 110 108 </A> 111 109 ) 112 110 } 113 111 114 112 function FeedPageDetail(props: {feed: Feed}) { 115 113 const database = useFeedlineDatabase() 114 + 115 + // eslint-disable-next-line solid/reactivity 116 116 const entries = makeStoreQuery(async () => { 117 117 const u = props.feed.url 118 118 return u ? await database.entries.where('feedurl').equals(u).reverse().sortBy('publishedAt') : [] ··· 163 163 const virtualHeightPx = () => `${viewportHeight()}px` 164 164 const virtualStyles = createMemo(() => ({ 165 165 top: offsetYPx(), 166 - height: virtualHeightPx(), 166 + 'max-height': virtualHeightPx(), 167 167 })) 168 168 169 169 return ( ··· 172 172 class={styles['entry-list-scroller']} 173 173 on:scroll={{ 174 174 handleEvent: (e) => setScrollY(e.currentTarget.scrollTop), 175 - passive: true 176 - }}> 175 + passive: true, 176 + }} 177 + > 177 178 <header class={styles['entry-list-header']} style={headerStyles()}> 178 179 <FeedPageHeader feed={props.feed} /> 179 180 </header>
+4 -4
src/app/primitives/breakpoints.ts
··· 1 1 import {createEffect, createSignal, onCleanup, onMount} from 'solid-js' 2 2 import z from 'zod/v4' 3 3 4 - const breakpointSchema = z.enum(['init', 'phone', 'tablet', 'desktop']) 4 + const breakpointSchema = z.enum(['init', 'bp:phone', 'bp:tablet', 'bp:desktop']) 5 5 export type Breakpoint = z.infer<typeof breakpointSchema> 6 6 7 7 function breakpointLabel(): Breakpoint { ··· 37 37 38 38 return { 39 39 breakpoint, 40 - isPhone: () => breakpoint() === 'phone', 41 - isTablet: () => breakpoint() === 'tablet', 42 - isDesktop: () => breakpoint() === 'desktop', 40 + isPhone: () => breakpoint() === 'bp:phone', 41 + isTablet: () => breakpoint() === 'bp:tablet', 42 + isDesktop: () => breakpoint() === 'bp:desktop', 43 43 } 44 44 }
+1 -1
src/app/primitives/database.ts
··· 1 1 // pulled mainly from 2 2 // https://github.com/faassen/solid-dexie/blob/main/src/solid-dexie.ts 3 3 import {liveQuery} from 'dexie' 4 - import {Accessor, createEffect, createMemo, createSignal, from, on, onCleanup} from 'solid-js' 4 + import {Accessor, createEffect, createSignal, on, onCleanup} from 'solid-js' 5 5 import {type ReconcileOptions, createStore, reconcile} from 'solid-js/store' 6 6 7 7 type NotArray<T> = T extends unknown[] ? never : T
+1 -1
src/app/primitives/dataset.ts
··· 10 10 const set = (value: T | null) => { 11 11 console.log('setting dataset:', value) 12 12 if (value === null) { 13 - // eslint-disable-next-lint @typescript-eslint/no-dynamic-delete 13 + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 14 14 delete target.dataset[key] 15 15 } else { 16 16 target.dataset[key] = value
-2
src/app/styles/reset.css
··· 8 8 9 9 @layer reset { 10 10 html { 11 - color-scheme: light dark; 12 11 font: 13 12 clamp(1rem, 1rem + 0.5vw, 2rem) / 1.4 system-ui, 14 13 sans-serif; ··· 56 55 } 57 56 58 57 a { 59 - color: oklch(0.68 0.17 228); 60 58 text-underline-offset: 2px; 61 59 &:not(:is(:hover, :focus)) { 62 60 text-decoration-color: color-mix(in srgb, currentColor, transparent 50%);
+35 -8
src/app/styles/system.css
··· 1 1 @layer system { 2 2 :root { 3 + --safe-top: env(safe-area-inset-top, 0px); 4 + --safe-bottom: env(safe-area-inset-bottom, 0px); 5 + --safe-left: env(safe-area-inset-left, 0px); 6 + --safe-right: env(safe-area-inset-right, 0px); 7 + 8 + color-scheme: light dark; 9 + 3 10 --layout-header-height: 56px; 4 11 --layout-sidebar-width: 280px; 5 12 --layout-drawer-mini: 64px; 6 13 --layout-drawer-midi: 40dvh; 7 14 8 - --safe-top: env(safe-area-inset-top, 0px); 9 - --safe-bottom: env(safe-area-inset-bottom, 0px); 10 - --safe-left: env(safe-area-inset-left, 0px); 11 - --safe-right: env(safe-area-inset-right, 0px); 15 + --light-bg: ghostwhite; 16 + --light-color: darkslategray; 17 + --light-accent: tomato; 18 + 19 + --dark-bg: #223; 20 + --dark-color: ghostwhite; 21 + --dark-accent: gold; 22 + 23 + --theme-bg: light-dark(var(--light-bg), var(--dark-bg)); 24 + --theme-color: light-dark(var(--light-color), var(--dark-color)); 25 + --theme-accent: light-dark(var(--light-accent), var(--dark-accent)); 12 26 } 13 27 14 28 html { 15 29 font-size: 14px; /* default */ 30 + 31 + body { 32 + &.force-dark { color-scheme: dark; } 33 + &.force-light { color-scheme: light; } 34 + } 16 35 } 17 36 18 - /* put content on the page that js can use to detect breakpoints */ 37 + * { 38 + color: var(--theme-color); 39 + background: var(--theme-bg); 40 + } 41 + 42 + /* 43 + put content on the page that js can use to detect breakpoints 44 + @see app/primitives/useBreakpoint 45 + */ 19 46 20 47 body:before { 21 - content: 'phone'; 48 + content: 'bp:phone'; 22 49 display: none; 23 50 } 24 51 25 52 @media (--tablet) { 26 53 body:before { 27 - content: 'tablet'; 54 + content: 'bp:tablet'; 28 55 } 29 56 } 30 57 31 58 @media (--desktop) { 32 59 body:before { 33 - content: 'desktop'; 60 + content: 'bp:desktop'; 34 61 } 35 62 } 36 63 }
+3 -3
src/lib/client/webrtc.ts
··· 453 453 return 454 454 } 455 455 456 - if (isFinal && this.#chunkIncoming) { 456 + if (isFinal) { 457 457 this.#chunkBuffer.enqueue(this.#chunkIncoming) 458 458 this.#chunkIncoming = null 459 459 } ··· 461 461 462 462 async #incomingLoop() { 463 463 try { 464 - let chunk: DataChunk 465 - while ((chunk = await this.#chunkBuffer.dequeue(this.#abort.signal))) { 464 + while (true) { 465 + const chunk = await this.#chunkBuffer.dequeue(this.#abort.signal) 466 466 let payload = chunk.buffer 467 467 468 468 try {
-1
vite.config.js
··· 1 1 import postcssGlobalData from '@csstools/postcss-global-data' 2 - import basicSsl from '@vitejs/plugin-basic-ssl' 3 2 import postcssPresetEnv from 'postcss-preset-env' 4 3 import devtools from 'solid-devtools/vite' 5 4 import {defineConfig} from 'vite'