Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-25 20:06:44 +02:00
commit 17a620f4f7
9 changed files with 343 additions and 118 deletions

View file

@ -1,32 +0,0 @@
name: deploy
# Event-driven production deploy. Replaces the 2-minute systemd poller on asgard: on a push
# to main, the asgard-hosted Forgejo Actions runner connects back to the asgard host over the
# Docker bridge gateway and runs the existing deploy/deploy.sh, which pulls the published image
# and restarts the stack. The image itself is still built and pushed from the developer machine
# (`siftlode publish`) BEFORE the push — this job only triggers the deploy, it does not build.
#
# The SSH key is a dedicated, forced-command key on the host (it can ONLY run deploy.sh), stored
# here as a base64-encoded Actions secret. The host address is the container's default-route
# gateway (= the Docker host), resolved at runtime so no fixed IP or runner config is needed.
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: trigger host deploy
run: |
command -v ssh >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq openssh-client; }
install -m 700 -d ~/.ssh
printf '%s' "${{ secrets.DEPLOY_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Default-route gateway of this job container = the Docker host (little-endian hex in /proc/net/route).
H=$(awk '$2=="00000000" && $1!="lo"{print $3; exit}' /proc/net/route)
HOST=$(printf '%d.%d.%d.%d' "0x${H:6:2}" "0x${H:4:2}" "0x${H:2:2}" "0x${H:0:2}")
echo "Deploying via host $HOST:2222"
ssh -p 2222 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null \
-i ~/.ssh/id_ed25519 "p33t@$HOST" deploy

View file

@ -1 +1 @@
0.14.1 0.15.0

View file

