Persönliche, unbegrenzt nutzbare Empfehlungscodes (z. B. BUBI-7K3M), die erst
nach der Registrierung im Onboarding eingelöst werden — keine Kopplung an den
Clerk-Sign-up-Ablauf mehr. Registrierung ohne Code bleibt möglich.
- Schema (rein additiv): Owner-Felder für Code, Einladungsstatus, Zähler und
Rang-Cache, ReferralRank / ReferralRankAchievement / ReferralSettings,
AcquisitionSource, NotificationType REFERRAL_JOINED / REFERRAL_RANK_UP
- Kernlogik src/lib/referrals.ts: Einlösen (7-Tage-Fenster, kein eigener Code,
keine Kreis-Einladungen, gesperrte Einladende ungültig), gegenseitiges
Folgen, Qualifizierung erst nach Tier + erstem Beitrag (Foto oder fertiges
Video), Tageslimit mit Prüf-Markierung, Ränge mit Bestandsschutz
- Web: /einladen, öffentliche Seite /r/[code], Onboarding-Schritt
"Wie hast du von PawFeed erfahren?", Rang-Abzeichen, Profilring,
"Eingeladen von …", neue Benachrichtigungen
- Admin /p/[secret]/referrals: Ränge anlegen/bearbeiten/deaktivieren mit
Live-Vorschau, Einstellungen, Übersicht mit Prüfung und Aberkennung;
bevorzugte Verifizierung als Vorteil
- prisma/scripts/referral-setup.ts: Start-Ränge, Settings, Code-Backfill,
optional --credit-existing für Alt-Einladungen
Noch offen: Mobile-App, Abbau des alten InviteCode-Ablaufs, Rechtstexte,
db push + Deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Replace next/font/google with next/font/local + vendored Inter variable
woff2 (latin, @fontsource-variable/inter@5.3.0), so neither visitors nor
the Docker build contact Google's servers.
- Add PlayStoreBadge (official Google Play artwork, DE/EN by locale, served
locally from /public/badges) to the hero, a new "PawFeed in your pocket"
section, and the shared landing footer (also covers /ueber-uns).
- Centralize the Play Store URL in src/lib/app-links.ts (derived from the
android.package id org.pawfeed.app).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The story upload input's accept attribute listed explicit image+video
MIME subtypes (image/jpeg,...,video/mp4,...). Several mobile
browsers'/OS's native 'Photos & Videos' pickers only reliably offer
both media kinds when given the broad image/*,video/* wildcard
categories - the explicit mixed list was silently narrowing the picker
to photos only, matching the user's report ('nur Zugriff auf Fotos,
Videos werden gar nicht angezeigt').
The actual accept/reject decision is unchanged (still enforced in
handleFile() against the specific ALLOWED_IMAGE_TYPES/
ALLOWED_VIDEO_TYPES lists) - this only relaxes what the OS offers in
the picker dialog.
Code-reviewed before commit (WORKFLOW.md step 6) - fixed the one
finding that became newly reachable by this fix: videos were
previously never selectable at all, so a parameterized/codec-suffixed
MIME string (e.g. from some Android camera-roll
pickers) could never surface as a rejection; now strips codec
parameters before the ALLOWED_VIDEO_TYPES comparison. Documented two
other findings as accepted, not-fixed-here trade-offs in code comments
(iOS HEIC auto-transcode interaction, broader picker surfacing formats
that still get rejected post-selection) rather than silently ignoring
them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two independent root causes behind 'admin music upload fails':
1. Supabase bucket name mismatch — code expects 'music-tracks' (plural,
MUSIC_STORAGE_BUCKET), but the bucket was created as 'music-track'
(singular). Every upload failed with a bucket-not-found error from
Supabase Storage, surfaced only as a generic toast. Fixed by creating
the correctly-named bucket (public, 5MB limit, audio/mpeg|mp4|aac
allowed_mime_types matching the app's own constants) via the Supabase
Storage API; verified end-to-end with a signed-upload-url + PUT probe.
The old empty 'music-track' bucket is unused but still exists -
deleting it needs the user's own action (blocked here by the
auto-mode classifier's cloud-storage-delete guard).
2. Client-side validation trusted the browser-reported File.type, which
is sniffed from the OS's MIME registry and is inconsistent for
.m4a/.aac (e.g. Windows Chrome reports .m4a as audio/x-m4a, not
audio/mp4) - a perfectly valid file could get rejected as 'wrong
type'. Now derives the content-type from the file extension instead.
Code-reviewed before commit (WORKFLOW.md step 6) - fixed two of four
findings: the extension lookup now uses a Map instead of a plain object
(prototype-shadowing via a file named e.g. 'x.constructor' previously
bypassed the rejection), and requires an actual '.' before the
extension (a bare filename like 'mp3' no longer matches). Extracted the
allowed-types/size constants into a new client-safe src/lib/music-
content-types.ts (supabase-storage.ts re-exports them) so the client
form and server-side Zod schema share one source of truth instead of
two independently maintained copies. Left the trust-the-extension
trade-off as-is (documented in code): this panel sits behind
ADMIN_SECRET + a role check, not the public upload surface, and the
old File.type check was never real content-based validation either.
Full test suite green (one unrelated legal.test.ts timeout in the
full-suite run was confirmed flaky - passes cleanly in isolation).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three pieces of work from this session:
- Pet-suggestion notifications: a weekly cron (species/breed affinity
heuristic) suggests pets to follow, with an owner opt-out and web/mobile
notification UI wiring.
- Story music overlay: admin-curated royalty-free track catalog
(/p/[secret]/music), attachable to stories on web (StoryForm/StoryViewer),
with mute-original support for video stories. Mobile UI is deferred to
the next native build (needs an audio-playback module) — tracked in
mobile/PENDING-NATIVE-BUILD.md.
- Auth UX fixes: sign-in/sign-up now use KeyboardAvoidingView + ScrollView
and a shared PasswordField with a show/hide toggle (mobile). Web's
ActivePetContext gained clearGactivePetId(), wired to sign-out via a new
SignOutCleanup component, fixing a false "you do not have permission to
access this pet" error after switching accounts on the same browser.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New public page at /kinderschutz — required by Google Play's Child
Safety Standards policy for apps with user-generated content/social
features (https://support.google.com/googleplay/android-developer/answer/14747720).
Registered as public + maintenance-exempt in proxy.ts, matching the
existing /impressum, /datenschutz, /nutzungsbedingungen pattern.
Content covers only mechanisms that actually exist in this codebase:
in-app reporting (ReportSheet + admin moderation review), a named
responsible contact (same identity as /impressum), and the German/EU
legal framework (StGB §184b/c, DSA, JuSchG) for law-enforcement
cooperation on confirmed CSAE content — no invented automated-
detection claims.
Cross-linked from the other legal pages' footers, the web /pets
footer, and the mobile app's My Pets legal-links section (new
pets.legalChildSafety i18n key, DE/EN).
- i18next + react-i18next + expo-localization: device-locale detection,
an MMKV-persisted DE/EN/Automatisch override (mirrors web's cookie-only
locale, no backend field needed), and a language switcher on the Pets
tab next to the theme toggle
- every screen and shared component converted from hardcoded German
strings to translation keys; de.json/en.json kept in exact key-parity
(218 keys each, verified programmatically)
- backend: src/trpc/init.ts now accepts an x-locale header as a fallback
locale source (cookie still wins, web unaffected) since mobile has no
cookie jar to set NEXT_LOCALE in; mobile's tRPC client sends the
current language on every request
- fixes a latent bug: species/breed pickers were hardcoded to locale
"de" while every other pet query resolved names via ctx.locale's
default (English, since mobile never sent a locale cookie) — both now
use the same current-language value
- expo-localization is a new native module (config plugin) and needs a
native rebuild to actually link; deviceLocale() falls back to English
instead of crashing when it isn't linked yet, so the app stays usable
before that rebuild happens
Needs a NAS deploy (src/trpc/init.ts is shared backend code) before the
x-locale fallback and the species/breed locale fix take effect for mobile.
Owner.expoPushToken + owner.registerPushToken/clearPushToken, plus a
best-effort Expo push send wired into notify() alongside the existing
in-app Notification row. Missing/expired token or unreachable Expo push
service silently no-ops — push delivery must never break the underlying
action it's attached to.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The admin panel (src/app/p/[secret]/layout.tsx) never wrapped its tree
in ClerkThemeProvider, unlike (app)/layout.tsx — a SpeedUp-Plan P0
optimization that deliberately kept Clerk's client bundle off routes
without a <UserButton/>. Consequence: no client-side session-token
refresh ran on admin pages. The initial page load stayed authenticated
(Clerk's server-side handshake reissues the token on full document
navigations), but subsequent tRPC XHRs — e.g. legal.publish — started
failing with UNAUTHORIZED/401 a few minutes in, since that handshake
only fires for navigations, not fetch calls. Confirmed live: a fresh
page reload always restored a working session, matching this exactly.
Mounting ClerkThemeProvider in the admin layout re-adds background
token refresh. Also gives the legal-publish error handler an
actionable message for UNAUTHORIZED specifically (distinct from other
failures), since a residual case can still occur after a real 7-day
session expiry.
Add a per-pet consent gate (Art. 9 Abs. 2 lit. a DSGVO) in front of the
whole Health-Tracking area (weight, vet visits, vaccines, emergency
vet, feeding, Health Card): an un-pre-checked checkbox must be
confirmed before any of it is used, enforced server-side in
health.ts (not just the UI) via a new assertHealthConsent check on
every data-bearing endpoint.
Revoking consent (health.revokeConsent) deletes every health record
for that pet in one transaction and is available at any time from the
Health page. Pet gains healthConsentGivenAt/healthConsentRevokedAt
(nullable, additive — db push already applied against the shared
Supabase instance).
Extends the Datenschutzerklärung (Abschnitt 3) with the Art. 9 legal
basis and the revoke/auto-delete mechanism, per counsel's follow-up.
Narrow the Art. 4 No. 1 GDPR non-personal-data claim to data that
permits no inference about the owner as a natural person, and call
out the case where it can (e.g. pet photos alongside a profile
picture) as personal data after all.
- clarify pet-only data (breed, weight, health records) is not
personal data under Art. 4 No. 1 GDPR since GDPR protects only
natural persons; still handled confidentially per policy
- switch Clerk/Cloudflare/Mux/Stripe from SCC framing to EU-U.S. DPF
certification (Art. 45 adequacy decision), strengthen the residual
SCC/Schrems-II fallback clause beyond TLS + access restrictions
- document 30-day retention and rationale for technical error reports
Verified live that adding Clerk's frontend API host to script-src does
NOT resolve the CSP violation in modern browsers: with 'strict-dynamic'
present, CSP3 ignores host-based entries in that directive entirely
(Chrome's own violation message confirms this). The console noise is a
Clerk SDK-side loading quirk, not something fixable here without
dropping 'strict-dynamic'. Keeping the host entry only as the same
spec-mandated pre-CSP3 fallback the existing 'unsafe-inline' entry
already relies on, with the comment corrected to say so.
Clerk's clerkMiddleware auto-adds its frontend API host to connect-src
but not script-src, so the initial (unversioned) clerk.browser.js/
ui.browser.js load was blocked by strict-dynamic before the 307 redirect
to the versioned URL — two CSP violations reported to /api/csp-report on
every page that renders Clerk UI, dragging the Best Practices Lighthouse
score down. Derives the host from the publishable key (same approach as
the existing Supabase/GlitchTip hostname helpers) instead of hardcoding it.
- README: note @clerk/localizations' deDE usage in the tech stack table
and shipped-features list.
- ClerkThemeProvider: comment explaining why deDE is needed (Clerk's own
UI defaults to English regardless of the app's next-intl locale).
- mobil.md: mark the Capacitor plan superseded by the React Native/Expo
scaffold decided this session (see mobile/README.md).
The sign-in/sign-up UI rendered in English despite the rest of the app
being German — @clerk/localizations ships an official deDE locale that
plugs directly into ClerkProvider's existing appearance config.
The post-card action row (comments, hide/unhide, pin/unpin, AI-disclosure
correction, delete) had no explanation of what each icon does — add a help
popover next to the "Posts" heading listing icon + action name.
Open reports and Verifications pending summarized a number but gave no way to
act on it — link each tile straight to /reports and /verification, with
hover/focus states and a chevron affordance so it reads as navigation, not
just a number.
The Overview card was a flat, uncategorized list of 10 stats with no visual
grouping. Split it into three labeled, color-identified groups so a moderator
can scan by kind of number instead of reading every label:
- Activity (emerald): owners, pets, posts, DAU, MAU
- Violations (rose): banned users, shadowbanned, blacklist entries — values
only pick up the color when actually > 0, same "act on this" convention as
the existing AttentionStat tiles
- Info (sky): active ads, verifications closed
Also switched the maintenance banner and AttentionStat tiles from border to
ring-1, matching the shared Card component's depth convention so the whole
dashboard uses one visual vocabulary instead of two.
Admin shell used hardcoded zinc-* classes instead of semantic design tokens and
a redundant "PawFeed Admin" kicker label. Dashboard was a wall of 12 identically
weighted stat cards with no hierarchy. Sidebar/mobile nav gave no indication of
the current page.
- layout.tsx/AdminMobileNav.tsx/GranularityToggle.tsx/TimeSeriesChart.tsx: replace
zinc-* with bg-background/text-foreground/border-border etc., force dark via
the `dark` class so the admin surface stays permanently dark regardless of
site-wide theme toggle
- New AdminSidebarNav.tsx: highlights the current page (desktop sidebar)
- AdminMobileNav.tsx: same active-state logic for the mobile drawer, topbar
shows the current page label instead of a static title
- page.tsx: replace the 12-card stat grid with 2 AttentionStat tiles (reports,
pending verifications — color means "act on this") plus a flat Overview list,
and convert the 5 chart containers to the shared Card component
Introduces PRODUCT.md/DESIGN.md (Impeccable skill) capturing PawFeed's
existing brand identity ("The Spotlight Porch") as durable reference, then
applies it:
- Landing hero: restrained "spotlight" glow instead of a full orange
gradient panel, tagline as the actual headline with a marigold accent on
"Stars"/"stars", real PawPrint mark fixed (was a mismatched hand-drawn
shape, also fixed a viewBox overflow clipping bug).
- Features section: one dominant mechanism (pet-as-identity) plus a quiet
supporting list, replacing the four-identical-cards grid craft-floor
refuses.
- Header: dropped the generic glass-blur sticky nav for a flat header with
a soft marigold gradient hairline; CTA button now matches DESIGN.md
(rounded-lg, no shadow, same orange as the hero CTA); logo gets a
paw-spring hover as a small on-brand moment; new active-page indicator
(LandingNavLinks) under the current nav link.
- Legal pages (Impressum/Datenschutz/Nutzungsbedingungen): replaced the
stacked numbered-card-per-section layout with a flowing typographic
document (hairline dividers, no boxes, no arbitrary section-number
badges) via the shared LegalLayout/LegalSection components.
- Nutzungsbedingungen: moved Sponsoring earlier (now 3a, right after
Nutzerinhalte & Urheberrecht) while keeping Salvatorische Klausel/Kontakt
last; existing section numbers left untouched to avoid breaking the
"gemäß Abschnitt 5" cross-reference in Haftungsausschluss.
Top-post and top-story cards in the pet insights dashboard now open the
real post (PostDetailDialog, same fetch-by-id pattern as the notifications
page) or story (StoryViewer, new initialStoryId prop to jump straight to
the right story instead of always starting at index 0).
New insights router aggregates the reactionCount/commentCount counters
(Post pre-existing, Story added in #29) into a small per-pet dashboard —
total paws/comments received and which post/story is resonating most.
Reachable from the owner's own pet profile ("Meine Tiere"), owner-only.
Story reactions/comments mirror the existing Post/Ad engagement models
(StoryReaction/StoryComment tables, denormalized counters on Story) with
a comment overlay rendered on top of the still-playing story rather than
pausing it. No notification wiring yet, matching the Advertisement
engagement model's precedent.
pets.byId is owner-only (throws FORBIDDEN for pets you don't own), but
followers/page.tsx and following/page.tsx used it just to grab the
pet's name for the heading/empty-state — any visitor viewing another
pet's followers or following list got a 404 (Gitea #33). Swapped to
pets.getProfile, the same public-safe procedure the actual profile
page already uses.
Also adds the requested privacy control: Pet.followerVisibility
(EVERYONE/FOLLOWERS_ONLY/NOBODY, default EVERYONE) gates
follows.listFollowers/listFollowing via a new assertCanViewFollowerLists
helper — FOLLOWERS_ONLY checks whether any pet the caller owns follows
the target pet; the owner can always see their own. Both pages now
render a "private" message instead of erroring when blocked. Editable
from the pet edit page's Privacy section, next to the existing DM
policy control.
Search input (w-64) + two status/role selects (w-36) sat in one
unwrappable row, and each user row's shrink-0 action-button cluster
(up to 6 buttons) never yielded width to the info block — both
squeezed everything together below md:. Filter bar now stacks
(search full-width, filters side by side below it), and user rows
stack info above actions on mobile, row layout from sm: up.
The permanent 224px sidebar in the admin layout had no responsive
handling at all, leaving almost no width for content on a phone
(Gitea #10). Sidebar is now hidden below md: and replaced by a
topbar + slide-in Sheet drawer (AdminMobileNav) with the same nav
links; content padding and the outer flex direction adapt per
breakpoint.
- New assertNotBlocked() helper (src/lib/assert-not-blocked.ts), mirroring
assertPetOwnership. comments.create, reactions.toggle (create path), and
reposts.create now reject direct interaction between blocked pets, not
just feed visibility (product decision: enforce, matching messages.ts's
existing DM block check).
- videos.updateCaption and stories.getViewers now route through the shared
assertPetOwnership helper instead of duplicating the inline ownership
check.
- ads.ts mutations (recordView, toggleReaction, addComment, toggleRepost)
gain the same rate limiting their post-side equivalents already have.
- follows.listFollowers/listFollowing/getPetsWithActiveStories now apply
getSpiderExclusionId(), matching the established per-query pattern in
feed.ts/explore.ts/search.ts.
Remaining findings from the review are addressed:
- timingSafeStringEqual's length short-circuit needs no fix (matches
Node's own crypto.timingSafeEqual behavior).
- GDPR export token (admin.exportUserData) and health-card token
(health.getOrCreateCard) now get an explicit crypto-random token
instead of falling back to Prisma's cuid() default, which is
timestamp/counter-derived and not meant for unguessable secrets.
- Add IP-based rate limiting to both public token endpoints
(/api/gdpr-export/[token], /health-card/[token]) to close the
brute-force window.
- videos.getByPostId now applies the same hiddenAt/shadowban
visibility filter posts.getById already uses, closing an IDOR that
let any authenticated user read Mux data for hidden/shadowbanned
video posts.
Found via a security-reviewer pass over the tRPC/API layer.
Broadcasts had no delete path (Phase 1 gap) — had to remove a test
message via a one-off script yesterday. New admin.deleteBroadcast
mutation mirrors hidePost/banOwner: SUPER_ADMIN-gated (same bar as
createBroadcast), reason required (min 1 char), wraps delete + a
DELETE_BROADCAST ModerationLog entry in one transaction.
BroadcastReceipt rows cascade via the existing onDelete: Cascade.
Frontend: trash icon per history item opens a confirm dialog with a
required reason input, mirroring the posts admin page's delete-confirm
pattern.
Cloudflare's brand guidelines flag a hand-written "Protected by
Cloudflare" text label as misleading — replace with their official
badge PNG (public/badges/cloudflare-protected.png), same fixed-size
<img> pattern already used for AiDisclosureBadge.
Mozilla Observatory's rescan flagged the enforced CSP as "implemented
unsafely" (-20) for unsafe-inline/https:/http: in script-src. Enable
Clerk's strict mode, which drops http:/https: and adds a per-request
nonce + 'strict-dynamic' instead. Clerk forwards the resulting CSP
header (with nonce) on both the response and the downstream request,
which is exactly what Next.js's automatic nonce application reads
during SSR, so framework scripts and page bundles are covered for
free. next-themes' hand-written FOUC-prevention <script> is not
covered by that automatic mechanism, so RootLayout now reads the
x-nonce header and passes it to ThemeProvider explicitly.
'unsafe-inline' stays in the header text as a spec-mandated fallback
for pre-CSP3 browsers; strict-dynamic-aware browsers ignore it.
'unsafe-eval' is kept for now (see prior commit ddbeee7) — untouched
by this change, separate follow-up once confirmed unnecessary.
Add 'report-sample' to script-src and capture lineNumber/columnNumber/
sample in /api/csp-report for both report shapes (Reporting API and
legacy report-uri). Previously only sourceFile was captured, which
wasn't enough to pin down the 2026-08-15 eval violations — live repro
via the avatar-crop flow (react-easy-crop) came back clean, so the
next real occurrence needs better diagnostics to actually find it.
Mozilla Observatory docked 25 points for CSP being report-only. The
report-only period since 2026-08-12 came back clean in GlitchTip except
6 isolated script-src eval hits on 2026-08-15 (likely react-easy-crop
on the avatar-crop flow) that never recurred. Keep 'unsafe-eval' in
script-src defensively so an enforced policy can't repeat the earlier
incident where a blocked required resource crashed ClerkProvider
app-wide.
MilestoneCard never rendered PostCard's action row (PawButton,
comment button, PawBackButton) -- reacting/commenting was already
fully supported server-side (reactions.ts/comments.ts don't restrict
by post type), the UI for it was just missing since MILE-02 shipped.
Also widens the prop type from a narrow local MilestoneCardPost to
the existing PostCardPost (PostCard.tsx already passes the full post
object in, the local type just didn't reflect it) -- this both
unlocks reactionCount/commentCount and lets MilestoneCard open the
same PostDetailDialog PostCard uses for commenting, instead of
needing a parallel implementation.
Separately: the card's bg-orange-50/border-orange-200 were hardcoded
light-only Tailwind classes with no dark: variant, so in Dark Mode the
pet name (text-foreground, which flips to a light color) sat on a
background that stayed pale and unreadable -- same failure mode as
the Clerk auth pages fixed earlier. Switched to the opacity-based
bg-orange-500/5 + border-orange-500/20 pattern already used elsewhere
in the app (unterstuetzen page, notifications list), which adapts to
either theme automatically instead of needing a manual dark: override.
MILESTONE_META in src/lib/milestone-meta.ts held hardcoded English
labels ("Birthday", "Vet Visit", ...) consumed directly by
MilestoneCard and MilestoneForm -- bypassing next-intl entirely, so no
de.json entry could ever have translated them (there was no key to
translate, not a missing one).
Adds a MilestoneTypes namespace to both message catalogs and switches
the two user-facing consumers to useTranslations("MilestoneTypes")
for the label, keeping MILESTONE_META only for the icon lookup. The
admin posts page (p/[secret]/posts) still reads MILESTONE_META.label
directly and is left as-is -- the admin panel is English-only by
existing project convention, not part of the localized surface.
SignIn/SignUp passed their own appearance.variables.colorBackground
("#FAFAFA", always light) and colorPrimary, which Clerk merges on top
of ClerkThemeProvider's appearance -- so in Dark Mode the provider's
baseTheme: dark (light-on-dark default text colors) stayed active
while colorBackground got forced back to light by the page-level
override. Light text on a light background made the whole form
unreadable.
Both page-level appearance props now only set elements.formButtonPrimary
(the orange CTA, theme-independent) -- colorPrimary/colorBackground are
already handled correctly, theme-aware, by ClerkThemeProvider.
The verified-checkmark + sponsor-sparkle JSX block was copy-pasted
across 6 near-identical spots (PostCard, pets/[petId] profile,
Sidebar x2, MobileNav x2, pets/[petId]/edit) with inconsistent icon
sizing (h-3 / h-3.5 / h-4) -- part of the same Gitea #31 DRY audit as
the pet-repository select consolidation.
Adds src/components/ui/pet-identity-badges.tsx (same role as the
existing pet-avatar.tsx: a small, domain-specific, reusable
presentational component) and migrates all 5 display sites to it. The
sponsor prop is optional -- Sidebar/MobileNav/edit page simply omit it
rather than faking a sponsor variant. isActiveSponsor() from
lib/sponsor.ts is unchanged, only reused instead of duplicated.
Deliberately NOT touched: the admin verify-toggle button in
p/[secret]/users/page.tsx -- an interactive control with its own
click/fill logic, not a passive display badge; forcing it into this
component would be a forced abstraction, not real DRY.
Pure refactor, no visual change -- verified via tsc, vitest, and a
production build.
The Prisma select fragment { id, name, nickname, avatarKey } (and its
isVerified + owner-sponsor extension) was copy-pasted ~38x across 13
router files -- no repository/query layer existed to consolidate it
(Gitea #31 DRY audit).
Adds src/repositories/pet-repository.ts exporting petIdentitySelect and
petCardSelect (composed via spread) plus their inferred Prisma payload
types, and migrates every call site to import and reuse them -- either
directly or composed with the few extra fields a given query needs
(e.g. ownerId, bio, isVerified where the base fragment doesn't cover
it). A handful of structurally different, smaller field selections
(e.g. name+nickname only, no id) were deliberately left as-is rather
than force-fit into the shared shape.
Pure refactor, no behavior change -- verified via tsc, vitest, and a
production build.
Two follow-ups from a fresh PageSpeed Insights run (mobile 87/100/100/100):
1. next.config.ts: enable experimental.inlineCss. Removes a ~342ms
render-blocking CSS request (globals.css, ~21.6 KiB gzip -- shared by
every route since Tailwind bundles the whole app's utilities into one
file). Officially documented, dependency-free, and explicitly
recommended for small atomic-CSS bundles like ours. Applies globally
(can't be scoped per route), so hard/full page loads lose cross-page
CSS caching -- soft client-side navigations are unaffected.
2. page.tsx hero image: `priority` was deprecated in Next.js 16 in favor
of `fetchPriority`/`loading` as separate props -- it still emitted a
<link rel=preload> but no longer set fetchpriority="high" on the <img>
itself. Replaced with loading="eager" fetchPriority="high" per the
bundled Next.js 16 docs.
SpeedUp-Plan, Gitea #28.
Adds public/llms.txt (llms.txt spec: H1 + summary + linked sections)
describing the product for AI agents/crawlers -- usage-focused only, no
internal vendor/infra details.
Also fixes proxy.ts's middleware matcher, which excludes common static
file extensions from Clerk's auth gate but was missing .txt -- every
request to /llms.txt (and any future .txt file, e.g. robots.txt) was
being redirected to the Clerk sign-in page instead of served. No app
route under src/app uses a .txt path, so this only affects genuinely
static files in public/.
SpeedUp-Plan (Gitea #28) -- last remaining Lighthouse finding.
The old public/app-preview-mobile.png was exactly 327x714px -- 1:1 with
its CSS display size, so retina displays (2x/3x DPR) had no headroom and
Lighthouse flagged it as "serves images with low resolution"
(image-size-responsive, SpeedUp-Plan P2).
Replaced with a fresh feed screenshot supplied by the user, center-cropped
to the same 327:714 aspect ratio and delivered at 981x2142 (3x) as WebP
(~193 KB) so next/image's built-in optimizer has real headroom to generate
a proper srcset instead of upscaling a 1x source.
resolvedTheme from next-themes is undefined during SSR and on the client's
first paint (before its mount effect runs), so ThemeToggleButton and the
shared useThemeToggle hook (Sidebar, MobileNav) rendered a Sun/Moon icon
that could mismatch the server-rendered one on first hydration whenever
the system/stored theme was dark -- a React #418 hydration error.
Both now gate isDark on a mounted flag, matching next-themes' documented
hydration-safe pattern.
Found via a Chrome DevTools Lighthouse re-check on the SpeedUp-Plan work
(Gitea #28) -- pre-existing, unrelated to the Clerk-provider scoping.
Moves ClerkThemeProvider from the root layout into (app)/layout.tsx and
(auth)/layout.tsx — it was previously mounted globally, shipping the
~240 KiB Clerk client SDK to every route including the public marketing
page. Only Sidebar's UserButton and the (auth) sign-in/sign-up forms
actually need client-side Clerk context; server-side auth() calls are
unaffected since they read from proxy.ts's clerkMiddleware, not React
context.
Also fixes two WCAG AA contrast failures on the hero/header CTAs
(orange-500/600 -> orange-700, ~2.8:1/3.6:1 -> ~5.5:1) and adds a
missing <main> landmark around the landing page content.
Source: PageSpeed Insights audit, mobile Performance 70 -> targets >90.
ActivePetProvider read localStorage in a useEffect, but
ActivePetInitializer (a descendant, so its effect fires first — React
runs effects child-before-parent) checked activePetId in its own
effect and overwrote localStorage with the owner's first pet whenever
it still saw the initial null — which was always, on every reload,
since the provider's own effect hadn't run yet. The switched-to pet
never had a chance to be read back.
Reads localStorage synchronously via a lazy useState initializer
(window-guarded for SSR) instead, so activePetId is already correct
on the client's first render and the race can't happen.
Real root cause of the mobile rendering bug (confirmed via an actual
Android Chrome screenshot, not devtools emulation): StoryViewer's
fixed inset-0 div is a descendant of <main className="overflow-auto">
in the (app) layout. On Android Chrome, a position:fixed element
inside an overflow-auto ancestor can size itself against that
ancestor's full scrollable content instead of the real viewport —
the overlay rendered many times taller than the screen, so only a
small top slice was visible, looking like a heavily zoomed crop. The
previous two commits were chasing the video's object-fit/aspect-ratio,
which was never the actual problem.
Renders the overlay via createPortal to document.body instead, the
same pattern Radix/Base UI's Sheet and Dialog already use internally
for exactly this class of bug.
The aspect-ratio-sized box from the previous commit caused a
mobile-only bug — video appeared cropped/zoomed instead of fitting.
Root cause not fully isolated (mux-player's object-fit default is
contain, confirmed in its source, so it wasn't that), but the sized
box is the only recent change in that path. Gates it to sm+ viewports
via a CSS custom property + Tailwind arbitrary aspect-ratio, and
reverts to full-bleed + explicit object-fit: contain below that —
the combination already verified working before the box-sizing
change.
The player box was full-bleed (w-full h-full) with object-fit: contain
handling the letterboxing internally — visually correct but the box
itself still spanned the whole screen, so the black letterbox area
read as part of the player rather than just backdrop. Sizes the box
itself to the story's aspectRatio (defaulting to 9:16 before Mux
reports the real value), centered via flex — same visual result, but
now only the actual content area is the "box".
Story gains mediaType (IMAGE/VIDEO) plus the same Mux fields as
VideoPost (muxUploadId/AssetId/PlaybackId, videoStatus, durationSecs,
aspectRatio); storageKey becomes optional since video stories don't
use Supabase Storage.
- 30s duration cap (deliberately shorter than the 180s normal-post
cap) enforced client-side before upload (reads the file's own
duration) and again server-side in the mux webhook and
stories.getVideoStatus's self-heal path, so a bypassed client check
or a direct API call can't produce an oversized story video.
- MAX_STORY_VIDEO_DURATION_SECS lives in a new client-safe
src/lib/story-video-limits.ts rather than stories.ts, which is
server-only — importing from a server-only module into StoryForm
(a client component) pulled the pg driver into the client bundle
and broke the build.
- Mux webhook now resolves an asset to either a VideoPost or a Story;
video stories never fan out to the feed (mirrors the normal-post
webhook branch, minus fanOutPost).
- StoryViewer: video stories autoplay muted (tap to unmute — browsers
block autoplay-with-sound past the story that was directly clicked
open) with a real playback-time progress bar (image stories keep
the fixed 5s bar), rendered via object-contain to correctly handle
the ~9:16 portrait ratio typical of phone-shot story videos instead
of a fixed 16:9 box.