@ -1,3 +1,6 @@
import base64
import binascii
import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -278,21 +281,92 @@ def _feed_params(
} }
SORTS = { # --- Keyset (cursor) pagination ---------------------------------------------
"newest": Video.published_at.desc().nulls_last(), #
"oldest": Video.published_at.asc().nulls_last(), # Each sort is described as an ordered list of key columns (expression + direction
"views": Video.view_count.desc().nulls_last(), # + whether it can be NULL + a JSON round-trip kind), always with Video.id as the
"views_asc": Video.view_count.asc().nulls_last(), # final, unique tiebreaker. This drives BOTH the ORDER BY and the keyset WHERE so
"duration_desc": Video.duration_seconds.desc().nulls_last(), # they can never drift apart — the prerequisite for correct cursor paging. Keyset
"duration_asc": Video.duration_seconds.asc().nulls_last(), # replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
"title": func.lower(Video.title).asc().nulls_last(), # and the page boundary is stable even as the scheduler ingests new videos mid-scroll
"title_desc": func.lower(Video.title).desc().nulls_last(), # (OFFSET would shift and duplicate/skip rows).
"subscribers": Channel.subscriber_count.desc().nulls_last(), def _sort_keys(sort: str, seed: int) -> list[dict]:
"subscribers_asc": Channel.subscriber_count.asc().nulls_last(), pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"}
# Your per-channel priority (set in the channel manager), newest first within a tier. table: dict[str, list[dict]] = {
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. "newest": [pub],
"priority": func.coalesce(Subscription.priority, 0).desc(), "oldest": [{**pub, "asc": True}],
} "views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}],
"views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}],
"duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}],
"duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}],
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}],
"title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}],
"subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}],
"subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}],
# Your per-channel priority (set in the channel manager), newest first within a tier.
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
"priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub],
"priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub],
# Deterministic per-seed shuffle: the same seed yields the same total order, so
# the cursor stays valid across pages of one shuffled run.
"shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}],
}
return table.get(sort) or table["newest"]
def _order_by(keys: list[dict]):
cols = []
for k in keys:
c = k["expr"].asc() if k["asc"] else k["expr"].desc()
cols.append(c.nulls_last() if k["nullable"] else c)
cols.append(Video.id.asc()) # unique final tiebreaker
return cols
def _keyset_predicate(keys: list[dict], cursor_id: str):
"""WHERE clause selecting rows strictly AFTER the cursor in the sort order.
Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL
sorts after every non-NULL value, so a cursor sitting on a non-NULL value must
also pull in the NULL group, and equality treats NULL == NULL."""
def eq(k):
return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"]
def after(k):
if k["value"] is None:
return false() # nulls are last; nothing sorts strictly after them
cmp = k["expr"] > k["value"] if k["asc"] else k["expr"] < k["value"]
return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp
branches = []
for j in range(len(keys)):
branches.append(and_(*[eq(keys[i]) for i in range(j)], after(keys[j])))
branches.append(and_(*[eq(k) for k in keys], Video.id > cursor_id))
return or_(*branches)
def _encode_cursor(keys: list[dict], row) -> str:
vals = []
for i, k in enumerate(keys):
v = getattr(row, f"_k{i}")
vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v)
payload = json.dumps({"k": vals, "id": row.id})
return base64.urlsafe_b64encode(payload.encode()).decode()
def _decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
try:
payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
raw = payload["k"]
if len(raw) != len(keys):
raise ValueError("key arity mismatch")
vals = [
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v
for k, v in zip(keys, raw)
]
return vals, payload["id"]
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid feed cursor")
@router.get("/feed") @router.get("/feed")
@ -301,29 +375,30 @@ def get_feed(
sort: str = "newest", sort: str = "newest",
seed: int = 0, seed: int = 0,
limit: int = Query(default=60, le=200), limit: int = Query(default=60, le=200),
offset: int = 0, cursor: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status = _filtered_query(db, user, **params)
if sort in ("priority", "priority_asc"): keys = _sort_keys(sort, seed)
prio = func.coalesce(Subscription.priority, 0)
query = query.order_by(
prio.asc() if sort == "priority_asc" else prio.desc(),
Video.published_at.desc().nulls_last(),
)
else:
order = SORTS.get(sort)
if order is None and sort == "shuffle":
order = func.md5(func.concat(Video.id, str(seed)))
query = query.order_by(order if order is not None else SORTS["newest"])
rows = db.execute(query.offset(offset).limit(limit + 1)).all() # Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
query = query.order_by(*_order_by(keys))
if cursor:
vals, cursor_id = _decode_cursor(keys, cursor)
for k, v in zip(keys, vals):
k["value"] = v
query = query.where(_keyset_predicate(keys, cursor_id))
rows = db.execute(query.limit(limit + 1)).all()
has_more = len(rows) > limit has_more = len(rows) > limit
page = rows[:limit]
return { return {
"items": [_serialize(r) for r in rows[:limit]], "items": [_serialize(r) for r in page],
"next_cursor": _encode_cursor(keys, page[-1]) if (has_more and page) else None,
"has_more": has_more, "has_more": has_more,
"offset": offset,
"limit": limit, "limit": limit,
} }

View file

@ -10,6 +10,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"lucide-react": "^0.408.0", "lucide-react": "^0.408.0",
@ -1247,6 +1248,33 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz",
"integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View file

@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"lucide-react": "^0.408.0", "lucide-react": "^0.408.0",

View file

@ -5,7 +5,7 @@ import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import VideoCard from "./VideoCard"; import VirtualFeed from "./VirtualFeed";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
const PAGE = 60; const PAGE = 60;
@ -94,9 +94,9 @@ export default function Feed({
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["feed", filters], queryKey: ["feed", filters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE), queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE),
initialPageParam: 0, initialPageParam: null as string | null,
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined), getNextPageParam: (last) => last.next_cursor ?? undefined,
}); });
// Drop optimistic status overrides when filters change OR fresh server data // Drop optimistic status overrides when filters change OR fresh server data
@ -118,22 +118,7 @@ export default function Feed({
staleTime: 30_000, staleTime: 30_000,
}); });
const sentinel = useRef<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query; const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
const el = sentinel.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Keep the loaded videos in a ref so onState can stay referentially stable // Keep the loaded videos in a ref so onState can stay referentially stable
// (it needs the list only to look up a title for the notification). A stable // (it needs the list only to look up a title for the notification). A stable
@ -388,36 +373,19 @@ export default function Feed({
{toolbar} {toolbar}
{items.length === 0 ? ( {items.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div> <div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
) : view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (
<VideoCard
key={v.id}
video={v}
view="grid"
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo}
/>
))}
</div>
) : ( ) : (
<div className="max-w-4xl mx-auto flex flex-col gap-1"> <VirtualFeed
{items.map((v) => ( items={items}
<VideoCard view={view}
key={v.id} onState={onState}
video={v} onToggleSave={onToggleSave}
view="list" onChannelFilter={onChannelFilter}
onState={onState} onResetState={onResetState}
onToggleSave={onToggleSave} onOpen={openVideo}
onChannelFilter={onChannelFilter} hasNextPage={!!hasNextPage}
onResetState={onResetState} isFetchingNextPage={isFetchingNextPage}
onOpen={openVideo} fetchNextPage={fetchNextPage}
/> />
))}
</div>
)} )}
{activeVideo && ( {activeVideo && (
<PlayerModal <PlayerModal
@ -427,7 +395,6 @@ export default function Feed({
onState={onState} onState={onState}
/> />
)} )}
<div ref={sentinel} className="h-10" />
{isFetchingNextPage && ( {isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div> <div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)} )}

View file

@ -0,0 +1,176 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Video } from "../lib/api";
import VideoCard from "./VideoCard";
// Grid column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
const GRID_MIN_COL = 260;
const GRID_GAP = 16;
const LIST_GAP = 4;
// Estimated row heights before measurement; real heights are measured per-row so
// these only affect the very first paint and the scrollbar's initial guess.
const GRID_ROW_EST = 340;
const LIST_ROW_EST = 96;
// Start fetching the next page this many rows before the end scrolls into view
// (keeps the old IntersectionObserver's generous prefetch feel).
const PREFETCH_ROWS = 4;
function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 1) return arr.map((x) => [x]);
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
// The feed scrolls inside the app's <main overflow-y-auto>, not the window, so the
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
function getScrollParent(node: HTMLElement | null): HTMLElement {
let el = node?.parentElement ?? null;
while (el) {
const oy = getComputedStyle(el).overflowY;
if (oy === "auto" || oy === "scroll") return el;
el = el.parentElement;
}
return document.documentElement;
}
export interface VirtualFeedProps {
items: Video[];
view: "grid" | "list";
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter: (channelId: string, channelName: string) => void;
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}
export default function VirtualFeed({
items,
view,
onState,
onToggleSave,
onChannelFilter,
onResetState,
onOpen,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}: VirtualFeedProps) {
const listRef = useRef<HTMLDivElement>(null);
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
const [colCount, setColCount] = useState(view === "list" ? 1 : 4);
// Distance from the scroll element's content top to where this list starts (the
// feed toolbar sits above it inside the same scroll area).
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
}, []);
// Re-measure the column count and start offset on container/scroll-area resize.
useLayoutEffect(() => {
const node = listRef.current;
if (!node) return;
const measure = () => {
if (scrollEl) {
const m =
node.getBoundingClientRect().top -
scrollEl.getBoundingClientRect().top +
scrollEl.scrollTop;
setScrollMargin(m);
}
setColCount(
view === "list"
? 1
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(node);
if (scrollEl) ro.observe(scrollEl);
return () => ro.disconnect();
}, [scrollEl, view]);
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
overscan: 4,
gap: view === "grid" ? GRID_GAP : LIST_GAP,
scrollMargin,
});
const virtualRows = virtualizer.getVirtualItems();
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of
// the end, pull the next keyset page.
useEffect(() => {
const last = virtualRows[virtualRows.length - 1];
if (!last) return;
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualRows.map((vr) => {
const row = rows[vr.index];
if (!row) return null;
return (
<div
key={vr.key}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
}}
>
{view === "grid" ? (
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
>
{row.map((v) => (
<VideoCard
key={v.id}
video={v}
view="grid"
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={onOpen}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto pb-1">
<VideoCard
video={row[0]}
view="list"
onState={onState}
onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={onOpen}
/>
</div>
)}
</div>
);
})}
</div>
);
}

View file

@ -79,7 +79,8 @@ export interface VideoDetail {
export interface FeedResponse { export interface FeedResponse {
items: Video[]; items: Video[];
has_more: boolean; has_more: boolean;
offset: number; // Opaque keyset cursor for the next page, or null when this is the last page.
next_cursor: string | null;
limit: number; limit: number;
} }
@ -308,11 +309,11 @@ function filterParams(f: FeedFilters): URLSearchParams {
return p; return p;
} }
function feedQuery(f: FeedFilters, offset: number, limit: number): string { function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string {
const p = filterParams(f); const p = filterParams(f);
p.set("sort", f.sort); p.set("sort", f.sort);
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
p.set("offset", String(offset)); if (cursor) p.set("cursor", cursor);
p.set("limit", String(limit)); p.set("limit", String(limit));
return p.toString(); return p.toString();
} }
@ -511,10 +512,10 @@ export const api = {
version: (): Promise<VersionInfo> => req("/api/version"), version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"), tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"), status: (): Promise<SyncStatus> => req("/api/sync/status"),
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> => feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, offset, limit)}`), req(`/api/feed?${feedQuery(f, cursor, limit)}`),
feedCount: (f: FeedFilters): Promise<{ count: number }> => feedCount: (f: FeedFilters): Promise<{ count: number }> =>
req(`/api/feed/count?${feedQuery(f, 0, 0)}`), req(`/api/feed/count?${filterParams(f).toString()}`),
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> => facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`), req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these

View file

@ -14,6 +14,15 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.15.0",
date: "2026-06-25",
summary: "A faster, smoother feed that stays light no matter how far you scroll.",
features: [
"The feed now draws only the videos currently on screen, so scrolling stays fluid and the page stays light even after you've loaded thousands of videos.",
"Paging through the feed is quicker and steadier: scrolling deep into a large catalogue no longer slows down, and videos arriving in the background while you scroll can no longer cause an item to show up twice or be skipped.",
],
},
{ {
version: "0.14.1", version: "0.14.1",
date: "2026-06-21", date: "2026-06-21",