Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5676065400 | ||
|
|
5ffa472da0 | ||
|
|
0c448de4c1 | ||
|
|
795a39355e | ||
|
|
8c640e2837 | ||
|
|
66230a8695 | ||
|
|
0a0ff90745 | ||
|
|
c7c1473e2a | ||
|
|
0668459c1e | ||
|
|
dbc416c43c | ||
|
|
2bd4b2d973 | ||
|
|
361aba0d1d | ||
|
|
e60abbabc6 | ||
|
|
81e617ece5 | ||
|
|
c678c4cf27 | ||
|
|
f51b158989 | ||
|
|
01459c7e0e | ||
|
|
a2af4eda6a | ||
|
|
333ba16573 | ||
|
|
9136981af7 | ||
|
|
7ab74f8077 | ||
|
|
4214a26bd7 | ||
|
|
6a88214186 | ||
|
|
c74bbffccb | ||
|
|
7169840000 | ||
|
|
f112d36f4a | ||
|
|
80e27a7f0b | ||
|
|
5606a81dd2 | ||
|
|
6979447b6d | ||
|
|
69e2c1364e | ||
|
|
a9b6a57585 | ||
|
|
00fb0db168 | ||
|
|
f1ccc78005 | ||
|
|
9adba560a7 | ||
|
|
efc82d636d | ||
|
|
566a2eedd0 | ||
|
|
7a064d8a36 | ||
|
|
94c143e17d | ||
|
|
8143e7b667 | ||
|
|
307b76e8fd | ||
|
|
9f7acf1d98 | ||
|
|
2c0680cef9 | ||
|
|
a01c58e5f0 | ||
|
|
ac664d488d | ||
|
|
81d9c6da30 | ||
|
|
4ec5d8c5f4 | ||
|
|
ccebfa46a6 | ||
|
|
204d5f6831 | ||
|
|
1a24ebae89 | ||
|
|
1bf0f9282a | ||
|
|
8f8fc8d75e | ||
|
|
18f25ce316 | ||
|
|
2b285de09e |
No files matched your search
@@ -84,13 +84,23 @@ All uploads are presigned/direct-to-storage — media bytes never pass through t
|
||||
|
||||
### Admin / moderation panel
|
||||
|
||||
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,moderators,posts,reports,ads,referrals,…}`.
|
||||
Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,pets,moderators,posts,reports,ads,referrals,curator,…}`. The dashboard has a collapsible "Tierarten" card (`SpeciesDistributionCard`, `petAdmin.speciesDistribution`: pets per species incl. empty species and per breed, curator pet excluded). The "Tiere" page (`pets`, router `pet-admin.ts`) manages single pets: verified/founding toggles, clearing profile content, deleting a pet (reason is sent to the owner as a team DM on another of their pets, or by e-mail if it was their only pet) and the owner's referral ranks.
|
||||
|
||||
**Never delete a pet with a bare `prisma.pet.delete`** — use `deletePetCompletely()` (`src/lib/delete-pet.ts`). `StoryView` (D-08), `Report.reporter` and `Repost.originalPost` have no cascade and would block the delete, and the denormalized counters on *other* pets' posts/stories must drop by this pet's paws/comments/paw-backs. `pets.delete`, `deleteOwnerAccount` and the admin delete all go through it.
|
||||
|
||||
### Download redirect `/app`
|
||||
|
||||
`src/app/app/route.ts` (public in `proxy.ts`) is the target for flyers/QR codes: Android → Play Store with sanitized `utm_*` passed as Play `referrer`, iOS → `APP_STORE_URL` in `src/lib/app-links.ts` once set (still `null` until the iOS release; until then the web start page), everything else → web start page; `?ref=<code>` → `/r/<code>`. Logic is pure in `src/lib/app-redirect.ts`; clicks are counted per day/source/platform in Redis (`appredirect:<date>` hashes, `src/lib/app-redirect-stats.ts`) and shown in the admin referral overview. Redis on the NAS is the `pawfeed-redis` container (not `glitchtip-redis`), password in `docker/.env` `REDIS_PASSWORD`.
|
||||
|
||||
### New-pet welcome & founding members
|
||||
|
||||
`pets.create` fires `welcomeNewPet()` (`src/lib/new-pet-welcome.ts`) in the background — it must never block pet creation. It (1) sets `Pet.isFoundingMember` via one guarded SQL update for the first 250 pets by `createdAt` (never revoked; badge = Sprout icon next to the name, web + mobile), (2) lets the curator pet "PawFeed Team" (`ensureTeamPet` in `src/lib/team-pet.ts`) follow the new pet, and (3) sends a welcome DM via `sendTeamMessage`. (2) and (3) are switchable and the DE/EN text (mandatory `{name}` placeholder) is editable in the admin panel under "Begrüßung" (`/p/[secret]/curator`, `CuratorSettings` id = 1, `curator-admin.ts`). Replies to the welcome DM land in the admin "Messages" team inbox.
|
||||
|
||||
### Referral program (Empfehlungsprogramm)
|
||||
|
||||
Registration is open; invites are a **Clerk-independent** referral layer on top. Every owner has one permanent, unlimited `Owner.referralCode` (e.g. `BUBI-7K3M`, prefix from the first pet's name — `src/lib/referral-code.ts`). A code is never part of the Clerk sign-up: it is redeemed **after** sign-up, in the onboarding step "Wie hast du von PawFeed erfahren?" (`AcquisitionStep`, web + mobile) or later on `/einladen` (mobile: `/invite`) within `ReferralSettings.redeemWindowDays`. `/r/[code]` is the public landing page (sets a `pf_ref` cookie only to prefill the web onboarding); old `/join?code=` links redirect there. Logic lives in `src/lib/referrals.ts`: redeem sets `Owner.invitedById` and makes both first pets follow each other; an invite **counts** (`referralQualifiedAt`, inviter's denormalized `qualifiedReferralCount` +1) only once the invitee has a pet **and** a first real post — `maybeQualifyReferral()` is called from `posts.create` and the Mux webhook on `READY`; a new post-creating mutation must call it too.
|
||||
|
||||
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
|
||||
Ranks (`ReferralRank`) are data, fully editable in the admin panel (`/p/[secret]/referrals`, `referral-admin.ts`); only the perk catalog (`ReferralPerk`, `IMPLEMENTED_REFERRAL_PERKS` in `src/lib/referral-ranks.ts`) needs code. Perks are cumulative. Reached ranks are **grandfathered** via `ReferralRankAchievement` — raising a threshold never removes a rank. An admin can revoke a rank permanently (`revokedAt` on the achievement row — the row stays so award/recompute never re-grant it; it is ignored for badges/perks until "Freigeben") or grant one manually (`grantedManually`). `Owner.referralRankId`/`referralPerks` are a cache maintained only by `src/lib/referrals.ts` and the set-based `recomputeReferralRanks()` (`src/lib/referral-recompute.ts`, run after every admin rank change); feed cards read them via `petCardSelect`. The legacy `InviteCode` table is history only — nothing writes to it anymore. One-time setup after the schema push: `npx tsx prisma/scripts/referral-setup.ts` (+ `prisma/enable_rls_referrals.sql`).
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -104,7 +114,7 @@ Despite `next.config.ts`'s `output: "standalone"` and the original stack researc
|
||||
|
||||
**Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26).** `docker/docker-compose.yml` defines `pawfeed-1`/`pawfeed-2`/`pawfeed-3` (via the `x-pawfeed-common` YAML anchor) instead of one `pawfeed` service — Next.js standalone output is single-process (`node server.js`), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container `nginx-app-1` on this NAS, admin UI at `192.168.1.222:81`) load-balances across them via a `least_conn` upstream block — that block lives in `/Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf` on the NAS host (an nginx `upstream {}` is only valid in the `http{}` context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the `server{}`/`location{}` context, and holds the `proxy_pass http://pawfeed_upstream;` override for the `pawfeed.org` Proxy Host instead). Full runbook, decisions, and risk notes: `docker/SCALING-ROADMAP.md`. **Deploying a config or code change means rebuilding all 3 replicas** — there is no separate single-container path anymore, and NPM's proxy target is `pawfeed_upstream`, not a bare `pawfeed:3000` hostname. Use `./docker/rolling-deploy.sh` for deploys against the live site — it rebuilds the shared `pawfeed:latest` image once, then restarts `pawfeed-1`/`2`/`3` one at a time, gated on each becoming `healthy` per the Docker `healthcheck` (`GET /api/health`, a dependency-free liveness check — see `src/app/api/health/route.ts`) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). `docker compose up -d --build` (`start.sh`) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. `src/lib/prisma.ts`'s Prisma pg pool size is capped per-replica via `DB_POOL_MAX` (compose sets `10`) so 3 replicas stay well under the Supabase pooler's client-connection limit.
|
||||
|
||||
`src/app/api/cron/trim-feeds/route.ts` trims each pet's Redis feed sorted set to the 90-day retention window and expects a `CRON_SECRET` bearer token. `vercel.json`'s `crons` array that points at it is **inert** on this self-hosted deployment — nothing calls this route yet. It needs an external trigger (e.g. a NAS-side cron job hitting the route with the secret header) before it actually runs.
|
||||
**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), not from `vercel.json` (whose `crons` array is inert here). Each entry calls `docker/run-cron.sh /api/cron/<name>`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise.
|
||||
|
||||
### Directories that are not app code
|
||||
|
||||
|
||||
+32
-10
@@ -116,7 +116,8 @@
|
||||
"zoom": "Zoom",
|
||||
"cropCancel": "Abbrechen",
|
||||
"cropSave": "Übernehmen",
|
||||
"cropSaving": "Wird verarbeitet…"
|
||||
"cropSaving": "Wird verarbeitet…",
|
||||
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||
},
|
||||
"Onboarding": {
|
||||
"addFirstPet": "Füge dein erstes Tier hinzu",
|
||||
@@ -259,7 +260,8 @@
|
||||
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
|
||||
"deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"countSingular": "1 Kommentar",
|
||||
"countPlural": "{count} Kommentare"
|
||||
"countPlural": "{count} Kommentare",
|
||||
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben."
|
||||
},
|
||||
"RelativeTime": {
|
||||
"justNow": "gerade eben",
|
||||
@@ -480,7 +482,11 @@
|
||||
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag.",
|
||||
"typePetSuggestion": "könnte dir gefallen",
|
||||
"typeReferralJoined": "ist über deine Einladung zu PawFeed gekommen",
|
||||
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!"
|
||||
"typeReferralRankUp": "Du hast einen neuen Empfehlungsrang erreicht. Schau ihn dir an!",
|
||||
"typeVaccineDue": "💉 Die Impfung „{name}“ ist am {date} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||
"typeStoryMention": "hat dich in einer Story markiert.",
|
||||
"typeStoryComment": "hat deine Story kommentiert.",
|
||||
"typeStoryReaction": "hat deiner Story eine Pfote gegeben."
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
@@ -510,7 +516,8 @@
|
||||
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
|
||||
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
|
||||
"musicLabel": "Musik hinzufügen (optional)",
|
||||
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen"
|
||||
"muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen",
|
||||
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||
},
|
||||
"MilestoneForm": {
|
||||
"questionLabel": "Was ist der Meilenstein?",
|
||||
@@ -530,7 +537,8 @@
|
||||
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
|
||||
"fileTooLarge": "Foto muss unter 10 MB sein.",
|
||||
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
|
||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut.",
|
||||
"heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
|
||||
},
|
||||
"MilestoneTypes": {
|
||||
"BIRTHDAY": "Geburtstag",
|
||||
@@ -578,12 +586,22 @@
|
||||
"mute": "Stummschalten",
|
||||
"unmute": "Ton einschalten",
|
||||
"videoError": "Video konnte nicht verarbeitet werden.",
|
||||
"openComments": "Kommentare öffnen"
|
||||
"openComments": "Kommentare öffnen",
|
||||
"deleteStory": "Story löschen",
|
||||
"deleteStoryTitle": "Story löschen?",
|
||||
"deleteStoryDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||
"deleteStoryCancel": "Abbrechen",
|
||||
"deleteStoryConfirm": "Löschen",
|
||||
"storyDeleted": "Story gelöscht",
|
||||
"storyDeleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
|
||||
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
|
||||
"pawCount": "{count, plural, one {# Pfote} other {# Pfoten}}"
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Storys",
|
||||
"addStory": "Story hinzufügen",
|
||||
"newStoryTitle": "Neue Story"
|
||||
"newStoryTitle": "Neue Story",
|
||||
"yourStory": "Deine Story"
|
||||
},
|
||||
"RepostCard": {
|
||||
"pawedBackBy": "Paw-Back von"
|
||||
@@ -654,9 +672,9 @@
|
||||
"onlyMe": "Nur ich",
|
||||
"followerVisibilitySaved": "Follower-Sichtbarkeit gespeichert",
|
||||
"followerVisibilitySaveFailed": "Follower-Sichtbarkeit konnte nicht gespeichert werden",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen.",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {name} sehen und {name} in Storys markieren.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {name} folgen, können die Follower- und Folgt-Listen sehen und {name} in Storys markieren.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {name} sehen — markieren in Storys kann {name} niemand.",
|
||||
"verificationHeading": "Verifizierung",
|
||||
"verifiedStatus": "Dieses Tier ist verifiziert.",
|
||||
"pendingStatus": "Dein Verifizierungsantrag wird noch geprüft.",
|
||||
@@ -836,5 +854,9 @@
|
||||
"codeLabel": "Einladungscode",
|
||||
"codeHint": "Gib den Code nach der Registrierung im Onboarding ein, dann seid ihr direkt verbunden.",
|
||||
"webSignUp": "Im Browser registrieren"
|
||||
},
|
||||
"FoundingMember": {
|
||||
"label": "Gründungsmitglied",
|
||||
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||
}
|
||||
}
|
||||
+32
-10
@@ -116,7 +116,8 @@
|
||||
"zoom": "Zoom",
|
||||
"cropCancel": "Cancel",
|
||||
"cropSave": "Apply",
|
||||
"cropSaving": "Processing…"
|
||||
"cropSaving": "Processing…",
|
||||
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||
},
|
||||
"Onboarding": {
|
||||
"addFirstPet": "Add your first pet",
|
||||
@@ -259,7 +260,8 @@
|
||||
"postError": "Couldn't post comment. Please try again.",
|
||||
"deleteError": "Couldn't delete comment. Please try again.",
|
||||
"countSingular": "1 comment",
|
||||
"countPlural": "{count} comments"
|
||||
"countPlural": "{count} comments",
|
||||
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones."
|
||||
},
|
||||
"RelativeTime": {
|
||||
"justNow": "now",
|
||||
@@ -480,7 +482,11 @@
|
||||
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary.",
|
||||
"typePetSuggestion": "might be a good match",
|
||||
"typeReferralJoined": "joined PawFeed through your invite",
|
||||
"typeReferralRankUp": "You reached a new referral rank. Take a look!"
|
||||
"typeReferralRankUp": "You reached a new referral rank. Take a look!",
|
||||
"typeVaccineDue": "💉 The \"{name}\" vaccination is due on {date} – time to book an appointment.",
|
||||
"typeStoryMention": "tagged you in a story.",
|
||||
"typeStoryComment": "commented on your story.",
|
||||
"typeStoryReaction": "gave your story a paw."
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
@@ -510,7 +516,8 @@
|
||||
"videoTooLong": "Story videos can be at most {max} seconds long.",
|
||||
"videoUnreadable": "Couldn't read the video length — please try a different file.",
|
||||
"musicLabel": "Add music (optional)",
|
||||
"muteOriginalLabel": "Mute original audio, play this track instead"
|
||||
"muteOriginalLabel": "Mute original audio, play this track instead",
|
||||
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||
},
|
||||
"MilestoneForm": {
|
||||
"questionLabel": "What's the milestone?",
|
||||
@@ -530,7 +537,8 @@
|
||||
"errorToast": "Couldn't share this milestone. Please try again.",
|
||||
"fileTooLarge": "Photo must be under 10 MB.",
|
||||
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
||||
"uploadFailed": "Upload failed — please try again."
|
||||
"uploadFailed": "Upload failed — please try again.",
|
||||
"heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
|
||||
},
|
||||
"MilestoneTypes": {
|
||||
"BIRTHDAY": "Birthday",
|
||||
@@ -578,12 +586,22 @@
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"videoError": "Video could not be processed.",
|
||||
"openComments": "Open comments"
|
||||
"openComments": "Open comments",
|
||||
"deleteStory": "Delete story",
|
||||
"deleteStoryTitle": "Delete story?",
|
||||
"deleteStoryDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||
"deleteStoryCancel": "Cancel",
|
||||
"deleteStoryConfirm": "Delete",
|
||||
"storyDeleted": "Story deleted",
|
||||
"storyDeleteError": "The story could not be deleted. Please try again.",
|
||||
"expiredOwnerOnly": "Expired – only visible to you",
|
||||
"pawCount": "{count, plural, one {# paw} other {# paws}}"
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Stories",
|
||||
"addStory": "Add story",
|
||||
"newStoryTitle": "New Story"
|
||||
"newStoryTitle": "New Story",
|
||||
"yourStory": "Your story"
|
||||
},
|
||||
"RepostCard": {
|
||||
"pawedBackBy": "Pawed-back by"
|
||||
@@ -654,9 +672,9 @@
|
||||
"onlyMe": "Only me",
|
||||
"followerVisibilitySaved": "Follower privacy setting saved",
|
||||
"followerVisibilitySaveFailed": "Failed to save follower privacy setting",
|
||||
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists.",
|
||||
"followerNobodyHint": "Only you can see {name}'s followers and following lists.",
|
||||
"followerEveryoneHint": "Anyone can see {name}'s followers and following lists and tag {name} in stories.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {name} can see the followers and following lists and tag {name} in stories.",
|
||||
"followerNobodyHint": "Only you can see {name}'s followers and following lists — nobody can tag {name} in stories.",
|
||||
"verificationHeading": "Verification",
|
||||
"verifiedStatus": "This pet is verified.",
|
||||
"pendingStatus": "Your verification request is pending review.",
|
||||
@@ -836,5 +854,9 @@
|
||||
"codeLabel": "Invite code",
|
||||
"codeHint": "Enter the code during onboarding after signing up and you are connected right away.",
|
||||
"webSignUp": "Sign up in the browser"
|
||||
},
|
||||
"FoundingMember": {
|
||||
"label": "Founding member",
|
||||
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||
}
|
||||
}
|
||||
@@ -152,13 +152,28 @@ function SettingsHeaderButton() {
|
||||
);
|
||||
}
|
||||
|
||||
// found.md: a push for a new message arrived but the speech bubble showed
|
||||
// nothing. Polling keeps the badge current while the app stays open; reading
|
||||
// a conversation invalidates conversations.* (see messages/[conversationId]).
|
||||
const UNREAD_MESSAGES_POLL_MS = 30 * 1000;
|
||||
|
||||
function MessagesHeaderButton() {
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const { data } = useQuery({
|
||||
...trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
refetchInterval: UNREAD_MESSAGES_POLL_MS,
|
||||
});
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/messages")} hitSlop={8} style={styles.headerButton}>
|
||||
<MessageCircle color={colors.ink} size={22} />
|
||||
<View>
|
||||
<MessageCircle color={colors.ink} size={22} />
|
||||
<UnreadBadge count={data?.count ?? 0} styles={styles} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -172,16 +187,20 @@ function NotificationsTabIcon({ color, size }: { color: ColorValue; size: number
|
||||
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
});
|
||||
const count = data?.count ?? 0;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Bell color={color} size={size} />
|
||||
{count > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
|
||||
</View>
|
||||
)}
|
||||
<UnreadBadge count={data?.count ?? 0} styles={styles} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadBadge({ count, styles }: { count: number; styles: ReturnType<typeof makeStyles> }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{count > 9 ? "9+" : count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function ExplorePetCard({ pet }: { pet: ExplorePet }) {
|
||||
return (
|
||||
<Pressable style={styles.cardPress} onPress={() => router.push(`/pets/${pet.id}`)}>
|
||||
<Card style={styles.card}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{formatPetName(pet)}
|
||||
</Text>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useActivePet } from "../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../src/context/ThemeContext";
|
||||
import { PetAvatar } from "../../../src/components/PetAvatar";
|
||||
import { formatPetName, formatRelativeTime } from "../../../src/lib/format";
|
||||
import { isoToDisplayDate } from "../../../src/lib/date-format";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens";
|
||||
import type { NotificationItem } from "../../../src/api/types";
|
||||
|
||||
@@ -21,6 +22,9 @@ const ACTOR_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
PAW_BACK: "notifications.pawBack",
|
||||
PET_SUGGESTION: "notifications.petSuggestion",
|
||||
REFERRAL_JOINED: "notifications.referralJoined",
|
||||
STORY_MENTION: "notifications.storyMention",
|
||||
STORY_COMMENT: "notifications.storyComment",
|
||||
STORY_REACTION: "notifications.storyReaction",
|
||||
};
|
||||
|
||||
// BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the
|
||||
@@ -37,6 +41,11 @@ const SELF_MESSAGE_KEYS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
// independently of the row's own tap target, ported from web's
|
||||
// notifications/page.tsx (name as a <Link>, rest as plain text).
|
||||
function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
||||
if (item.type === "VACCINE_DUE") {
|
||||
if (!item.vaccine?.nextDueDate) return t("notifications.vaccineDueGeneric");
|
||||
const iso = new Date(item.vaccine.nextDueDate).toISOString().slice(0, 10);
|
||||
return t("notifications.vaccineDue", { name: item.vaccine.name, date: isoToDisplayDate(iso) });
|
||||
}
|
||||
const selfKey = SELF_MESSAGE_KEYS[item.type];
|
||||
if (selfKey) return t(selfKey);
|
||||
const actorKey = ACTOR_MESSAGE_KEYS[item.type];
|
||||
@@ -44,6 +53,11 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
||||
return item.type;
|
||||
}
|
||||
|
||||
const SELF_ICONS: Partial<Record<NotificationItem["type"], string>> = {
|
||||
BIRTHDAY: "🎂",
|
||||
VACCINE_DUE: "💉",
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's notifications/page.tsx getHref()/getPostId(): FOLLOW goes to
|
||||
* the actor's profile, BIRTHDAY/ADOPTION_DAY to this pet's own health page
|
||||
@@ -55,6 +69,21 @@ function getNotificationSuffix(item: NotificationItem, t: TFunction): string {
|
||||
* there so this doesn't try to be smarter about it).
|
||||
*/
|
||||
function getNotificationRoute(item: NotificationItem): string | null {
|
||||
// Story-Mitteilungen: Story öffnen, solange sie läuft (der Besitzer sieht
|
||||
// eigene Storys 7 Tage), sonst das Tierprofil.
|
||||
if (item.story && (item.type === "STORY_MENTION" || item.type === "STORY_COMMENT" || item.type === "STORY_REACTION")) {
|
||||
const isOwnStory = item.story.petId === item.recipientPetId;
|
||||
const isRunning = new Date(item.story.expiresAt).getTime() > Date.now();
|
||||
if (isRunning || isOwnStory) {
|
||||
const params = new URLSearchParams({
|
||||
petId: item.story.petId,
|
||||
petName: item.story.pet.name,
|
||||
petAvatarKey: item.story.pet.avatarKey ?? "",
|
||||
});
|
||||
return `/story-viewer?${params.toString()}`;
|
||||
}
|
||||
return `/pets/${item.story.petId}`;
|
||||
}
|
||||
switch (item.type) {
|
||||
case "FOLLOW":
|
||||
case "PET_SUGGESTION":
|
||||
@@ -64,6 +93,7 @@ function getNotificationRoute(item: NotificationItem): string | null {
|
||||
return "/invite";
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
return item.recipientPetId ? `/pets/${item.recipientPetId}/health` : null;
|
||||
case "REACTION":
|
||||
case "COMMENT":
|
||||
@@ -157,10 +187,10 @@ export default function NotificationsScreen() {
|
||||
}}
|
||||
>
|
||||
{item.actorPet ? (
|
||||
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} />
|
||||
<PetAvatar name={item.actorPet.name} avatarKey={item.actorPet.avatarKey} owner={item.actorPet.owner} />
|
||||
) : (
|
||||
<View style={styles.selfIcon}>
|
||||
<Text style={styles.selfIconText}>{item.type === "BIRTHDAY" ? "🎂" : "🏠"}</Text>
|
||||
<Text style={styles.selfIconText}>{SELF_ICONS[item.type] ?? "🏠"}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.rowText}>
|
||||
|
||||
@@ -80,7 +80,7 @@ function PetRow({
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.row}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" owner={pet.owner} />
|
||||
<View style={styles.rowText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
|
||||
@@ -52,7 +52,11 @@ export default function InviteScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={[styles.content, bottomPadding]}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.content, bottomPadding]}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.subtitle}>{t("referrals.subtitle")}</Text>
|
||||
|
||||
{!data.programActive && <Text style={styles.notice}>{t("referrals.programPaused")}</Text>}
|
||||
|
||||
@@ -66,7 +66,7 @@ function ConversationRow({ conversation, onPress }: { conversation: Conversation
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<Pressable style={styles.row} onPress={onPress}>
|
||||
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} />
|
||||
<PetAvatar name={conversation.otherPet.name} avatarKey={conversation.otherPet.avatarKey} owner={conversation.otherPet.owner} />
|
||||
<View style={styles.rowText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(conversation.otherPet)}</Text>
|
||||
|
||||
@@ -88,7 +88,11 @@ export default function NewMilestoneScreen() {
|
||||
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.label}>{t("milestones.typeLabel")}</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{USER_INITIATED_MILESTONE_TYPES.map((type) => {
|
||||
|
||||
@@ -8,8 +8,10 @@ import { useTRPC } from "../../../../src/api/trpc";
|
||||
import { useActivePet } from "../../../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../../../src/context/ThemeContext";
|
||||
import { TextField } from "../../../../src/components/TextField";
|
||||
import { DateField } from "../../../../src/components/DateField";
|
||||
import { Button } from "../../../../src/components/Button";
|
||||
import { BreedPicker } from "../../../../src/components/pet/BreedPicker";
|
||||
import { PrivacySettings } from "../../../../src/components/pet/PrivacySettings";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../../../src/theme/tokens";
|
||||
import { displayDateToIso, isoToDisplayDate } from "../../../../src/lib/date-format";
|
||||
import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding";
|
||||
@@ -17,7 +19,8 @@ import { useBottomSafePadding } from "../../../../src/hooks/useBottomSafePadding
|
||||
/**
|
||||
* Edit form (name, nickname, breed, bio, adoption story, birthday, adoption
|
||||
* date) — a subset of web's src/app/(app)/pets/[petId]/edit/page.tsx.
|
||||
* DM-policy/follower-visibility/avatar upload aren't editable here yet.
|
||||
* Privacy (DM policy, follower visibility) lives in PrivacySettings and saves
|
||||
* on its own; avatar upload isn't editable here yet.
|
||||
* Species itself stays fixed (only its breed is re-selectable) — found.md
|
||||
* asked specifically for breed correction, not a full species change.
|
||||
*/
|
||||
@@ -125,6 +128,8 @@ export default function EditPetScreen() {
|
||||
data={EMPTY_DATA}
|
||||
renderItem={null}
|
||||
contentContainerStyle={[styles.container, bottomPadding]}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<Text style={styles.label}>{t("editPet.nameLabel")}</Text>
|
||||
@@ -156,15 +161,22 @@ export default function EditPetScreen() {
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>{t("editPet.birthdayLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={birthday} onChangeText={setBirthday} />
|
||||
|
||||
<Text style={styles.label}>{t("editPet.adoptedAtLabel")}</Text>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={adoptedAt} onChangeText={setAdoptedAt} />
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button title={t("common.save")} disabled={updateMutation.isPending} onPress={handleSave} />
|
||||
|
||||
<PrivacySettings
|
||||
petId={pet.id}
|
||||
petName={pet.name}
|
||||
dmPolicy={pet.dmPolicy}
|
||||
followerVisibility={pet.followerVisibility}
|
||||
/>
|
||||
|
||||
<Pressable style={styles.deleteRow} onPress={confirmDelete} disabled={deleteMutation.isPending}>
|
||||
<Trash2 color={colors.alertRed} size={18} />
|
||||
<Text style={styles.deleteText}>{t("editPet.deletePet")}</Text>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function FollowersScreen() {
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.follower.id}`)}>
|
||||
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} />
|
||||
<PetAvatar name={item.follower.name} avatarKey={item.follower.avatarKey} owner={item.follower.owner} />
|
||||
<Text style={typography.body}>{formatPetName(item.follower)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function FollowingScreen() {
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => router.push(`/pets/${item.followee.id}`)}>
|
||||
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} />
|
||||
<PetAvatar name={item.followee.name} avatarKey={item.followee.avatarKey} owner={item.followee.owner} />
|
||||
<Text style={typography.body}>{formatPetName(item.followee)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,11 @@ export default function PetHealthScreen() {
|
||||
const bottomPadding = useBottomSafePadding();
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={[styles.container, bottomPadding]}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, bottomPadding]}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<HealthConsentGate petId={petId}>
|
||||
<WeightLogSection petId={petId} />
|
||||
<VetVisitSection petId={petId} />
|
||||
|
||||
@@ -145,8 +145,9 @@ export default function PetProfileScreen() {
|
||||
</ReferralAvatarRing>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(pet)}</Text>
|
||||
<PetIdentityBadges isVerified={pet.isVerified} sponsor={pet.owner} referral={pet.owner} />
|
||||
<PetIdentityBadges isVerified={pet.isVerified} isFoundingMember={pet.isFoundingMember} sponsor={pet.owner} referral={pet.owner} />
|
||||
</View>
|
||||
{pet.isFoundingMember && <Text style={styles.foundingHint}>{t("foundingMember.tooltip")}</Text>}
|
||||
<Text style={styles.species}>{pet.species.name}</Text>
|
||||
{pet.invitedByPet && (
|
||||
<Text style={styles.invitedBy}>
|
||||
@@ -228,6 +229,7 @@ function makeStyles(colors: ColorTokens) {
|
||||
name: { ...typography.title },
|
||||
species: { ...typography.body, color: colors.quietGray },
|
||||
invitedBy: { ...typography.body, fontSize: 12, color: colors.quietGray },
|
||||
foundingHint: { ...typography.body, fontSize: 12, color: "#059669" },
|
||||
invitedByName: { color: colors.ink, fontWeight: "600" },
|
||||
statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 },
|
||||
stat: { alignItems: "center" },
|
||||
|
||||
@@ -14,7 +14,10 @@ import { TextField } from "../../src/components/TextField";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
// HEIC/HEIF (iPhone, manche Android-Kameras) ist hier ausdrücklich erlaubt:
|
||||
// resizeForUpload() wandelt jedes Bild vor dem Upload in JPEG um, der Server
|
||||
// sieht also nie HEIC. Vorher wurden solche Fotos hier still aussortiert.
|
||||
const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
|
||||
const MAX_IMAGES = 10;
|
||||
|
||||
type PickedImage = {
|
||||
@@ -92,9 +95,13 @@ export default function NewPostScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const accepted = assets
|
||||
.filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
|
||||
.slice(0, available);
|
||||
const supported = assets.filter((asset) =>
|
||||
ALLOWED_MIME_TYPES.includes((asset.mimeType ?? "image/jpeg").toLowerCase())
|
||||
);
|
||||
if (supported.length < assets.length) {
|
||||
Alert.alert(t("post.unsupportedFormat"));
|
||||
}
|
||||
const accepted = supported.slice(0, available);
|
||||
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
@@ -155,7 +162,11 @@ export default function NewPostScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{images.length > 0 && (
|
||||
<View style={styles.grid}>
|
||||
{images.map((img, i) => (
|
||||
|
||||
@@ -154,7 +154,7 @@ function CommentRow({ comment, isOwn, onDelete, onPressPet }: CommentRowProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={onPressPet}>
|
||||
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" />
|
||||
<PetAvatar name={comment.pet.name} avatarKey={comment.pet.avatarKey} size="sm" owner={comment.pet.owner} />
|
||||
</Pressable>
|
||||
<View style={styles.rowBody}>
|
||||
<View style={styles.rowHeader}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FollowButton } from "../../src/components/FollowButton";
|
||||
import { getMediaUrl } from "../../src/lib/media-url";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
import { useBottomSafePadding } from "../../src/hooks/useBottomSafePadding";
|
||||
import type { ReferralOwnerFields } from "../../src/hooks/useReferralRanks";
|
||||
|
||||
type Tab = "pets" | "people" | "tags";
|
||||
type Styles = ReturnType<typeof makeStyles>;
|
||||
@@ -25,6 +26,7 @@ type SearchPet = {
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
breed: { name: string } | null;
|
||||
owner?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -207,7 +209,7 @@ function PetRow({
|
||||
return (
|
||||
<View style={[styles.petRow, indented && styles.petRowIndented]}>
|
||||
<Pressable style={styles.petRowMain} onPress={onPress}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" owner={pet.owner} />
|
||||
<View style={styles.petRowText}>
|
||||
<Text style={styles.petName}>{pet.name}</Text>
|
||||
<Text style={styles.petSpecies}>{pet.breed?.name ?? pet.species.name}</Text>
|
||||
|
||||
+213
-76
@@ -1,23 +1,32 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState, useCallback, useMemo, useLayoutEffect } from "react";
|
||||
import { View, Text, Image as RNImage, Pressable, StyleSheet, Alert } from "react-native";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera } from "lucide-react-native";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, Camera, Type } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { uploadImageToSupabase } from "../../src/lib/upload";
|
||||
import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload";
|
||||
import { resizeForUpload } from "../../src/lib/resize-image";
|
||||
import { Button } from "../../src/components/Button";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits";
|
||||
import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor";
|
||||
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
/** Echte Pixelmaße der (verkleinerten) Datei — der Picker meldet bei gedrehten Handyfotos teils vertauschte Werte. */
|
||||
function imageSize(uri: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => RNImage.getSize(uri, (width, height) => resolve({ width, height }), reject));
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo-only story creation (matches web's own "Phase 2" note in
|
||||
* stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload
|
||||
* flow web itself only added later, not carried over here). Single image,
|
||||
* unlike post-new.tsx's multi-image picker — a story is one photo.
|
||||
* Story erstellen: Foto, kurzes Video oder reine Text-Story (Farbverlauf),
|
||||
* danach der Story-Editor (Text, @-Markierungen, GIFs, Sticker — siehe
|
||||
* src/components/story-editor/). Overlays gehen als Daten mit, nicht ins Bild
|
||||
* gerechnet. Der Video-Pfad entspricht web's StoryForm.tsx: createVideoUpload
|
||||
* legt die (PROCESSING) Story an, die Bytes gehen direkt zu Mux. Ist der
|
||||
* Notschalter (Admin) aus, gibt es nur Vorschau + Teilen wie früher.
|
||||
*/
|
||||
export default function NewStoryScreen() {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,23 +35,34 @@ export default function NewStoryScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
|
||||
const [asset, setAsset] = useState<ImagePicker.ImagePickerAsset | null>(null);
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
const editorEnabled = flags?.storyEditor ?? false;
|
||||
|
||||
const [media, setMedia] = useState<EditorMedia | null>(null);
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Im Editor gehört der ganze Bildschirm der Story — Kopfzeile ausblenden.
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({ headerShown: media === null });
|
||||
}, [navigation, media]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
// Video-Upload in Prozent (0–100), null solange kein Video hochgeladen wird.
|
||||
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
|
||||
|
||||
const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions());
|
||||
const createMutation = useMutation(trpc.stories.create.mutationOptions());
|
||||
const createTextMutation = useMutation(trpc.stories.createText.mutationOptions());
|
||||
const createVideoUploadMutation = useMutation(trpc.stories.createVideoUpload.mutationOptions());
|
||||
|
||||
const createMutation = useMutation(
|
||||
trpc.stories.create.mutationOptions({
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
},
|
||||
onError: (err) => setError(err.message),
|
||||
})
|
||||
);
|
||||
const finish = useCallback(async () => {
|
||||
await queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
await queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
router.back();
|
||||
}, [queryClient, trpc, router]);
|
||||
|
||||
const pickFromGallery = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
@@ -50,9 +70,21 @@ export default function NewStoryScreen() {
|
||||
Alert.alert(t("onboarding.galleryPermissionDenied"));
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 });
|
||||
if (!result.canceled) setAsset(result.assets[0]);
|
||||
}, []);
|
||||
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 });
|
||||
if (result.canceled) return;
|
||||
|
||||
// expo-image-picker meldet `duration` (ms) nur bei Videos — verlässlicher als ein MIME-Typ.
|
||||
const picked = result.assets[0];
|
||||
if (picked.duration != null) {
|
||||
if (picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) {
|
||||
Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS }));
|
||||
return;
|
||||
}
|
||||
setMedia({ kind: "video", uri: picked.uri, width: picked.width, height: picked.height, mimeType: picked.mimeType ?? undefined });
|
||||
return;
|
||||
}
|
||||
setMedia({ kind: "image", uri: picked.uri, width: picked.width, height: picked.height });
|
||||
}, [t]);
|
||||
|
||||
const takePhoto = useCallback(async () => {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
@@ -61,65 +93,159 @@ export default function NewStoryScreen() {
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 0.8 });
|
||||
if (!result.canceled) setAsset(result.assets[0]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!asset || !activePetId) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const resized = await resizeForUpload(asset.uri, asset.width, asset.height);
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null });
|
||||
} catch {
|
||||
setError(t("onboarding.uploadError"));
|
||||
if (!result.canceled) {
|
||||
const photo = result.assets[0];
|
||||
setMedia({ kind: "image", uri: photo.uri, width: photo.width, height: photo.height });
|
||||
}
|
||||
}, [asset, activePetId, getPresignedUrlMutation, createMutation]);
|
||||
}, [t]);
|
||||
|
||||
const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending;
|
||||
const handleSubmit = useCallback(
|
||||
async ({ overlays, background }: EditorResult) => {
|
||||
if (!media || !activePetId) return;
|
||||
setError(null);
|
||||
const editorFields = overlays.length > 0 ? { overlays } : {};
|
||||
|
||||
try {
|
||||
if (media.kind === "text") {
|
||||
await createTextMutation.mutateAsync({ petId: activePetId, background: background ?? "sunset", overlays });
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (media.kind === "video") {
|
||||
const { uploadUrl } = await createVideoUploadMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
aiDisclosure: "NONE",
|
||||
...editorFields,
|
||||
...(media.width > 0 && media.height > 0 ? { mediaWidth: media.width, mediaHeight: media.height } : {}),
|
||||
});
|
||||
setIsUploading(true);
|
||||
setUploadPercent(0);
|
||||
await uploadFileToUrl(media.uri, uploadUrl ?? "", media.mimeType ?? "video/mp4", (fraction) => {
|
||||
const pct = Math.round(fraction * 100);
|
||||
setUploadPercent((prev) => (prev === pct ? prev : pct));
|
||||
});
|
||||
// Story existiert schon (PROCESSING) — story-viewer.tsx fragt den Status ab.
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
const resized = await resizeForUpload(media.uri, media.width, media.height);
|
||||
const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height }));
|
||||
const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
contentType: resized.mimeType,
|
||||
fileSize: resized.fileSize,
|
||||
});
|
||||
await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType);
|
||||
await createMutation.mutateAsync({
|
||||
petId: activePetId,
|
||||
storageKey: key,
|
||||
blurhash: null,
|
||||
...editorFields,
|
||||
mediaWidth: size.width,
|
||||
mediaHeight: size.height,
|
||||
});
|
||||
await finish();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t("onboarding.uploadError"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadPercent(null);
|
||||
}
|
||||
},
|
||||
[media, activePetId, createTextMutation, createVideoUploadMutation, getPresignedUrlMutation, createMutation, finish, t]
|
||||
);
|
||||
|
||||
const isSubmitting =
|
||||
isUploading ||
|
||||
getPresignedUrlMutation.isPending ||
|
||||
createMutation.isPending ||
|
||||
createTextMutation.isPending ||
|
||||
createVideoUploadMutation.isPending;
|
||||
|
||||
if (media && activePetId) {
|
||||
return (
|
||||
<StoryEditor
|
||||
media={media}
|
||||
petId={activePetId}
|
||||
toolsEnabled={editorEnabled}
|
||||
isSubmitting={isSubmitting}
|
||||
submitLabel={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
if (!isSubmitting) {
|
||||
setMedia(null);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
progress={
|
||||
<>
|
||||
{error ? <Text style={styles.errorOnDark}>{error}</Text> : null}
|
||||
{isSubmitting && media.kind === "video" ? <UploadProgress percent={uploadPercent} styles={styles} /> : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{asset ? (
|
||||
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
|
||||
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
|
||||
<Text style={styles.previewHint}>{t("story.changePhotoHint")}</Text>
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{editorEnabled && (
|
||||
<Pressable style={styles.textStoryButton} onPress={() => setMedia({ kind: "text" })}>
|
||||
<Type color={colors.pureWhite} size={22} />
|
||||
<Text style={styles.textStoryText}>{t("storyEditor.textStory")}</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.pickerRow}>
|
||||
<Pressable style={styles.pickerButton} onPress={pickFromGallery}>
|
||||
<ImagePlus color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.galleryButton")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.pickerButton} onPress={takePhoto}>
|
||||
<Camera color={colors.marigoldOrange} size={22} />
|
||||
<Text style={styles.pickerText}>{t("onboarding.cameraButton")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<Button
|
||||
title={isSubmitting ? t("milestones.sharing") : t("story.shareButton")}
|
||||
onPress={handleSubmit}
|
||||
disabled={!asset || isSubmitting}
|
||||
/>
|
||||
{isSubmitting && <ActivityIndicator color={colors.marigoldOrange} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
/**
|
||||
* Fortschritt beim Video-Upload zu Mux. `percent === null` = Upload wird noch
|
||||
* vorbereitet (createVideoUpload läuft), dann echter Fortschritt aus
|
||||
* expo-file-system `onProgress`.
|
||||
*/
|
||||
function UploadProgress({ percent, styles }: { percent: number | null; styles: ReturnType<typeof makeStyles> }) {
|
||||
const { t } = useTranslation();
|
||||
const label = percent === null ? t("story.preparingUpload") : t("story.uploadingVideo", { percent });
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.progressWrap}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityValue={{ min: 0, max: 100, now: percent ?? 0 }}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${percent ?? 0}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressLabel}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, bottomInset: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, padding: 16, gap: 12, backgroundColor: colors.porchWhite },
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
paddingBottom: Math.max(16, bottomInset + 12),
|
||||
gap: 12,
|
||||
backgroundColor: colors.porchWhite,
|
||||
},
|
||||
pickerRow: { flexDirection: "row", gap: 12 },
|
||||
pickerButton: {
|
||||
flex: 1,
|
||||
@@ -133,9 +259,20 @@ function makeStyles(colors: ColorTokens) {
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
pickerText: { ...typography.body, color: colors.ink },
|
||||
preview: { flex: 1, borderRadius: radii.md, overflow: "hidden", backgroundColor: colors.hairlineGray, gap: 8 },
|
||||
previewImage: { width: "100%", flex: 1 },
|
||||
previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", paddingVertical: 6 },
|
||||
error: { ...typography.body, color: colors.alertRed },
|
||||
textStoryButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
height: 48,
|
||||
borderRadius: radii.lg,
|
||||
backgroundColor: colors.marigoldOrange,
|
||||
},
|
||||
textStoryText: { ...typography.body, color: colors.pureWhite, fontFamily: typography.title.fontFamily },
|
||||
errorOnDark: { ...typography.body, color: "#f87171", textAlign: "center" },
|
||||
progressWrap: { gap: 6 },
|
||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: "rgba(255,255,255,0.2)", overflow: "hidden" },
|
||||
progressFill: { height: "100%", borderRadius: 3, backgroundColor: colors.marigoldOrange },
|
||||
progressLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", color: "#ffffff" },
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,37 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator, type LayoutChangeEvent } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { useEventListener } from "expo";
|
||||
import { AlertCircle, Clock, Eye, MessageCircle, PawPrint, Trash2, X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../src/api/trpc";
|
||||
import { useActivePet } from "../../src/context/ActivePetContext";
|
||||
import { PetAvatar } from "../../src/components/PetAvatar";
|
||||
import { StoryPawButton } from "../../src/components/stories/StoryPawButton";
|
||||
import { StoryCommentsSheet } from "../../src/components/stories/StoryCommentsSheet";
|
||||
import { StoryViewersSheet } from "../../src/components/stories/StoryViewersSheet";
|
||||
import { getMediaUrl } from "../../src/lib/media-url";
|
||||
import { StoryOverlayLayer } from "../../src/components/story-editor/StoryOverlayLayer";
|
||||
import { StoryBackground } from "../../src/components/story-editor/StoryBackground";
|
||||
import { containRect, storyMediaSize } from "../../src/lib/story-overlays";
|
||||
import { colors } from "../../src/theme/tokens";
|
||||
|
||||
const STORY_DURATION_MS = 5000;
|
||||
const IMAGE_STORY_DURATION_MS = 5000;
|
||||
|
||||
// "away" = über eine Markierung ins Tierprofil gewechselt — Story pausiert bis zur Rückkehr.
|
||||
type Sheet = "comments" | "viewers" | "confirm-delete" | "away" | null;
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/StoryViewer.tsx, scoped to photo
|
||||
* stories only (no Mux video story playback — see story-new.tsx). Also
|
||||
* drops the owner-only viewer-count/list Sheet and the paw-reaction/comment
|
||||
* overlay (Gitea #29 additions on web) — fast-follows, not core to "view a
|
||||
* story". Tap the left third to go back a story, the rest to skip ahead;
|
||||
* a fixed timer still auto-advances either way.
|
||||
* Port of web's src/components/stories/StoryViewer.tsx. Tap the left third
|
||||
* to go back a story, the rest to skip ahead. Foto-Storys laufen über einen
|
||||
* festen 5-s-Timer; Video-Storys folgen der echten Wiedergabe (expo-video
|
||||
* `timeUpdate`) und springen bei `playToEnd` weiter, wie im Web. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene
|
||||
* Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder
|
||||
* die Lösch-Rückfrage offen ist, hält der Timer an.
|
||||
*/
|
||||
export default function StoryViewerScreen() {
|
||||
const { petId, petName, petAvatarKey } = useLocalSearchParams<{
|
||||
@@ -27,26 +39,106 @@ export default function StoryViewerScreen() {
|
||||
petName: string;
|
||||
petAvatarKey?: string;
|
||||
}>();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOwnStory = activePetId === petId;
|
||||
|
||||
const { data: stories = [] } = useQuery(trpc.stories.listActive.queryOptions({ petId }));
|
||||
const listQuery = trpc.stories.listActive.queryOptions({ petId, viewerPetId: activePetId ?? undefined });
|
||||
const { data: stories = [], isSuccess: storiesLoaded } = useQuery(listQuery);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [openSheet, setOpenSheet] = useState<Sheet>(null);
|
||||
const advancedRef = useRef(false);
|
||||
const elapsedRef = useRef(0); // bereits abgelaufene Zeit der aktuellen Story (für Pause/Weiter)
|
||||
|
||||
const currentStory = stories[index];
|
||||
const isPaused = openSheet !== null;
|
||||
const isVideo = currentStory?.mediaType === "VIDEO";
|
||||
const isText = currentStory?.mediaType === "TEXT";
|
||||
const [screen, setScreen] = useState({ width: 0, height: 0 });
|
||||
const { data: flags } = useQuery({ ...trpc.stories.featureFlags.queryOptions(), staleTime: 60_000 });
|
||||
// Notschalter: Overlays nur zeigen, wenn der Story-Editor im Admin an ist.
|
||||
const showOverlays = flags?.storyEditor ?? false;
|
||||
|
||||
// Zurück aus dem Tierprofil (über eine Markierung) → Story läuft weiter.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setOpenSheet((sheet) => (sheet === "away" ? null : sheet));
|
||||
}, [])
|
||||
);
|
||||
const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions());
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.stories.delete.mutationOptions({
|
||||
onSuccess: () => {
|
||||
const remaining = stories.length - 1;
|
||||
setOpenSheet(null);
|
||||
void queryClient.invalidateQueries(trpc.stories.pathFilter());
|
||||
void queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
if (remaining <= 0) router.back();
|
||||
else setIndex((i) => Math.min(i, remaining - 1));
|
||||
},
|
||||
onError: () => {
|
||||
setOpenSheet(null);
|
||||
Alert.alert(t("storyViewer.deleteError"));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Story-ID beim Antippen festhalten: gelöscht wird genau die angezeigte Story.
|
||||
const confirmDelete = (storyId: string) => {
|
||||
setOpenSheet("confirm-delete");
|
||||
Alert.alert(
|
||||
t("storyViewer.deleteTitle"),
|
||||
t("storyViewer.deleteDescription"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel", onPress: () => setOpenSheet(null) },
|
||||
{ text: t("storyViewer.deleteConfirm"), style: "destructive", onPress: () => deleteMutation.mutate({ storyId }) },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => setOpenSheet(null) }
|
||||
);
|
||||
};
|
||||
|
||||
// Solange ein Video noch verarbeitet wird, Status abfragen (Self-Heal wie VideoPlayer.tsx).
|
||||
const { data: videoStatusData } = useQuery({
|
||||
...trpc.stories.getVideoStatus.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||
enabled: !!currentStory && isVideo && currentStory.videoStatus === "PROCESSING",
|
||||
refetchInterval: (query) => (query.state.data?.videoStatus === "PROCESSING" ? 3000 : false),
|
||||
});
|
||||
const videoStatus = videoStatusData?.videoStatus ?? currentStory?.videoStatus;
|
||||
const videoPlaybackId = videoStatusData?.muxPlaybackId ?? currentStory?.muxPlaybackId;
|
||||
|
||||
const { data: viewers = [] } = useQuery({
|
||||
...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }),
|
||||
enabled: isOwnStory && !!currentStory,
|
||||
});
|
||||
|
||||
const close = useCallback(() => {
|
||||
queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId }));
|
||||
queryClient.invalidateQueries(trpc.follows.pathFilter());
|
||||
router.back();
|
||||
}, [queryClient, trpc, petId, router]);
|
||||
|
||||
const openPet = useCallback(
|
||||
(targetPetId: string) => {
|
||||
setOpenSheet(null);
|
||||
router.push(`/pets/${targetPetId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const openMentionedPet = useCallback(
|
||||
(targetPetId: string) => {
|
||||
setOpenSheet("away");
|
||||
router.push(`/pets/${targetPetId}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
if (advancedRef.current) return;
|
||||
advancedRef.current = true;
|
||||
@@ -70,16 +162,25 @@ export default function StoryViewerScreen() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStory?.id, activePetId]);
|
||||
|
||||
// Fixed-duration progress bar, auto-advances/closes when it fills.
|
||||
// Neue Story → Fortschritt zurücksetzen.
|
||||
useEffect(() => {
|
||||
if (!currentStory) return;
|
||||
setProgress(0);
|
||||
elapsedRef.current = 0;
|
||||
advancedRef.current = false;
|
||||
const startTime = Date.now();
|
||||
setProgress(0);
|
||||
}, [currentStory?.id]);
|
||||
|
||||
// Fester Timer nur für Foto-Storys; pausiert, solange ein Sheet offen ist.
|
||||
// Video-Storys steuern Fortschritt und Weiterspringen selbst (ReadyVideoStory).
|
||||
useEffect(() => {
|
||||
if (!currentStory || isVideo || isPaused) return;
|
||||
const resumedAt = Date.now();
|
||||
const elapsedBefore = elapsedRef.current;
|
||||
let rafId: number;
|
||||
|
||||
function tick() {
|
||||
const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100);
|
||||
const elapsed = elapsedBefore + (Date.now() - resumedAt);
|
||||
elapsedRef.current = elapsed;
|
||||
const pct = Math.min((elapsed / IMAGE_STORY_DURATION_MS) * 100, 100);
|
||||
setProgress(pct);
|
||||
if (pct < 100) rafId = requestAnimationFrame(tick);
|
||||
else advance();
|
||||
@@ -87,14 +188,27 @@ export default function StoryViewerScreen() {
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [currentStory, advance]);
|
||||
}, [currentStory, isVideo, isPaused, advance]);
|
||||
|
||||
// Keine (laufende) Story mehr, z. B. über eine alte Mitteilung geöffnet → zurück statt schwarzem Bildschirm.
|
||||
useEffect(() => {
|
||||
if (storiesLoaded && stories.length === 0) router.back();
|
||||
}, [storiesLoaded, stories.length, router]);
|
||||
|
||||
if (!currentStory) return null;
|
||||
|
||||
const mediaUrl = currentStory.storageKey ? getMediaUrl(currentStory.storageKey) : null;
|
||||
const refreshStories = () => void queryClient.invalidateQueries({ queryKey: listQuery.queryKey });
|
||||
// Medienfläche wie bei contentFit="contain" — darauf beziehen sich die Overlays.
|
||||
const mediaSize = storyMediaSize(currentStory);
|
||||
const rect = mediaSize ? containRect(screen.width, screen.height, mediaSize.width, mediaSize.height) : null;
|
||||
const onLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setScreen((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.backdrop}>
|
||||
<View style={styles.backdrop} onLayout={onLayout}>
|
||||
<View style={[styles.progressRow, { top: insets.top + 12 }]}>
|
||||
{stories.map((s, i) => (
|
||||
<View key={s.id} style={styles.progressTrack}>
|
||||
@@ -108,28 +222,210 @@ export default function StoryViewerScreen() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />}
|
||||
{isVideo ? (
|
||||
<VideoStory
|
||||
key={currentStory.id}
|
||||
status={videoStatus}
|
||||
playbackId={videoPlaybackId}
|
||||
paused={isPaused}
|
||||
onProgress={setProgress}
|
||||
onEnded={advance}
|
||||
/>
|
||||
) : isText ? (
|
||||
rect && (
|
||||
<View style={{ position: "absolute", left: rect.x, top: rect.y }}>
|
||||
<StoryBackground backgroundKey={currentStory.background ?? "sunset"} width={rect.width} height={rect.height} />
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
|
||||
)}
|
||||
|
||||
<View style={styles.tapZones}>
|
||||
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
|
||||
<Pressable style={styles.tapZoneRight} onPress={advance} />
|
||||
</View>
|
||||
|
||||
{showOverlays && rect && (
|
||||
<StoryOverlayLayer
|
||||
overlays={currentStory.overlays}
|
||||
rect={rect}
|
||||
mentionedPets={currentStory.mentionedPets}
|
||||
onPressMention={openMentionedPet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[styles.petInfo, { bottom: insets.bottom + 24 }]}>
|
||||
<PetAvatar name={petName} avatarKey={petAvatarKey || null} size="sm" />
|
||||
<Text style={styles.petName}>{petName}</Text>
|
||||
</View>
|
||||
|
||||
{currentStory.isExpired && (
|
||||
<View style={[styles.expiredRow, { top: insets.top + 56 }]} pointerEvents="none">
|
||||
<View style={styles.expiredBadge}>
|
||||
<Clock size={14} color={colors.pureWhite} />
|
||||
<Text style={styles.expiredText}>{t("storyViewer.expiredOwnerOnly")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isOwnStory && (
|
||||
<View style={[styles.ownerActions, { top: insets.top + 8 }]}>
|
||||
<Pressable
|
||||
style={styles.viewerCount}
|
||||
onPress={() => setOpenSheet("viewers")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.viewersTitle", { count: viewers.length })}
|
||||
>
|
||||
<Eye size={16} color={colors.pureWhite} />
|
||||
<Text style={styles.viewerCountText}>{viewers.length}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.deleteButton}
|
||||
onPress={() => confirmDelete(currentStory.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.deleteStory")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{deleteMutation.isPending ? <ActivityIndicator color={colors.pureWhite} size="small" /> : <Trash2 size={16} color={colors.pureWhite} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{activePetId && (
|
||||
<View style={[styles.actions, { bottom: insets.bottom + 24 }]}>
|
||||
{currentStory.isExpired ? (
|
||||
<View style={styles.actionWrap} accessibilityLabel={t("storyViewer.pawCount", { count: currentStory.reactionCount })}>
|
||||
<View style={styles.actionCircle}>
|
||||
<PawPrint size={20} color={colors.pureWhite} />
|
||||
</View>
|
||||
{currentStory.reactionCount > 0 && <Text style={styles.actionCount}>{currentStory.reactionCount}</Text>}
|
||||
</View>
|
||||
) : (
|
||||
<StoryPawButton
|
||||
key={currentStory.id}
|
||||
storyId={currentStory.id}
|
||||
petId={activePetId}
|
||||
initialCount={currentStory.reactionCount}
|
||||
initialReacted={currentStory.reactedByViewer}
|
||||
/>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => setOpenSheet("comments")}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyViewer.openComments")}
|
||||
hitSlop={8}
|
||||
style={styles.actionWrap}
|
||||
>
|
||||
<View style={styles.actionCircle}>
|
||||
<MessageCircle size={20} color={colors.pureWhite} />
|
||||
</View>
|
||||
{currentStory.commentCount > 0 && <Text style={styles.actionCount}>{currentStory.commentCount}</Text>}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Pressable style={[styles.closeButton, { top: insets.top + 8 }]} onPress={close} hitSlop={12}>
|
||||
<X color={colors.pureWhite} size={22} />
|
||||
</Pressable>
|
||||
|
||||
{activePetId && (
|
||||
<StoryCommentsSheet
|
||||
storyId={currentStory.id}
|
||||
activePetId={activePetId}
|
||||
visible={openSheet === "comments"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
onChanged={refreshStories}
|
||||
onPressPet={openPet}
|
||||
readOnly={currentStory.isExpired}
|
||||
/>
|
||||
)}
|
||||
{isOwnStory && (
|
||||
<StoryViewersSheet
|
||||
storyId={currentStory.id}
|
||||
visible={openSheet === "viewers"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
onPressPet={openPet}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type VideoStatusValue = "PROCESSING" | "READY" | "ERROR";
|
||||
|
||||
interface VideoStoryProps {
|
||||
status: VideoStatusValue | null | undefined;
|
||||
playbackId: string | null | undefined;
|
||||
paused: boolean;
|
||||
onProgress: (pct: number) => void;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
/** Zeigt den Verarbeitungs-/Fehlerzustand; der Player wird erst bei READY gemountet (wie VideoPlayer.tsx). */
|
||||
function VideoStory({ status, playbackId, ...playerProps }: VideoStoryProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status === "READY" && playbackId) {
|
||||
return <ReadyVideoStory playbackId={playbackId} {...playerProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.statusBox}>
|
||||
{status === "ERROR" ? (
|
||||
<>
|
||||
<AlertCircle color={colors.alertRed} size={28} />
|
||||
<Text style={styles.statusLabel}>{t("video.error")}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActivityIndicator color={colors.marigoldOrange} />
|
||||
<Text style={styles.statusLabel}>{t("video.processing")}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt einmal ab (kein Loop) und springt bei `playToEnd` weiter.
|
||||
* `play()` erst bei `readyToPlay` — vorher ist `duration` 0 und `playToEnd`
|
||||
* feuerte sofort (Viewer schloss sich nach < 1 s, auf Emulator und Gerät
|
||||
* beobachtet). Offenes Sheet pausiert das Video wie den Foto-Timer.
|
||||
*/
|
||||
function ReadyVideoStory({ playbackId, paused, onProgress, onEnded }: Omit<VideoStoryProps, "status" | "playbackId"> & { playbackId: string }) {
|
||||
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
|
||||
p.loop = false;
|
||||
p.timeUpdateEventInterval = 0.25;
|
||||
});
|
||||
const pausedRef = useRef(paused);
|
||||
|
||||
useEffect(() => {
|
||||
pausedRef.current = paused;
|
||||
if (paused) player.pause();
|
||||
else if (player.status === "readyToPlay") player.play();
|
||||
}, [player, paused]);
|
||||
|
||||
useEventListener(player, "statusChange", ({ status }) => {
|
||||
if (status === "readyToPlay" && !pausedRef.current) player.play();
|
||||
});
|
||||
useEventListener(player, "timeUpdate", ({ currentTime }) => {
|
||||
if (player.duration > 0) onProgress((currentTime / player.duration) * 100);
|
||||
});
|
||||
useEventListener(player, "playToEnd", () => {
|
||||
onProgress(100);
|
||||
onEnded();
|
||||
});
|
||||
|
||||
return <VideoView player={player} style={styles.media} contentFit="contain" nativeControls={false} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: colors.ink },
|
||||
media: { flex: 1, width: "100%" },
|
||||
statusBox: { flex: 1, width: "100%", alignItems: "center", justifyContent: "center", gap: 8 },
|
||||
statusLabel: { color: colors.pureWhite, fontSize: 14 },
|
||||
tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" },
|
||||
tapZoneLeft: { flex: 1 },
|
||||
tapZoneRight: { flex: 2 },
|
||||
@@ -145,6 +441,33 @@ const styles = StyleSheet.create({
|
||||
zIndex: 2,
|
||||
},
|
||||
petName: { color: colors.pureWhite, fontWeight: "600", fontSize: 14 },
|
||||
actions: { position: "absolute", right: 16, alignItems: "center", gap: 16, zIndex: 2 },
|
||||
actionWrap: { alignItems: "center", gap: 2 },
|
||||
actionCircle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
actionCount: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||
expiredRow: { position: "absolute", left: 0, right: 0, alignItems: "center", zIndex: 2 },
|
||||
expiredBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
expiredText: { color: colors.pureWhite, fontSize: 12, fontWeight: "600" },
|
||||
ownerActions: { position: "absolute", left: 16, flexDirection: "row", alignItems: "center", gap: 8, zIndex: 2 },
|
||||
deleteButton: { width: 36, height: 36, borderRadius: 18, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
viewerCount: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
},
|
||||
viewerCountText: { color: colors.pureWhite, fontSize: 13, fontWeight: "600" },
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
|
||||
@@ -79,6 +79,8 @@ export default function OnboardingPetScreen() {
|
||||
data={EMPTY_DATA}
|
||||
renderItem={null}
|
||||
contentContainerStyle={{ flexGrow: 1, backgroundColor: colors.porchWhite }}
|
||||
automaticallyAdjustKeyboardInsets
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={<>{content}</>}
|
||||
/>
|
||||
);
|
||||
|
||||
+52
-1
@@ -22,8 +22,9 @@ TODO Neu2:
|
||||
→ Zurück-Pfeil war nur unsichtbar (schwarz auf schwarzem Header im Dark Mode), jetzt auf allen Unterseiten sichtbar. Speicherproblem lag am Datumsformat (nächster Punkt) plus fehlender Fehlermeldung bei ungültiger Eingabe — beides behoben, End-to-End getestet.
|
||||
- [x] Datum wird Korrekt dargestellt, die eingabe ist aber, aktuelle ansicht JJJJ-MM-DD // korrekt wäre DD-MM-JJJJ
|
||||
→ Eingabefelder akzeptieren jetzt TT-MM-JJJJ, werden intern korrekt umgewandelt; ungültige Eingaben zeigen eine Fehlermeldung.
|
||||
- [ ] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
|
||||
- [x] Gewichtsdaten werden nicht als Diagramm angezeigt (feature)
|
||||
→ Echtes Feature, kein Bug — noch offen, Rückmeldung ob/wann gewünscht.
|
||||
→ **Umgesetzt (04.10.):** Neues `WeightChart` (`src/components/health/WeightChart.tsx`, Port des Web-Diagramms mit `react-native-svg`) erscheint ab 2 Einträgen über der Gewichtsliste. Die x-Achse ist zeitproportional (unregelmäßige Wiegetermine werden nicht verzerrt), die y-Achse hat runde Werte, Datumslabels überlappen nicht (bei über einem Jahr „Feb. 25“). Antippen wählt die nächste Messung, Wert + Datum stehen im Kopf; rechts die Veränderung seit der ersten Messung. Linienfarbe als neues Token `chartLine` (#ea580c), mit dem Dataviz-Validator gegen helle und dunkle Flächen geprüft (Kontrast ≥ 3:1). Geometrie in `src/lib/weight-chart.ts` mit 11 Tests; die Darstellung habe ich als Vorschau mit der echten Geometrie in Hell + Dunkel gerendert und geprüft. Commit `e60abba`, OTA Runtime 1.0.6: Android `16d95168-caf2-4c0d-a0c7-730acd1bfb96`, iOS `d8e82f19-2a66-4f21-9558-b16296a7ba3f`. Nicht auf einem Gerät getestet.
|
||||
- [x] Technische Frage, was wird beim Feed abgerufen? Zum vergleich in der Web umgebung wird mein feed(followerbezogen) und der trend (könnte dir auch gefallen) seperat eingeblendet, wird in der App eine kombination daraus gemacht ? falls ja super.
|
||||
→ Nein: Mobile Feed zeigt ausschließlich den chronologischen Follower-Feed (feed.getFeed, mode "chrono"). Web's separater "Trending"-Tab (explore.getTrending, "könnte dir auch gefallen") ist in der App noch gar nicht gebaut — bekannte Lücke, bereits in der README dokumentiert.
|
||||
- [x] unter meine Tiere, bitte Impressum datenschutz und agbs als link einbinden. sowie eine Versionsnummer der App
|
||||
@@ -113,6 +114,7 @@ ErrorRecovery: exception encountered: Error: Missing required env var EXPO_PUBLI
|
||||
Neu 7:
|
||||
- [ ] Datums eingabe unüblich mit - getrennt statt mit . ansicht am Handy wird meist durch die tastatur überblendet. können wir für die mobile eine data wheel picker nutzen ?
|
||||
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
|
||||
→ **Trennzeichen erledigt (04.10.)**, siehe iOS-Abschnitt unten (`DateField`, TT.MM.JJJJ). Wheel-Picker weiter offen (nativer Build).
|
||||
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
|
||||
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
|
||||
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
||||
@@ -139,10 +141,59 @@ Neu 7:
|
||||
**Stolperstein beim Publish:** Das lokale (gitignorete) `mobile/android/`-Verzeichnis vom Device-Test der letzten Session ließ `eas update` das Projekt fälschlich als "Bare Workflow" erkennen (`CommandError: runtime version policies are not supported` statt der konfigurierten `"policy": "appVersion"`) — Fix war, `android/` kurz umzubenennen/wegzuschieben, Update zu publishen, danach zurückzubenennen. Für künftige OTA-Publishes nach einem lokalen `expo run:android`-Test: dasselbe Problem erwarten.
|
||||
- [x] Im Pet-Profil ist der Erste Post nicht Klickbar da er in die Gesten-Steuerung rein ragt.
|
||||
→ Ursache: Android läuft edge-to-edge, und die Stack-Screens (ohne Tab-Leiste darunter) hatten unten keinen Safe-Area-Abstand — der letzte Inhalt lag unter der Gesten-Leiste. Fix: neuer Hook `src/hooks/useBottomSafePadding.ts` (paddingBottom = 16 + insets.bottom), eingebaut in Tierprofil, Follower, Folgt, Gesundheit, Meilensteine, Profil bearbeiten, Einstellungen, Suche, Nachrichten-Liste und Konto löschen. Commit `de237f1`, per OTA für Android veröffentlicht (Update-Group `4dc6f797`), noch nicht am Gerät bestätigt. Gilt automatisch auch für iOS (Home-Indicator).
|
||||
- [x] Nutzer-Feedback: Nach dem Posten einer Story erscheint man selbst nicht als Ring — man kann die eigene Story nicht ansehen.
|
||||
→ Ursache (Web **und** App gleich): Die Story-Leiste zeigt nur die „+“-Kachel und Storys gefolgter Tiere (`follows.getPetsWithActiveStories`); das eigene aktive Tier ist darin nie enthalten. Fix: zusätzlicher Ring „Deine Story“ direkt nach der „+“-Kachel, solange das aktive Tier eine aktive Story hat (`stories.hasActiveStory`, wird nach dem Posten schon invalidiert). Web: `src/components/stories/StoryTray.tsx` (öffnet den StoryViewer — dort sieht der Owner auch die Zuschauerliste); App: `src/components/StoryTray.tsx`. Commit `204d5f6`, live (Web + OTA Android `58e99a29`, iOS `2aaa3975`). Typecheck/Tests/Build grün; die 2 Lint-Fehler „Cannot access refs during render“ in der Web-StoryTray bestanden schon vorher (horizontales Scrollen).
|
||||
**Story-Kommentare:** Backend (`stories.addComment/listComments/deleteComment`, `toggleReaction`, `getViewers`) und Web-UI (`StoryCommentsOverlay`) existieren bereits — nur der App-Viewer (`app/(app)/story-viewer.tsx`) hat Kommentare, Reaktionen und Zuschauerliste beim Portieren bewusst ausgelassen. Offen als eigener Punkt.
|
||||
- [x] Story-Kommentare, Pfoten-Reaktionen und Zuschauerliste in der App (Folge-Punkt zum Story-Feedback).
|
||||
→ Neuer App-Viewer (`app/(app)/story-viewer.tsx`) mit Pfote (`src/components/stories/StoryPawButton.tsx`), Kommentar-Bottom-Sheet (`StoryCommentsSheet.tsx`, Schreiben + eigene löschen) und — nur bei der eigenen Story — Zuschauerliste (`StoryViewersSheet.tsx`, Augen-Symbol oben links). Solange ein Sheet offen ist, pausiert der Story-Timer (anders als im Web, damit die Story beim Tippen nicht weiterspringt). Nutzt die vorhandenen Server-Prozeduren; einzige Server-Änderung: `stories.listActive` nimmt optional `viewerPetId` und liefert `reactedByViewer`, damit die Pfote gefüllt startet, wenn man schon reagiert hat — abwärtskompatibel, und der Web-Viewer nutzt es jetzt auch (bekannte Einschränkung "Pfote startet immer leer" behoben). Tests in `src/__tests__/stories.test.ts`, alle 268 Tests + Build + App-Typecheck grün. Nicht auf einem Gerät getestet; Tastaturverhalten im Kommentar-Sheet (Android `KeyboardAvoidingView` behavior "height" im Modal) bitte bei Testern beobachten. Weiterhin offen: keine Benachrichtigung bei Story-Kommentaren/-Pfoten (Notification hat keine `storyId`, bräuchte Schemaänderung); Video-Storys spielt der App-Viewer weiterhin nicht ab. Commit `ccebfa4`, live (Web + OTA Runtime 1.0.6: Android `a3941052-a3ec-4c3c-a875-24ad3dcd1c98`, iOS `5e855c5b-f6db-44d0-8350-6b0b91308804`).
|
||||
-
|
||||
|
||||
|
||||
|
||||
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
|
||||
|
||||
- [x] Nutzer-Feedback: Storys und Beiträge mit Bildern im HEIF/HEIC-Format (Apple) lassen sich nicht erstellen.
|
||||
→ **Kein reines Serverproblem.** Der Server nimmt bewusst nur JPEG/PNG/WebP an (`ALLOWED_CONTENT_TYPES` in `src/lib/supabase-storage.ts`), und die Bytes gehen direkt vom Client zu Supabase — umgewandelt wurde HEIC nirgends. Zwei Fundstellen:
|
||||
1. **App, Beiträge:** `app/(app)/post-new.tsx` filterte gewählte Fotos nach ihrem *Original*-MIME-Typ (`image/jpeg|png|webp`) und warf `image/heic`/`image/heif` still raus — obwohl `resizeForUpload()` danach sowieso jedes Bild in JPEG umwandelt. Storys/Meilensteine/Avatar in der App filtern nicht und waren nicht betroffen.
|
||||
2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt.
|
||||
Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet. Commit `1bf0f92`, Web per Rolling-Deploy live, OTA Runtime 1.0.6: Android `1b4baa80-a35a-49b7-ac6e-f44ebe93b4db`, iOS `c31382ee-83b7-4a81-a06d-1a2e53180d7d`. **Vom Nutzer bestätigt (02.10.2026): funktioniert.**
|
||||
|
||||
- [x] Nutzerfeedback - Story löschbar ? wie kann man die story löschen, falls man mal was falsches in die story geladen hat ?
|
||||
→ Gab es bisher nirgends. Neu: Server `stories.delete` (nur Owner; löscht Aufrufe — `StoryView` hat bewusst kein Cascade —, dann die Story, Pfoten/Kommentare per Cascade; danach Bild in Supabase Storage bzw. Mux-Asset best-effort). Web: Papierkorb neben der Zuschauerzahl in der eigenen Story + Rückfrage (`AlertDialog`), Bild-Timer pausiert während der Rückfrage, gelöscht wird die beim Antippen gemerkte Story-ID. App: gleicher Papierkorb + native Rückfrage, Timer pausiert. Nach dem Löschen springt der Viewer zur nächsten Story bzw. schließt, wenn keine mehr da ist. 5 neue Tests in `src/__tests__/stories.test.ts`. Nicht auf einem Gerät getestet. Commit `81d9c6d`, live (Web + OTA Runtime 1.0.6: Android `49757586-c443-4952-a679-c66a4225ef82`, iOS `48d22b11-abc9-4c75-be52-a122b0130c5e`).
|
||||
- [x] Storys nur für Ersteller länger einsehbar wegen den Kommentaren (7Tage)
|
||||
→ **Umgesetzt (02.10.):** Der Ersteller sieht seine Storys 7 Tage lang (danach löscht der Aufräum-Job sie), alle anderen weiterhin nur 24 Stunden. Server: `stories.listActive` liefert dem Owner die Storys der letzten 7 Tage mit `isExpired`; `stories.hasActiveStory` meldet mit `includeOwnRecent` (nur die Story-Leiste setzt das) einen grauen Ring `expiredOnly`, damit Profilbilder im Feed nicht ebenfalls grau umrandet werden. Web + App: grauer Ring „Deine Story“, Hinweis „Abgelaufen – nur für dich sichtbar“ im Viewer, Kommentare bei abgelaufenen Storys nur lesbar, Pfote nur als Zähler; Zuschauerliste und Löschen bleiben. Keine Schemaänderung. 4 neue Tests. Nicht auf einem Gerät getestet. Commit `2c0680c`, live (Web + OTA Runtime 1.0.6: Android `5b324ee8-4821-4ca5-b6e7-6a02635a41f7`, iOS `a430ccd0-f2bb-452f-b3cd-45e94910901b`).
|
||||
→ Noch offen. Befund: abgelaufene Storys werden nie gelöscht (Einträge, Bilder, Mux-Videos bleiben liegen) — Vorschlag: grauer Ring "Deine Story" 7 Tage für den Ersteller, danach Aufräum-Cron (braucht einen Auslöser auf dem NAS, die bestehenden Cron-Routen haben keinen) + Satz unter "Speicherdauer" in der Datenschutzerklärung. **Teil erledigt (02.10.):** Aufräum-Job gebaut — `/api/cron/cleanup-stories` löscht Storys älter als 7 Tage komplett (Aufrufe, Pfoten, Kommentare, Bild, Mux-Video; gemeinsame Funktion `src/lib/story-cleanup.ts`, auch von „Story löschen“ genutzt), täglich 04:30 über die bestehende NAS-Crontab (`docker/run-cron.sh`, Log in `docker/cron.log`); Datenschutzerklärung Abschnitt 6 ergänzt. Offen bleibt nur die 7-Tage-Ansicht für den Ersteller.
|
||||
|
||||
|
||||
|
||||
- [x] Video-Storys in der App (Upload + Wiedergabe)
|
||||
→ **Umgesetzt (03.10.):** Branch `feature/mobile-video-stories` (Gitea #37, 20.09. auf Emulator + Gerät getestet) auf den seit 02.10. umgebauten Story-Viewer portiert. Galerie-Picker nimmt Fotos und Videos (max. 30 s, Prüfung vor dem Upload), Upload direkt zu Mux über `stories.createVideoUpload`; Viewer spielt READY-Videos über `expo-video` ab (Fortschritt aus `timeUpdate`, weiter bei `playToEnd`, `play()` erst bei `readyToPlay`), zeigt „wird verarbeitet“/Fehler und pausiert das Video, solange Kommentare/Zuschauer/Löschen offen sind. Nur JS (`expo-video` ist seit dem 1.0.6-Build nativ drin). App-Typecheck grün; der Port selbst (Pause bei offenem Sheet) **nicht auf einem Gerät getestet**. Kamera nimmt weiterhin nur Fotos auf (Video-Aufnahme bräuchte Mikrofon-Berechtigung = neuer Build). Commit `69e2c13`, OTA Runtime 1.0.6: Android `665f2bf0-7e19-4b38-a07f-5da03459a55c`, iOS `963e721c-3c7c-4d45-b08a-b3c49969e5a2`.
|
||||
- [x] Nutzer-Feedback: Beim Video-Story-Upload sieht man nur einen Spinner — Fortschritt nicht erkennbar.
|
||||
→ **Umgesetzt (03.10.):** Beim Video zuerst „Upload wird vorbereitet …“, dann Fortschrittsbalken mit Prozent („Video wird hochgeladen … 45 %“) aus `File.upload` `onProgress` (expo-file-system 57.0.7, nativ im 1.0.6-Build — Android meldet alle 100 ms). Vorschau während des Uploads gesperrt. Fotos behalten den Spinner (Upload dauert dort nur Sekundenbruchteile). App-Typecheck grün, nicht auf einem Gerät getestet. Commit `5606a81`, OTA Runtime 1.0.6: Android `000908ec-4aac-4bc3-80c7-a86ea438d1c2`, iOS `cd0481b9-84d3-4734-987b-c1b418d51c61`.
|
||||
|
||||
- [x] Feedback kam über iOS, betrifft aber das System, bei Impfungen die man anlegt, sollte ein Wiederholungstermin nach dem Abklingen der Impfung eingebunden werden, mit einem Reminder -1Monat vor ablauf damit man einen neuen Termin vereinbaren kann zum Impfen.
|
||||
→ **Umgesetzt und live (04.10.).** Den Folgetermin gab es schon (`Vaccine.nextDueDate`, Web + App), es fehlte nur die Erinnerung. Neu: täglicher Cron `/api/cron/vaccine-reminders` (`src/lib/vaccine-reminders.ts`) erinnert **einmalig 30 Tage vor** dem Folgetermin per Mitteilung `VACCINE_DUE` + Push („Die Impfung „Tollwut“ für Bubi ist am 01.11.2026 fällig …“). Bereits überfällige Termine werden nicht nachträglich erinnert (die zeigt die Gesundheitsseite als „überfällig“). Schema: `NotificationType.VACCINE_DUE`, `Notification.vaccineId` (Cascade), `Vaccine.reminderSentAt` (Schutz gegen doppelten Versand, wird bei Fehler wieder freigegeben). Web + App zeigen die Mitteilung mit 💉, Tippen öffnet die Gesundheitsseite. App: Schnellauswahl „+1 Jahr / +3 Jahre“ für den Folgetermin plus Hinweis „Mit Folgetermin erinnern wir dich 30 Tage vorher“. 8 neue Tests, Typecheck Web + App grün. Commit `c74bbff`, App-Teil per OTA Runtime 1.0.6: Android `64144379-e335-45ca-bb05-bc12155da54c`, iOS `b8f31fed-aa02-41a2-97c3-3707d02857ce`. Schema per `db push` auf dem NAS (rein additiv), Rolling-Deploy (`7ab74f8`; der erste Versuch brach ab, weil ein Test aus `src/__tests__` nach `mobile/` importierte, das im Docker-Build fehlt — Test liegt jetzt in `mobile/src/lib/date-format.test.ts`), Crontab täglich 08:15. Testaufruf: HTTP 200, `sent: 0` (derzeit nichts fällig). Der echte Versand inkl. Push ist noch nicht mit einer fälligen Impfung beobachtet, die App nicht auf einem Gerät getestet.
|
||||
- [x] Feedback iOS Datums eingabe läuft noch mittels - Trennung statt . ( option für iOS - Data Wheel Picker)
|
||||
→ **Trennzeichen umgesetzt (04.10.):** Neue `DateField`-Komponente (`src/components/DateField.tsx`) für alle Datumsfelder (Geburtstag, Adoptionstag, Gewicht, Tierarzt, Impfungen): Zifferntastatur, die Punkte setzt die App beim Tippen selbst (TT.MM.JJJJ; die iOS-Zifferntastatur hat keinen Punkt). Ältere Eingaben mit „-“ oder „/“ und einstellige Tage/Monate werden weiter akzeptiert. Tests in `src/__tests__/mobile-date-format.test.ts`. Commit `c74bbff`, OTA wie oben. Nicht auf einem Gerät getestet. **Wheel-Picker weiterhin offen** — braucht ein natives Modul, steht in `PENDING-NATIVE-BUILD.md` für den nächsten Build.
|
||||
- [x] Nutzer-Feedback iOS (Screenshot `feedback-ios/WhatsApp Image 2026-10-04 at 12.50.58.jpeg`): Beim Antippen eines Datumsfelds in „Profil bearbeiten“ verdeckt die Tastatur das Feld, die Eingabe ist nicht zu sehen.
|
||||
→ **Behoben (04.10.):** Der Screen hatte keinen Tastatur-Ausgleich (nur `FlatList`, kein `KeyboardAvoidingView`). Neu: `automaticallyAdjustKeyboardInsets` (iOS: unterer Inset in Tastaturhöhe **und** automatisches Scrollen zum aktiven Feld, im RN-Quellcode `RCTScrollViewComponentView.mm` geprüft) plus `keyboardShouldPersistTaps="handled"`. Gleiche Lücke auch in Gesundheit (Gewicht/Impfung/Tierarzt), Neuer Beitrag, Neuer Meilenstein, Tier anlegen/Onboarding und Einladen gefunden und mitbehoben. Android unverändert (Prop gilt nur für iOS, dort nicht gemeldet). Typecheck sauber. Commit `333ba16`, OTA nur iOS Runtime 1.0.6 `718ede5a-91c4-4e4e-b444-174ab3af66a2`. Nicht auf einem Gerät getestet.
|
||||
- [x] Feedback iOS - Rassen suche ist als Dropdown, ohne Suchfunktion. Bei Hunden und Katzen ist die Liste lang, die Suche würde verkürzt werden, wenn man eintippen könnte um das Dropdown zu kürzen.
|
||||
→ **Umgesetzt (04.10.):** `OptionPicker` zeigt ab 10 Einträgen oben ein Suchfeld („Suchen …“), das beim Tippen filtert — ohne Groß-/Kleinschreibung und Akzente („frise“ findet „Bichon Frisé“, `src/lib/filter-options.ts` + Tests). Öffnet sich die Tastatur, rückt die Liste nach oben, damit sie nicht verdeckt wird. Wirkt in Tier anlegen, Profil bearbeiten und Explore-Filter; die kurze Tierarten-Liste bleibt ohne Suchfeld. Typecheck sauber. Commit `01459c7`, OTA Runtime 1.0.6: Android `02f677cc-406b-4475-9601-e61b8a4b963a`, iOS `708be8d6-9786-44ad-a3c9-c305a3f93dd2`. Nicht auf einem Gerät getestet — bitte besonders das Hochrücken über der Tastatur prüfen.
|
||||
- Feature: Storie-Edit Modus, mittels Overlays eigene Storys hübsch machen, durch Texteingaben in das Bild oder Effekte oder Gifs. (Analog zu Instagram) ->Branch
|
||||
|
||||
- [x] Admin-Dashboard -> Zurückgewiesene Reports, als Meldung an den Melder mit einer Begründung warum abgewiesen wurde. Sowie Meldung bei bestätigter Report löschung, z.b mit Danksagung für die Meldung.
|
||||
→ **Umgesetzt und live (04.10.), als Team-Nachricht.** Admin → Reports: „Report ignorieren“ hat jetzt ein Feld „Begründung für den Melder“; der Melder bekommt vom PawFeed-Team „Danke für deine Meldung zum Beitrag/Profil von X … kein Verstoß … Begründung: …“ (Begründung steht zusätzlich im Moderations-Log). „Post löschen“ dankt jedem Melder des Beitrags einmal („… Inhalt entfernt. Danke, dass du hilfst …“); die interne Lösch-Begründung geht bewusst nicht an den Melder. Beides per Schalter im Dialog abschaltbar, Versand erst nach erfolgreicher Moderation und ohne sie bei einem Fehler zu kippen. **Nebenbei behoben:** Beim Löschen eines gemeldeten Beitrags blieben dessen Reports verwaist in der Liste (`targetPostId` ist keine Relation) — werden jetzt mitgelöscht. 7 neue Tests (`admin-report-feedback.test.ts`), alle 336 Tests grün. Commit `f51b158`, Rolling-Deploy auf dem NAS. Kein App-Update nötig. Im Live-Admin noch nicht mit einem echten Report ausprobiert.
|
||||
|
||||
|
||||
- Feedback - iOS & Andriod, Story-Bilder und Videos mit Overlays bearbeiten direkt in der App. Text hinzufügen, Gifs einbinden, Markierungen auf andere Pets setzten (Analog zu Instagram) bitte als neues Branch anlegen.
|
||||
|
||||
- [x] Admin-Dashboard - Tiere zeigt aktuell neben dem Namen nur einen Rang an, zur besseren übersicht sollten alle erlangten Ränge angezeigt werden in der Liste.
|
||||
→ **Umgesetzt und live (04.10.):** Die Liste zeigte nur den höchsten Rang (Cache `referralRankId`) und nur mit Vorteil NAME_BADGE. Jetzt lädt `petAdmin.list` alle Rang-Einträge des Besitzers (nach Schwelle sortiert) und zeigt jedes Rangsymbol (`AchievedRankBadges`), unabhängig von NAME_BADGE. Entzogene Ränge erscheinen ausgegraut, Tooltip mit „manuell vergeben“ / „entzogen“. Commit `dbc416c`, Rolling-Deploy.
|
||||
|
||||
- [x] Feedback - iOS, wenn man Nachrichten erhält bekommt man zwar eine Benachrichtung aber in der App wird bei der Sprechblase kein Indicator angezeigt z.b. ein Orangener Punkt der auf eine ungelesene nachricht hindeutet.
|
||||
→ **Umgesetzt (04.10.):** Die Sprechblase oben zeigt jetzt wie die Glocke einen orangen Zähler mit der Zahl ungelesener Nachrichten (`conversations.unreadCount`, gab es serverseitig schon). Aktualisiert sich alle 30 s, solange die App offen ist, und verschwindet nach dem Lesen des Chats. Commit `2bd4b2d`, OTA Runtime 1.0.6: Android `70b064d1-3424-44af-b834-7e77a60475e6`, iOS `e0993ecc-0961-4270-8876-a999e8339ba3`. Nicht auf einem Gerät getestet.
|
||||
|
||||
- [x] Testuser hat Goldene Pfote als Rang erhalten, im Rang habe ich Profil_RING mit eingesetzt damit sich der Profil-Ring mit färbt. aber das passiert nicht.
|
||||
→ **Ursache:** Kein Datenfehler — der Testnutzer („Krypton“) hatte PROFILE_RING korrekt im Cache. Der Ring war bewusst **nur im Tierprofil oben** zu sehen, nicht im Feed oder an Storys. **Auf Wunsch erweitert und live (04.10.):** (1) Ring in Rangfarbe jetzt **überall am Tierbild** — Feed, Kommentare, Suche, Explore, Nachrichten, Mitteilungen, Follower-Listen, Meilenstein-/Repost-Karten, Story-Kommentare/-Zuschauer (Web + App). Dafür liefert die gemeinsame Tier-Abfrage (`petIdentitySelect`) die Rangdaten jetzt mit. Der Ring liegt außen um das Bild, Listen verrutschen nicht. (2) **Story-Ring:** Bei ungesehener Story leuchtet die Pfote in der Rangfarbe statt Orange; ohne Story gibt es einen schlichten Ring in Rangfarbe; gesehene Storys bleiben grau. 6 neue Tests, alle 353 Tests grün. Commit `c7c1473`, Rolling-Deploy + OTA (wie oben). Nicht auf einem Gerät getestet.
|
||||
|
||||
|
||||
@@ -57,21 +57,32 @@ function AppleSignInButtonInner({ onError, legalAccepted, onNeedsConsent, disabl
|
||||
const result = await startAppleAuthenticationFlow();
|
||||
const { setActive, signUp } = result;
|
||||
let createdSessionId = result.createdSessionId;
|
||||
let missingFields = signUp?.status === "missing_requirements" ? signUp.missingFields : [];
|
||||
|
||||
if (!createdSessionId && signUp?.status === "missing_requirements" && signUp.missingFields.includes("legal_accepted")) {
|
||||
if (!createdSessionId && signUp && missingFields.includes("legal_accepted")) {
|
||||
if (!legalAccepted) {
|
||||
onNeedsConsent?.();
|
||||
return;
|
||||
}
|
||||
const updatedSignUp = await signUp.update({ legalAccepted: true });
|
||||
createdSessionId = updatedSignUp.createdSessionId;
|
||||
missingFields = updatedSignUp.status === "missing_requirements" ? updatedSignUp.missingFields : [];
|
||||
}
|
||||
|
||||
if (createdSessionId && setActive) {
|
||||
await setActive({ session: createdSessionId });
|
||||
router.replace("/(app)");
|
||||
return;
|
||||
}
|
||||
// No createdSessionId otherwise (user cancelled the Apple sheet).
|
||||
|
||||
// Any other required field Apple can't supply (e.g. first/last name —
|
||||
// Apple only sends the name on the very first authorization) would
|
||||
// otherwise leave the button silently doing nothing.
|
||||
if (missingFields.length > 0) {
|
||||
Sentry.captureMessage(`Apple sign-up missing requirements: ${missingFields.join(", ")}`);
|
||||
onError(t("auth.appleSignInError"));
|
||||
}
|
||||
// No createdSessionId and nothing missing: user cancelled the Apple sheet.
|
||||
} catch (err) {
|
||||
Sentry.captureException(err);
|
||||
onError(err instanceof Error ? err.message : t("auth.appleSignInError"));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { TextField } from "./TextField";
|
||||
import { formatDateInput } from "../lib/date-format";
|
||||
|
||||
const DISPLAY_DATE_LENGTH = 10; // DD.MM.YYYY
|
||||
|
||||
type DateFieldProps = Omit<TextInputProps, "onChangeText" | "keyboardType" | "maxLength"> & {
|
||||
onChangeText: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text date input with a DD.MM.YYYY mask: number pad only, the dots are
|
||||
* inserted automatically (iOS' number pad has no "." key). A native wheel
|
||||
* picker needs @react-native-community/datetimepicker → PENDING-NATIVE-BUILD.md.
|
||||
*/
|
||||
export function DateField({ onChangeText, ...props }: DateFieldProps) {
|
||||
return (
|
||||
<TextField
|
||||
{...props}
|
||||
keyboardType="number-pad"
|
||||
maxLength={DISPLAY_DATE_LENGTH}
|
||||
onChangeText={(text) => onChangeText(formatDateInput(text))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, ScrollView, ActivityIndicator, Modal, StyleSheet } from "react-native";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { ChevronDown, Search } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||
import { radii, fontFamily, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||
import { filterOptionsByName } from "../lib/filter-options";
|
||||
|
||||
export type PickerOption = { id: string; name: string };
|
||||
|
||||
@@ -22,6 +35,11 @@ type Props = {
|
||||
const BUTTON_HEIGHT = 48;
|
||||
const LIST_GAP = 4;
|
||||
const MAX_LIST_HEIGHT = 220;
|
||||
// Ab so vielen Einträgen gibt es oben ein Suchfeld (found.md: Hunde-/Katzen-
|
||||
// Rassenlisten sind lang). Tierarten (kurze Liste) bleiben ohne.
|
||||
const SEARCH_MIN_OPTIONS = 10;
|
||||
const SEARCH_HEIGHT = 44;
|
||||
const MIN_LIST_TOP = 48;
|
||||
|
||||
type Anchor = { top: number; left: number; width: number };
|
||||
|
||||
@@ -64,6 +82,13 @@ export function OptionPicker({
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const triggerRef = useRef<View>(null);
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [listHeight, setListHeight] = useState(0);
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
|
||||
const hasSearch = options.length >= SEARCH_MIN_OPTIONS;
|
||||
const visibleOptions = useMemo(() => filterOptionsByName(options, query), [options, query]);
|
||||
|
||||
useEffect(() => {
|
||||
// No need to reset `anchor` to null when closing — the Modal already
|
||||
@@ -76,6 +101,38 @@ export function OptionPicker({
|
||||
});
|
||||
}, [open]);
|
||||
|
||||
// The search field brings up the keyboard; a list anchored low on the
|
||||
// screen would end up underneath it, so it moves up while the keyboard is open.
|
||||
useEffect(() => {
|
||||
if (!open || !hasSearch) return;
|
||||
const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
|
||||
const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
|
||||
const showSub = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
|
||||
const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
|
||||
return () => {
|
||||
showSub.remove();
|
||||
hideSub.remove();
|
||||
setKeyboardHeight(0);
|
||||
};
|
||||
}, [open, hasSearch]);
|
||||
|
||||
const close = () => {
|
||||
setQuery("");
|
||||
onToggle();
|
||||
};
|
||||
|
||||
const select = (option: PickerOption) => {
|
||||
setQuery("");
|
||||
onSelect(option);
|
||||
};
|
||||
|
||||
const listTop = (() => {
|
||||
if (!anchor) return 0;
|
||||
if (!keyboardHeight || !listHeight) return anchor.top;
|
||||
const maxTop = windowHeight - keyboardHeight - listHeight - LIST_GAP;
|
||||
return Math.max(MIN_LIST_TOP, Math.min(anchor.top, maxTop));
|
||||
})();
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
@@ -87,7 +144,7 @@ export function OptionPicker({
|
||||
<Text style={selectedName ? styles.value : styles.placeholder}>{selectedName ?? placeholder}</Text>
|
||||
<ChevronDown color={colors.quietGray} size={18} />
|
||||
</Pressable>
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={onToggle}>
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={close}>
|
||||
{/*
|
||||
Backdrop and list are SIBLINGS, not parent/child. The list must
|
||||
not be a descendant of the backdrop Pressable at all — a Pressable
|
||||
@@ -99,17 +156,36 @@ export function OptionPicker({
|
||||
the list's bounds hit-tests into the list's own subtree and never
|
||||
reaches the backdrop's Pressable underneath at all.
|
||||
*/}
|
||||
<Pressable style={styles.backdrop} onPress={onToggle} />
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
{anchor && (
|
||||
<View style={[styles.list, { top: anchor.top, left: anchor.left, width: anchor.width }]}>
|
||||
<View
|
||||
style={[styles.list, hasSearch && styles.listWithSearch, { top: listTop, left: anchor.left, width: anchor.width }]}
|
||||
onLayout={(e) => setListHeight(e.nativeEvent.layout.height)}
|
||||
>
|
||||
{hasSearch && (
|
||||
<View style={styles.searchRow}>
|
||||
<Search color={colors.quietGray} size={16} />
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("common.searchPlaceholder")}
|
||||
placeholderTextColor={colors.quietGray}
|
||||
style={styles.searchInput}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.marigoldOrange} style={styles.loading} />
|
||||
) : options.length === 0 ? (
|
||||
<Text style={styles.empty}>{emptyLabel ?? t("common.noResultsFound")}</Text>
|
||||
) : visibleOptions.length === 0 ? (
|
||||
<Text style={styles.empty}>{emptyLabel && !query ? emptyLabel : t("common.noResultsFound")}</Text>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView}>
|
||||
{options.map((option) => (
|
||||
<Pressable key={option.id} style={styles.row} onPress={() => onSelect(option)}>
|
||||
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="handled">
|
||||
{visibleOptions.map((option) => (
|
||||
<Pressable key={option.id} style={styles.row} onPress={() => select(option)}>
|
||||
<Text style={getTypography(colors).body}>{option.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -154,7 +230,18 @@ function makeStyles(colors: ColorTokens) {
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
listWithSearch: { maxHeight: MAX_LIST_HEIGHT + SEARCH_HEIGHT },
|
||||
scrollView: { maxHeight: MAX_LIST_HEIGHT },
|
||||
searchRow: {
|
||||
height: SEARCH_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.hairlineGray,
|
||||
},
|
||||
searchInput: { flex: 1, height: SEARCH_HEIGHT, fontFamily: fontFamily.regular, fontSize: 14, color: colors.ink },
|
||||
row: { paddingHorizontal: 14, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
loading: { padding: 16 },
|
||||
empty: { ...typography.body, color: colors.quietGray, padding: 16 },
|
||||
|
||||
@@ -3,6 +3,7 @@ import Svg, { Circle } from "react-native-svg";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../api/trpc";
|
||||
import { useActivePet } from "../context/ActivePetContext";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
@@ -10,6 +11,14 @@ type Props = {
|
||||
/** Avatar size in px — used to scale the paw outline (default: 32) */
|
||||
size?: number;
|
||||
onPress?: () => void;
|
||||
/** Nur Story-Leiste: eigene abgelaufene Storys (≤ 7 Tage) als grauer Ring. */
|
||||
includeOwnRecent?: boolean;
|
||||
/**
|
||||
* Owner with the PROFILE_RING perk (found.md): an unseen story's paw takes
|
||||
* the rank colour instead of orange, and without a story a plain ring in
|
||||
* the rank colour is drawn. Seen stories stay gray either way.
|
||||
*/
|
||||
owner?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
const RING_PAD = 8;
|
||||
@@ -35,15 +44,21 @@ export function getPawRingBox(size: number) {
|
||||
* instead of inline SVG markup. Orange = unseen stories, gray = all seen,
|
||||
* no ring = no active story (STORY-02).
|
||||
*/
|
||||
export function PawRing({ petId, children, size = 32, onPress }: Props) {
|
||||
export function PawRing({ petId, children, size = 32, onPress, includeOwnRecent, owner }: Props) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data = { hasStory: false, allSeen: false } } = useQuery(
|
||||
trpc.stories.hasActiveStory.queryOptions({ petId, viewerPetId: activePetId ?? undefined })
|
||||
trpc.stories.hasActiveStory.queryOptions({
|
||||
petId,
|
||||
viewerPetId: activePetId ?? undefined,
|
||||
...(includeOwnRecent ? { includeOwnRecent: true } : {}),
|
||||
})
|
||||
);
|
||||
const { hasStory, allSeen } = data;
|
||||
const ringColor = allSeen ? SEEN_COLOR : UNSEEN_COLOR;
|
||||
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||
const ringColor = allSeen ? SEEN_COLOR : (rank?.color ?? UNSEEN_COLOR);
|
||||
const showRankRingOnly = !hasStory && !!rank;
|
||||
|
||||
const box = getPawRingBox(size);
|
||||
const strokeWidth = Math.max(4, size / 8);
|
||||
@@ -64,6 +79,16 @@ export function PawRing({ petId, children, size = 32, onPress }: Props) {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{showRankRingOnly && (
|
||||
<Svg
|
||||
width={box.width}
|
||||
height={box.height}
|
||||
viewBox={`0 ${-BEAN_OVERHANG} 100 ${100 + BEAN_OVERHANG}`}
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<Circle cx={50} cy={50} r={44} fill="none" stroke={rank.color} strokeWidth={strokeWidth * 0.6} />
|
||||
</Svg>
|
||||
)}
|
||||
{hasStory && (
|
||||
<Svg
|
||||
width={box.width}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Image } from "expo-image";
|
||||
import { getAvatarUrl } from "../lib/media-url";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { fontFamily, type ColorTokens } from "../theme/tokens";
|
||||
import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
const SIZE_PRESETS = { sm: 32, md: 40, lg: 56 } as const;
|
||||
|
||||
@@ -11,28 +12,60 @@ type Props = {
|
||||
name: string;
|
||||
avatarKey?: string | null;
|
||||
size?: keyof typeof SIZE_PRESETS;
|
||||
/**
|
||||
* The pet's owner (referralRankId/referralPerks, part of petIdentitySelect).
|
||||
* With the PROFILE_RING perk the avatar gets a ring in the rank colour.
|
||||
* Don't pass it when a PawRing wraps this avatar — PawRing draws that ring.
|
||||
*/
|
||||
owner?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
// Ring sits *outside* the avatar (absolute overlay) so ranked and unranked
|
||||
// avatars keep the same footprint and list rows don't shift.
|
||||
const RING_WIDTH = { sm: 2, md: 2, lg: 3 } as const;
|
||||
const RING_GAP = 2;
|
||||
|
||||
/**
|
||||
* Port of web's src/components/ui/pet-avatar.tsx — image if the pet has one,
|
||||
* otherwise a colored initial fallback.
|
||||
*/
|
||||
export function PetAvatar({ name, avatarKey, size = "md" }: Props) {
|
||||
export function PetAvatar({ name, avatarKey, size = "md", owner }: Props) {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const rank = useOwnerRankWithPerk(owner, "PROFILE_RING");
|
||||
const px = SIZE_PRESETS[size];
|
||||
const uri = getAvatarUrl(avatarKey);
|
||||
const dimensionStyle = { width: px, height: px, borderRadius: px / 2 };
|
||||
|
||||
if (uri) {
|
||||
return <Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />;
|
||||
}
|
||||
|
||||
return (
|
||||
const avatar = uri ? (
|
||||
<Image source={{ uri }} style={dimensionStyle} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[styles.fallback, dimensionStyle]}>
|
||||
<Text style={[styles.fallbackText, { fontSize: px * 0.4 }]}>{name.charAt(0).toUpperCase()}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!rank) return avatar;
|
||||
|
||||
const offset = RING_GAP + RING_WIDTH[size];
|
||||
return (
|
||||
<View style={{ width: px, height: px }}>
|
||||
{avatar}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -offset,
|
||||
left: -offset,
|
||||
width: px + offset * 2,
|
||||
height: px + offset * 2,
|
||||
borderRadius: (px + offset * 2) / 2,
|
||||
borderWidth: RING_WIDTH[size],
|
||||
borderColor: rank.color,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { BadgeCheck, Sparkles } from "lucide-react-native";
|
||||
import { BadgeCheck, Sparkles, Sprout } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isActiveSponsor } from "../lib/sponsor";
|
||||
import { colors } from "../theme/tokens";
|
||||
import { ReferralIcon } from "../lib/referral-icons";
|
||||
@@ -7,21 +8,27 @@ import { useOwnerRankWithPerk, type ReferralOwnerFields } from "../hooks/useRefe
|
||||
|
||||
type Props = {
|
||||
isVerified?: boolean;
|
||||
sponsor?: { sponsorSince: Date | string | null; sponsorPeriodEnd: Date | string | null } | null;
|
||||
/** Eines der ersten 250 Tiere (Pet.isFoundingMember). */
|
||||
isFoundingMember?: boolean;
|
||||
sponsor?: { sponsorSince?: Date | string | null; sponsorPeriodEnd?: Date | string | null } | null;
|
||||
/** Owner-Felder referralRankId/referralPerks — Rang-Abzeichen des Empfehlungsprogramms. */
|
||||
referral?: ReferralOwnerFields | null;
|
||||
};
|
||||
|
||||
/** Port of web's src/components/ui/pet-identity-badges.tsx. */
|
||||
export function PetIdentityBadges({ isVerified, sponsor, referral }: Props) {
|
||||
const FOUNDING_COLOR = "#059669"; // emerald-600, wie im Web
|
||||
|
||||
export function PetIdentityBadges({ isVerified, isFoundingMember, sponsor, referral }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const showSponsor = sponsor ? isActiveSponsor(sponsor) : false;
|
||||
const rank = useOwnerRankWithPerk(referral, "NAME_BADGE");
|
||||
if (!isVerified && !showSponsor && !rank) return null;
|
||||
if (!isVerified && !isFoundingMember && !showSponsor && !rank) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{isVerified && <BadgeCheck size={14} color={colors.marigoldOrange} />}
|
||||
{showSponsor && <Sparkles size={14} color={colors.marigoldOrange} />}
|
||||
{isFoundingMember && <Sprout size={14} color={FOUNDING_COLOR} accessibilityLabel={t("foundingMember.label")} />}
|
||||
{rank && <ReferralIcon icon={rank.icon} size={14} color={rank.color} fill={rank.color} accessibilityLabel={rank.name} />}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getMediaUrl } from "../lib/media-url";
|
||||
import { formatPetName, formatRelativeTime } from "../lib/format";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../theme/tokens";
|
||||
import type { ReferralOwnerFields } from "../hooks/useReferralRanks";
|
||||
|
||||
/**
|
||||
* Structural rather than imported from a single router's inferred output:
|
||||
@@ -39,7 +40,7 @@ type PostCardOriginalPost = {
|
||||
type?: string | null;
|
||||
images: PostCardImage[];
|
||||
milestone?: unknown;
|
||||
pet: { id: string; name: string; avatarKey?: string | null };
|
||||
pet: { id: string; name: string; avatarKey?: string | null; owner?: ReferralOwnerFields | null };
|
||||
};
|
||||
|
||||
export type PostCardPost = {
|
||||
@@ -59,9 +60,12 @@ export type PostCardPost = {
|
||||
nickname?: string | null;
|
||||
avatarKey?: string | null;
|
||||
isVerified?: boolean;
|
||||
isFoundingMember?: boolean;
|
||||
// Sponsor fields only come with petCardSelect (feed/explore); a plain pet
|
||||
// identity's owner (posts.byPetId, comments) carries just the referral rank.
|
||||
owner?: {
|
||||
sponsorSince: Date | string | null;
|
||||
sponsorPeriodEnd: Date | string | null;
|
||||
sponsorSince?: Date | string | null;
|
||||
sponsorPeriodEnd?: Date | string | null;
|
||||
referralRankId?: string | null;
|
||||
referralPerks?: string[] | null;
|
||||
} | null;
|
||||
@@ -144,11 +148,11 @@ export function PostCard({ post, actingPetId, isTrending, isVisible = true }: Pr
|
||||
<View style={styles.card}>
|
||||
<View style={styles.headerRow}>
|
||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${post.pet.id}`)}>
|
||||
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} />
|
||||
<PetAvatar name={post.pet.name} avatarKey={post.pet.avatarKey} owner={post.pet.owner} />
|
||||
<View style={styles.headerText}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{formatPetName(post.pet)}</Text>
|
||||
<PetIdentityBadges isVerified={post.pet.isVerified} sponsor={post.pet.owner} referral={post.pet.owner} />
|
||||
<PetIdentityBadges isVerified={post.pet.isVerified} isFoundingMember={post.pet.isFoundingMember} sponsor={post.pet.owner} referral={post.pet.owner} />
|
||||
{isTrending && (
|
||||
<View style={styles.trendingBadge}>
|
||||
<Flame size={11} color={colors.marigoldDeep} fill={colors.marigoldDeep} />
|
||||
|
||||
@@ -45,7 +45,7 @@ export function RepostCard({ post }: Props) {
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.header} onPress={() => router.push(`/pets/${origPet.id}`)}>
|
||||
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" />
|
||||
<PetAvatar name={origPet.name} avatarKey={origPet.avatarKey} size="sm" owner={origPet.owner} />
|
||||
<Text style={styles.name}>{origPet.name}</Text>
|
||||
</Pressable>
|
||||
|
||||
|
||||
@@ -16,20 +16,33 @@ const RING_BOX = getPawRingBox(AVATAR_SIZE);
|
||||
|
||||
/**
|
||||
* Port of web's src/components/stories/StoryTray.tsx: an "add story" tile
|
||||
* for the active pet, then followed pets with an active story (each in a
|
||||
* PawRing). No viewer list, reactions, or comments on stories yet (Gitea
|
||||
* for the active pet, then the active pet's own story while it has one
|
||||
* ("Deine Story" — the followed-pets query never includes the viewer's own
|
||||
* pet), then followed pets with an active story (each in a PawRing). No viewer list, reactions, or comments on stories yet (Gitea
|
||||
* #29 on web) — that's a fast-follow, matching how earlier phases trimmed
|
||||
* AI-disclosure/mentions from ported forms. Photo stories only, no video
|
||||
* story upload/playback (mirrors mobile's post-creation scope decision).
|
||||
* AI-disclosure/mentions from ported forms. Photo and video stories both
|
||||
* play back the same way here — see story-viewer.tsx for the branch.
|
||||
*/
|
||||
export function StoryTray() {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const router = useRouter();
|
||||
const { activePetId } = useActivePet();
|
||||
const { activePetId, pets } = useActivePet();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
const { data: ownStory } = useQuery({
|
||||
...trpc.stories.hasActiveStory.queryOptions({ petId: activePetId ?? "", includeOwnRecent: true }),
|
||||
enabled: !!activePetId,
|
||||
});
|
||||
const activePet = pets.find((pet) => pet.id === activePetId) ?? null;
|
||||
|
||||
const openViewer = (pet: { id: string; name: string; avatarKey?: string | null }) =>
|
||||
router.push({
|
||||
pathname: "/story-viewer",
|
||||
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
|
||||
});
|
||||
|
||||
const { data: followedWithStories = [] } = useQuery({
|
||||
...trpc.follows.getPetsWithActiveStories.queryOptions({ followerPetId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
@@ -48,18 +61,20 @@ export function StoryTray() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{ownStory?.hasStory && activePet && (
|
||||
<Pressable style={styles.item} onPress={() => openViewer(activePet)}>
|
||||
<PawRing petId={activePet.id} size={AVATAR_SIZE} includeOwnRecent owner={activePet.owner}>
|
||||
<PetAvatar name={activePet.name} avatarKey={activePet.avatarKey} size="lg" />
|
||||
</PawRing>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{t("feed.yourStory")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{followedWithStories.map((pet) => (
|
||||
<Pressable
|
||||
key={pet.id}
|
||||
style={styles.item}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/story-viewer",
|
||||
params: { petId: pet.id, petName: pet.name, petAvatarKey: pet.avatarKey ?? "" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<PawRing petId={pet.id} size={AVATAR_SIZE}>
|
||||
<Pressable key={pet.id} style={styles.item} onPress={() => openViewer(pet)}>
|
||||
<PawRing petId={pet.id} size={AVATAR_SIZE} owner={pet.owner}>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="lg" />
|
||||
</PawRing>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
|
||||
@@ -6,10 +6,14 @@ import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
/** Übliche Auffrisch-Intervalle (z. B. Tollwut 1–3 Jahre) als Schnellauswahl für den Folgetermin. */
|
||||
const NEXT_DUE_PRESET_YEARS = [1, 3];
|
||||
|
||||
/** Port of web's health.ts vaccine CRUD — see health/page.tsx's Impfungen tab. */
|
||||
export function VaccineSection({ petId }: { petId: string }) {
|
||||
@@ -80,8 +84,24 @@ export function VaccineSection({ petId }: { petId: string }) {
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.vaccineNamePlaceholder")} value={name} onChangeText={setName} maxLength={100} />
|
||||
<TextField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||
<TextField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||
<DateField placeholder={t("health.vaccineGivenPlaceholder")} value={dateGiven} onChangeText={setDateGiven} />
|
||||
<DateField placeholder={t("health.vaccineNextDuePlaceholder")} value={nextDueDate} onChangeText={setNextDueDate} />
|
||||
<View style={styles.presetRow}>
|
||||
{NEXT_DUE_PRESET_YEARS.map((years) => {
|
||||
const suggestion = addYearsToDisplayDate(dateGiven, years);
|
||||
return (
|
||||
<Pressable
|
||||
key={years}
|
||||
style={[styles.preset, !suggestion && styles.presetDisabled]}
|
||||
disabled={!suggestion}
|
||||
onPress={() => suggestion && setNextDueDate(suggestion)}
|
||||
>
|
||||
<Text style={styles.presetText}>{t("health.vaccineNextDuePreset", { count: years })}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text style={styles.hint}>{t("health.vaccineReminderHint")}</Text>
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||
<Button
|
||||
title={addMutation.isPending ? "…" : t("common.save")}
|
||||
@@ -134,5 +154,10 @@ function makeStyles(colors: ColorTokens) {
|
||||
rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
overdue: { color: colors.alertRed },
|
||||
presetRow: { flexDirection: "row", gap: 8 },
|
||||
preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint },
|
||||
presetDisabled: { opacity: 0.4 },
|
||||
presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
@@ -72,7 +73,7 @@ export function VetVisitSection({ petId }: { petId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<TextField placeholder={t("health.vetReasonPlaceholder")} value={reason} onChangeText={setReason} maxLength={100} />
|
||||
<TextField placeholder={t("health.vetNamePlaceholder")} value={vetName} onChangeText={setVetName} maxLength={100} />
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={500} multiline />
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, Text, Pressable, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||
import Svg, { Path, Line, Circle, Text as SvgText, Defs, LinearGradient, Stop } from "react-native-svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { Card } from "../Card";
|
||||
import { getTypography, fontFamily, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { buildWeightChart, nearestPointIndex, type WeightEntry } from "../../lib/weight-chart";
|
||||
|
||||
const CHART_HEIGHT = 170;
|
||||
const BOX = { left: 40, right: 12, top: 12, bottom: 26 };
|
||||
const AXIS_FONT_SIZE = 10;
|
||||
const MAX_LABELED_Y_TICKS = 5;
|
||||
const MARKER_RADIUS = 4;
|
||||
const SELECTED_RADIUS = 6;
|
||||
const LONG_SPAN_MS = 300 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Weight history (found.md: Gewichtsdaten als Diagramm) — port of web's
|
||||
* src/components/health/WeightChart.tsx. Tap anywhere on the plot to select
|
||||
* the nearest weigh-in; its value and date show in the header (no floating
|
||||
* tooltip to collide with the edges on a phone). The newest entry is
|
||||
* selected by default. The entry list below the chart is the table view.
|
||||
*/
|
||||
export function WeightChart({ entries }: { entries: WeightEntry[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const chart = useMemo(
|
||||
() => (width > 0 ? buildWeightChart(entries, { width, height: CHART_HEIGHT, ...BOX }) : null),
|
||||
[entries, width]
|
||||
);
|
||||
|
||||
const formatKg = (kg: number, maximumFractionDigits = 2) =>
|
||||
`${kg.toLocaleString(i18n.language, { maximumFractionDigits })} kg`;
|
||||
const formatDate = (time: number, options: Intl.DateTimeFormatOptions) =>
|
||||
new Date(time).toLocaleDateString(i18n.language, { timeZone: "UTC", ...options });
|
||||
|
||||
const onLayout = (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width));
|
||||
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const points = chart?.points ?? [];
|
||||
const activeIndex = selectedIndex !== null && selectedIndex < points.length ? selectedIndex : points.length - 1;
|
||||
const active = points[activeIndex];
|
||||
const delta = chart?.deltaKg ?? 0;
|
||||
const deltaLabel = `${delta > 0 ? "+" : delta < 0 ? "−" : "±"}${formatKg(Math.abs(delta))}`;
|
||||
const spansLong = points.length > 1 && points[points.length - 1].time - points[0].time > LONG_SPAN_MS;
|
||||
const axisDateOptions: Intl.DateTimeFormatOptions = spansLong
|
||||
? { month: "short", year: "2-digit" }
|
||||
: { day: "2-digit", month: "2-digit" };
|
||||
|
||||
const summary =
|
||||
points.length > 1
|
||||
? t("health.weightChartA11y", {
|
||||
from: formatKg(points[0].kg),
|
||||
fromDate: formatDate(points[0].time, { dateStyle: "medium" }),
|
||||
to: formatKg(points[points.length - 1].kg),
|
||||
toDate: formatDate(points[points.length - 1].time, { dateStyle: "medium" }),
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.eyebrow}>{t("health.weightChartTitle")}</Text>
|
||||
{active && (
|
||||
<Text style={styles.readout}>
|
||||
{formatKg(active.kg)}
|
||||
<Text style={styles.readoutDate}>{" "}{formatDate(active.time, { dateStyle: "medium" })}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.deltaChip}>
|
||||
<Text style={styles.deltaText}>{deltaLabel}</Text>
|
||||
<Text style={styles.deltaCaption}>{t("health.weightChartSinceFirst")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onLayout={onLayout}
|
||||
onPress={(e) => chart && setSelectedIndex(nearestPointIndex(chart.points, e.nativeEvent.locationX))}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={summary}
|
||||
style={styles.plot}
|
||||
>
|
||||
{chart && (
|
||||
<Svg width={width} height={CHART_HEIGHT}>
|
||||
<Defs>
|
||||
<LinearGradient id="weightArea" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={colors.chartLine} stopOpacity={0.2} />
|
||||
<Stop offset="1" stopColor={colors.chartLine} stopOpacity={0} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{chart.yTicks.map((tick, i) => (
|
||||
<Line
|
||||
key={`grid-${i}`}
|
||||
x1={BOX.left}
|
||||
x2={width - BOX.right}
|
||||
y1={tick.y}
|
||||
y2={tick.y}
|
||||
stroke={colors.hairlineGray}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{chart.yTicks.map((tick, i) =>
|
||||
chart.yTicks.length <= MAX_LABELED_Y_TICKS || i % 2 === 0 ? (
|
||||
<SvgText
|
||||
key={`ylabel-${i}`}
|
||||
x={BOX.left - 6}
|
||||
y={tick.y + AXIS_FONT_SIZE / 3}
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
fontFamily={fontFamily.regular}
|
||||
fill={colors.quietGray}
|
||||
textAnchor="end"
|
||||
>
|
||||
{tick.kg.toLocaleString(i18n.language, {
|
||||
minimumFractionDigits: chart.yTickDecimals,
|
||||
maximumFractionDigits: chart.yTickDecimals,
|
||||
})}
|
||||
</SvgText>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<Path d={chart.areaPath} fill="url(#weightArea)" />
|
||||
<Path
|
||||
d={chart.linePath}
|
||||
fill="none"
|
||||
stroke={colors.chartLine}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{active && (
|
||||
<Line
|
||||
x1={active.x}
|
||||
x2={active.x}
|
||||
y1={BOX.top}
|
||||
y2={chart.baselineY}
|
||||
stroke={colors.quietGray}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{chart.points.map((p, i) => (
|
||||
<Circle
|
||||
key={`pt-${i}`}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={i === activeIndex ? SELECTED_RADIUS : MARKER_RADIUS}
|
||||
fill={i === activeIndex ? colors.chartLine : colors.pureWhite}
|
||||
stroke={i === activeIndex ? colors.pureWhite : colors.chartLine}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{chart.xLabelIndices.map((i) => {
|
||||
const p = chart.points[i];
|
||||
const isFirst = i === chart.xLabelIndices[0] && p.x - BOX.left < 20;
|
||||
const isLast = i === chart.points.length - 1;
|
||||
return (
|
||||
<SvgText
|
||||
key={`xlabel-${i}`}
|
||||
x={p.x}
|
||||
y={CHART_HEIGHT - 6}
|
||||
fontSize={AXIS_FONT_SIZE}
|
||||
fontFamily={fontFamily.regular}
|
||||
fill={colors.quietGray}
|
||||
textAnchor={isLast ? "end" : isFirst ? "start" : "middle"}
|
||||
>
|
||||
{formatDate(p.time, axisDateOptions)}
|
||||
</SvgText>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
)}
|
||||
</Pressable>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
card: { gap: 8 },
|
||||
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 },
|
||||
eyebrow: { ...typography.label },
|
||||
readout: { ...typography.title, fontSize: 20, marginTop: 2 },
|
||||
readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 },
|
||||
deltaChip: { alignItems: "flex-end" },
|
||||
deltaText: { ...typography.body, fontFamily: typography.title.fontFamily },
|
||||
deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 },
|
||||
plot: { height: CHART_HEIGHT },
|
||||
});
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { Plus, Trash2 } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { TextField } from "../TextField";
|
||||
import { DateField } from "../DateField";
|
||||
import { Button } from "../Button";
|
||||
import { Card } from "../Card";
|
||||
import { WeightChart } from "./WeightChart";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format";
|
||||
|
||||
@@ -65,7 +67,7 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card style={styles.form}>
|
||||
<TextField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<DateField placeholder={t("health.weightDatePlaceholder")} value={date} onChangeText={setDate} />
|
||||
<TextField placeholder={t("health.weightKgPlaceholder")} value={weightKg} onChangeText={setWeightKg} keyboardType="decimal-pad" />
|
||||
<TextField placeholder={t("health.notesPlaceholder")} value={notes} onChangeText={setNotes} maxLength={200} />
|
||||
<Button
|
||||
@@ -81,20 +83,23 @@ export function WeightLogSection({ petId }: { petId: string }) {
|
||||
) : logs.length === 0 ? (
|
||||
<Text style={styles.empty}>{t("health.weightEmpty")}</Text>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<View key={log.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||
<Text style={styles.rowSecondary}>
|
||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||
{log.notes ? ` · ${log.notes}` : ""}
|
||||
</Text>
|
||||
<>
|
||||
<WeightChart entries={logs} />
|
||||
{logs.map((log) => (
|
||||
<View key={log.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowPrimary}>{(log.weightGrams / 1000).toLocaleString(i18n.language)} kg</Text>
|
||||
<Text style={styles.rowSecondary}>
|
||||
{new Date(log.date).toLocaleDateString(i18n.language)}
|
||||
{log.notes ? ` · ${log.notes}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={() => deleteMutation.mutate({ id: log.id, petId })} hitSlop={8}>
|
||||
<Trash2 color={colors.quietGray} size={16} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Pressable, Alert, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
type DmPolicy = "EVERYONE" | "FOLLOWERS_ONLY";
|
||||
type FollowerVisibility = "EVERYONE" | "FOLLOWERS_ONLY" | "NOBODY";
|
||||
|
||||
type Props = {
|
||||
petId: string;
|
||||
petName: string;
|
||||
dmPolicy: DmPolicy;
|
||||
followerVisibility: FollowerVisibility;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of the "Privatsphäre" block from web's src/app/(app)/pets/[petId]/edit/page.tsx
|
||||
* (found.md: only editable on web so far). Each choice is saved immediately via
|
||||
* pets.update — no separate save button, so it can't get lost in the long form.
|
||||
*/
|
||||
export function PrivacySettings({ petId, petName, dmPolicy, followerVisibility }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
const update = useMutation(
|
||||
trpc.pets.update.mutationOptions({
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: trpc.pets.byId.queryOptions({ petId }).queryKey }),
|
||||
onError: () => Alert.alert(t("privacy.saveFailed")),
|
||||
})
|
||||
);
|
||||
|
||||
const dmOptions: { value: DmPolicy; label: string }[] = [
|
||||
{ value: "EVERYONE", label: t("privacy.everyone") },
|
||||
{ value: "FOLLOWERS_ONLY", label: t("privacy.onlyFollowedPets") },
|
||||
];
|
||||
const followerOptions: { value: FollowerVisibility; label: string }[] = [
|
||||
{ value: "EVERYONE", label: t("privacy.everyone") },
|
||||
{ value: "FOLLOWERS_ONLY", label: t("privacy.onlyMyFollowers") },
|
||||
{ value: "NOBODY", label: t("privacy.onlyMe") },
|
||||
];
|
||||
const followerHint = {
|
||||
EVERYONE: t("privacy.followerEveryoneHint", { name: petName }),
|
||||
FOLLOWERS_ONLY: t("privacy.followerFollowersOnlyHint", { name: petName }),
|
||||
NOBODY: t("privacy.followerNobodyHint", { name: petName }),
|
||||
}[followerVisibility];
|
||||
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.heading}>{t("privacy.heading")}</Text>
|
||||
|
||||
<Text style={styles.question}>{t("privacy.whoCanMessage", { name: petName })}</Text>
|
||||
<OptionGroup
|
||||
options={dmOptions}
|
||||
value={dmPolicy}
|
||||
disabled={update.isPending}
|
||||
onChange={(value) => update.mutate({ petId, dmPolicy: value })}
|
||||
styles={styles}
|
||||
colors={colors}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
{dmPolicy === "FOLLOWERS_ONLY" ? t("privacy.dmFollowersOnlyHint") : t("privacy.dmEveryoneHint")}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.question}>{t("privacy.whoCanSeeFollowers", { name: petName })}</Text>
|
||||
<OptionGroup
|
||||
options={followerOptions}
|
||||
value={followerVisibility}
|
||||
disabled={update.isPending}
|
||||
onChange={(value) => update.mutate({ petId, followerVisibility: value })}
|
||||
styles={styles}
|
||||
colors={colors}
|
||||
/>
|
||||
<Text style={styles.hint}>{followerHint}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionGroup<T extends string>({
|
||||
options,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
styles,
|
||||
colors,
|
||||
}: {
|
||||
options: { value: T; label: string }[];
|
||||
value: T;
|
||||
disabled: boolean;
|
||||
onChange: (value: T) => void;
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
colors: ColorTokens;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.group} accessibilityRole="radiogroup">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.value}
|
||||
style={[styles.option, index > 0 && styles.optionDivider]}
|
||||
onPress={() => !isSelected && onChange(option.value)}
|
||||
disabled={disabled}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: isSelected, disabled }}
|
||||
>
|
||||
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>{option.label}</Text>
|
||||
{isSelected && <Check color={colors.marigoldOrange} size={18} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
section: { marginTop: 24, gap: 8 },
|
||||
heading: { ...typography.title, fontSize: 16 },
|
||||
question: { ...typography.body, fontFamily: typography.title.fontFamily, marginTop: 8 },
|
||||
group: { borderWidth: 1, borderColor: colors.hairlineGray, borderRadius: radii.lg, overflow: "hidden" },
|
||||
option: {
|
||||
minHeight: 48,
|
||||
paddingHorizontal: 14,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
optionDivider: { borderTopWidth: 1, borderTopColor: colors.hairlineGray },
|
||||
optionText: { ...typography.body },
|
||||
optionTextSelected: { fontFamily: typography.title.fontFamily, color: colors.marigoldOrange },
|
||||
hint: { ...typography.label, textTransform: "none", letterSpacing: 0 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send, Trash2, X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { TextField } from "../TextField";
|
||||
import { formatPetName, formatRelativeTime } from "../../lib/format";
|
||||
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
storyId: string;
|
||||
activePetId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
/** Wird nach Hinzufügen/Löschen aufgerufen, damit der Viewer den Zähler neu lädt. */
|
||||
onChanged: () => void;
|
||||
onPressPet: (petId: string) => void;
|
||||
/** Abgelaufene Story in der 7-Tage-Ansicht des Erstellers: nur lesen. */
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's StoryCommentsOverlay als Bottom-Sheet. Der Viewer pausiert die
|
||||
* Story, solange das Sheet offen ist (anders als im Web — auf dem Handy
|
||||
* verdeckt die Tastatur sonst die laufende Story und sie springt weiter,
|
||||
* während man noch tippt).
|
||||
*/
|
||||
export function StoryCommentsSheet({ storyId, activePetId, visible, onClose, onChanged, onPressPet, readOnly = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
const { data: comments = [], isLoading } = useQuery({
|
||||
...trpc.stories.listComments.queryOptions({ storyId }),
|
||||
enabled: visible,
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: trpc.stories.listComments.queryKey({ storyId }) });
|
||||
onChanged();
|
||||
};
|
||||
|
||||
const add = useMutation(
|
||||
trpc.stories.addComment.mutationOptions({
|
||||
onSuccess: () => {
|
||||
setBody("");
|
||||
refresh();
|
||||
},
|
||||
})
|
||||
);
|
||||
const remove = useMutation(trpc.stories.deleteComment.mutationOptions({ onSuccess: refresh }));
|
||||
|
||||
const trimmed = body.trim();
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView style={styles.flex} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("storyViewer.commentsTitle")}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
|
||||
<X size={20} color={colors.ink} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={comments}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={<Text style={styles.muted}>{t("comments.empty")}</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={() => onPressPet(item.pet.id)}>
|
||||
<PetAvatar name={item.pet.name} avatarKey={item.pet.avatarKey} size="sm" owner={item.pet.owner} />
|
||||
</Pressable>
|
||||
<View style={styles.rowBody}>
|
||||
<View style={styles.rowHeader}>
|
||||
<Text style={styles.petName} onPress={() => onPressPet(item.pet.id)}>
|
||||
{formatPetName(item.pet)}
|
||||
</Text>
|
||||
<Text style={styles.time}>{formatRelativeTime(item.createdAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.body}>{item.body}</Text>
|
||||
</View>
|
||||
{item.pet.id === activePetId && (
|
||||
<Pressable
|
||||
onPress={() => remove.mutate({ commentId: item.id, petId: activePetId })}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={t("storyViewer.deleteComment")}
|
||||
>
|
||||
<Trash2 size={16} color={colors.quietGray} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{readOnly ? (
|
||||
<Text style={styles.readOnly}>{t("storyViewer.readOnlyExpired")}</Text>
|
||||
) : (
|
||||
<View style={styles.inputRow}>
|
||||
<TextField
|
||||
placeholder={t("comments.placeholder")}
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
maxLength={500}
|
||||
style={styles.input}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={() => trimmed && add.mutate({ storyId, petId: activePetId, body: trimmed })}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => add.mutate({ storyId, petId: activePetId, body: trimmed })}
|
||||
disabled={!trimmed || add.isPending}
|
||||
style={[styles.send, (!trimmed || add.isPending) && styles.sendDisabled]}
|
||||
accessibilityLabel={t("messages.send")}
|
||||
>
|
||||
{add.isPending ? <ActivityIndicator color="#ffffff" /> : <Send size={18} color="#ffffff" />}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{add.isError && <Text style={styles.error}>{t("storyViewer.commentError")}</Text>}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, insetBottom: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
|
||||
sheet: {
|
||||
maxHeight: "65%",
|
||||
backgroundColor: colors.porchWhite,
|
||||
borderTopLeftRadius: radii["2xl"],
|
||||
borderTopRightRadius: radii["2xl"],
|
||||
paddingBottom: insetBottom + 12,
|
||||
},
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
title: { ...typography.title, fontSize: 16 },
|
||||
loading: { padding: 24 },
|
||||
list: { padding: 16, gap: 14 },
|
||||
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
|
||||
row: { flexDirection: "row", gap: 10, alignItems: "flex-start" },
|
||||
rowBody: { flex: 1, gap: 2 },
|
||||
rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
||||
petName: { ...typography.body, fontWeight: "600", fontSize: 13 },
|
||||
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
|
||||
body: { ...typography.body },
|
||||
inputRow: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingTop: 10 },
|
||||
input: { flex: 1 },
|
||||
send: { width: 48, height: 48, borderRadius: radii.lg, backgroundColor: colors.marigoldOrange, alignItems: "center", justifyContent: "center" },
|
||||
sendDisabled: { opacity: 0.5 },
|
||||
readOnly: { ...typography.body, fontSize: 13, color: colors.quietGray, paddingHorizontal: 16, paddingTop: 10 },
|
||||
error: { ...typography.body, fontSize: 12, color: colors.alertRed, paddingHorizontal: 12, paddingTop: 6 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { PawPrint } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { colors } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
storyId: string;
|
||||
petId: string;
|
||||
initialCount: number;
|
||||
initialReacted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand
|
||||
* und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt.
|
||||
* Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet.
|
||||
*/
|
||||
export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [reacted, setReacted] = useState(initialReacted);
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const toggle = useMutation(
|
||||
trpc.stories.toggleReaction.mutationOptions({
|
||||
onMutate: () => {
|
||||
const previous = { reacted, count };
|
||||
setReacted(!reacted);
|
||||
setCount(reacted ? Math.max(0, count - 1) : count + 1);
|
||||
return previous;
|
||||
},
|
||||
// Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung.
|
||||
onSuccess: (data, _vars, previous) => {
|
||||
setReacted(data.reacted);
|
||||
if (!previous) return;
|
||||
const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1;
|
||||
setCount(Math.max(0, previous.count + delta));
|
||||
},
|
||||
onError: (_err, _vars, previous) => {
|
||||
if (previous) {
|
||||
setReacted(previous.reacted);
|
||||
setCount(previous.count);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !toggle.isPending && toggle.mutate({ storyId, petId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")}
|
||||
accessibilityState={{ selected: reacted }}
|
||||
hitSlop={8}
|
||||
style={styles.wrap}
|
||||
>
|
||||
<View style={styles.circle}>
|
||||
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
|
||||
</View>
|
||||
{count > 0 && <Text style={styles.count}>{count}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { alignItems: "center", gap: 2 },
|
||||
circle: { width: 44, height: 44, borderRadius: 22, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center" },
|
||||
count: { color: colors.pureWhite, fontSize: 12, fontWeight: "600", textShadowColor: "rgba(0,0,0,0.6)", textShadowRadius: 3 },
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { formatPetName, formatRelativeTime } from "../../lib/format";
|
||||
import { getTypography, radii, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
type Props = {
|
||||
storyId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onPressPet: (petId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Port of web's owner-only viewer Sheet — wer hat diese Story gesehen.
|
||||
* stories.getViewers prüft serverseitig, dass nur der Owner der Story fragt.
|
||||
*/
|
||||
export function StoryViewersSheet({ storyId, visible, onClose, onPressPet }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]);
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { data: viewers = [], isLoading } = useQuery({
|
||||
...trpc.stories.getViewers.queryOptions({ storyId }),
|
||||
enabled: visible,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityLabel={t("common.close")} />
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("storyViewer.viewersTitle", { count: viewers.length })}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} accessibilityLabel={t("common.close")}>
|
||||
<X size={20} color={colors.ink} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator style={styles.loading} color={colors.marigoldOrange} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={viewers}
|
||||
keyExtractor={(item) => item.viewerPet.id}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={<Text style={styles.muted}>{t("storyViewer.noViewers")}</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => onPressPet(item.viewerPet.id)}>
|
||||
<PetAvatar name={item.viewerPet.name} avatarKey={item.viewerPet.avatarKey} size="sm" owner={item.viewerPet.owner} />
|
||||
<Text style={styles.name} numberOfLines={1}>{formatPetName(item.viewerPet)}</Text>
|
||||
<Text style={styles.time}>{formatRelativeTime(item.viewedAt)}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens, insetBottom: number) {
|
||||
const typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)" },
|
||||
sheet: {
|
||||
maxHeight: "60%",
|
||||
backgroundColor: colors.porchWhite,
|
||||
borderTopLeftRadius: radii["2xl"],
|
||||
borderTopRightRadius: radii["2xl"],
|
||||
paddingBottom: insetBottom + 12,
|
||||
},
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray },
|
||||
title: { ...typography.title, fontSize: 16 },
|
||||
loading: { padding: 24 },
|
||||
list: { padding: 16, gap: 12 },
|
||||
muted: { ...typography.body, color: colors.quietGray, textAlign: "center", paddingVertical: 16 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 10 },
|
||||
name: { ...typography.body, flex: 1, fontWeight: "600" },
|
||||
time: { ...typography.body, fontSize: 11, color: colors.quietGray },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { PanResponder, View, type GestureResponderEvent, type PanResponderGestureState, type PanResponderInstance } from "react-native";
|
||||
import { OverlayContent } from "./OverlayContent";
|
||||
import { PositionedOverlay } from "./StoryOverlayLayer";
|
||||
import { angleDelta, applyGesture, touchGeometry, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||
|
||||
const TAP_MAX_MOVE = 6;
|
||||
const TAP_MAX_MS = 250;
|
||||
|
||||
type Callbacks = {
|
||||
overlay: StoryOverlay;
|
||||
rect: Rect;
|
||||
onChange: (next: StoryOverlay) => void;
|
||||
onTap: () => void;
|
||||
/** Ziehen beginnt/läuft/endet — der Editor zeigt dann den Papierkorb. */
|
||||
onDragStateChange: (dragging: boolean, pageY: number) => void;
|
||||
/** Loslassen über dem Papierkorb? (pageY des Fingers) */
|
||||
isOverTrash: (pageY: number) => boolean;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
type Props = Callbacks & { mentionName?: string };
|
||||
|
||||
/**
|
||||
* Ein verschiebbares Overlay im Story-Editor. Gesten mit React-Natives
|
||||
* eingebautem PanResponder statt gesture-handler/reanimated: Die sind zwar
|
||||
* über expo-router installiert, aber ob sie im Store-Build 1.0.6 nativ stecken,
|
||||
* ist nicht sicher belegt — ein OTA-Update mit ihrem Import würde dann beim
|
||||
* Start abstürzen. PanResponder ist RN-Core und damit OTA-sicher.
|
||||
*
|
||||
* 1 Finger: verschieben · 2 Finger: zoomen + drehen (+ verschieben) ·
|
||||
* kurz tippen: bearbeiten · auf den Papierkorb ziehen: löschen.
|
||||
*/
|
||||
export function EditableOverlay({ mentionName, ...callbacks }: Props) {
|
||||
// Die Gesten-Handler leben über Renders hinweg und lesen den aktuellen Stand
|
||||
// erst beim Berühren aus diesem Ref (klassisches PanResponder-Muster).
|
||||
const latest = useRef<Callbacks>(callbacks);
|
||||
useLayoutEffect(() => {
|
||||
latest.current = callbacks;
|
||||
});
|
||||
const getLatest = useCallback(() => latest.current, []);
|
||||
// Die Fabrik legt nur Handler an; gelesen wird der Ref erst bei Berührung, nie beim Rendern.
|
||||
// eslint-disable-next-line react-hooks/refs -- PanResponder-Muster, siehe createOverlayResponder
|
||||
const [responder] = useState(() => createOverlayResponder(getLatest));
|
||||
|
||||
return (
|
||||
<PositionedOverlay overlay={callbacks.overlay} rect={callbacks.rect} interactive>
|
||||
<View {...responder.panHandlers} hitSlop={12}>
|
||||
<OverlayContent overlay={callbacks.overlay} baseWidth={callbacks.rect.width} mentionName={mentionName} />
|
||||
</View>
|
||||
</PositionedOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestenlogik außerhalb der Komponente: hält ihren Zustand in der Closure und
|
||||
* liest Props nur in Event-Handlern (nie während des Renderns).
|
||||
*/
|
||||
function createOverlayResponder(getLatest: () => Callbacks): PanResponderInstance {
|
||||
const g = {
|
||||
base: null as StoryOverlay | null,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
touches: 0,
|
||||
startDistance: 1,
|
||||
startAngle: 0,
|
||||
startCentroid: { x: 0, y: 0 },
|
||||
startedAt: 0,
|
||||
moved: 0,
|
||||
};
|
||||
|
||||
// Fingerzahl geändert → neue Ausgangslage, sonst springt das Overlay.
|
||||
function rebase(evt: GestureResponderEvent, state: Pick<PanResponderGestureState, "dx" | "dy">) {
|
||||
const touches = evt.nativeEvent.touches;
|
||||
g.base = getLatest().overlay;
|
||||
g.offsetX = state.dx;
|
||||
g.offsetY = state.dy;
|
||||
g.touches = touches.length;
|
||||
if (touches.length >= 2) {
|
||||
const geo = touchGeometry(touches[0], touches[1]);
|
||||
g.startDistance = geo.distance;
|
||||
g.startAngle = geo.angle;
|
||||
g.startCentroid = { x: (touches[0].pageX + touches[1].pageX) / 2, y: (touches[0].pageY + touches[1].pageY) / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
function finish(evt: GestureResponderEvent) {
|
||||
const { pageY } = evt.nativeEvent;
|
||||
getLatest().onDragStateChange(false, pageY);
|
||||
if (g.moved <= TAP_MAX_MOVE && Date.now() - g.startedAt <= TAP_MAX_MS) {
|
||||
getLatest().onTap();
|
||||
} else if (getLatest().isOverTrash(pageY)) {
|
||||
getLatest().onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
return PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderTerminationRequest: () => false,
|
||||
onPanResponderGrant: (evt) => {
|
||||
g.startedAt = Date.now();
|
||||
g.moved = 0;
|
||||
rebase(evt, { dx: 0, dy: 0 });
|
||||
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
|
||||
},
|
||||
onPanResponderMove: (evt, state) => {
|
||||
const touches = evt.nativeEvent.touches;
|
||||
if (touches.length !== g.touches) rebase(evt, state);
|
||||
g.moved = Math.max(g.moved, Math.hypot(state.dx, state.dy));
|
||||
|
||||
let pinch = 1;
|
||||
let rotationDelta = 0;
|
||||
let dx = state.dx - g.offsetX;
|
||||
let dy = state.dy - g.offsetY;
|
||||
if (touches.length >= 2 && g.startDistance > 0) {
|
||||
const now = touchGeometry(touches[0], touches[1]);
|
||||
pinch = now.distance / g.startDistance;
|
||||
rotationDelta = angleDelta(g.startAngle, now.angle);
|
||||
dx = (touches[0].pageX + touches[1].pageX) / 2 - g.startCentroid.x;
|
||||
dy = (touches[0].pageY + touches[1].pageY) / 2 - g.startCentroid.y;
|
||||
g.moved = Math.max(g.moved, TAP_MAX_MOVE + 1);
|
||||
}
|
||||
const base = g.base ?? getLatest().overlay;
|
||||
getLatest().onChange(applyGesture(base, getLatest().rect, { dx, dy, pinch, rotationDelta }));
|
||||
getLatest().onDragStateChange(true, evt.nativeEvent.pageY);
|
||||
},
|
||||
onPanResponderRelease: (evt) => finish(evt),
|
||||
onPanResponderTerminate: (evt) => finish(evt),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, FlatList, ActivityIndicator, useWindowDimensions, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { PickerSheet } from "./PickerSheet";
|
||||
|
||||
export type PickedGif = { id: string; url: string; width: number; height: number };
|
||||
|
||||
const COLUMNS = 3;
|
||||
const GAP = 6;
|
||||
|
||||
/**
|
||||
* GIF-Suche über unseren Server (stories.searchGifs → GIPHY, nur Freigabe G).
|
||||
* Ohne Suchbegriff erscheinen die aktuellen Trends. „Powered by GIPHY“ ist
|
||||
* Pflicht laut GIPHY-Bedingungen.
|
||||
*/
|
||||
export function GifPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (gif: PickedGif) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const { width } = useWindowDimensions();
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounce(query.trim(), 400);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [items, setItems] = useState<PickedGif[]>([]);
|
||||
|
||||
// Neue Suche → von vorne
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Liste beim Suchwechsel leeren ist gewollt
|
||||
setOffset(0);
|
||||
setItems([]);
|
||||
}, [debounced]);
|
||||
|
||||
const { data, isFetching, isError } = useQuery({
|
||||
...trpc.stories.searchGifs.queryOptions({ query: debounced, offset }),
|
||||
enabled: visible,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Seiten werden angehängt, sobald sie da sind
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((g) => g.id));
|
||||
return [...prev, ...data.gifs.filter((g) => !seen.has(g.id))];
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const tile = (width - 32 - GAP * (COLUMNS - 1)) / COLUMNS;
|
||||
|
||||
return (
|
||||
<PickerSheet visible={visible} title={t("storyEditor.gifTitle")} onClose={onClose}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("storyEditor.gifSearch")}
|
||||
placeholderTextColor="#a3a3a3"
|
||||
autoCorrect={false}
|
||||
style={styles.search}
|
||||
/>
|
||||
{isError && <Text style={styles.hint}>{t("storyEditor.gifError")}</Text>}
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(g) => g.id}
|
||||
numColumns={COLUMNS}
|
||||
columnWrapperStyle={{ gap: GAP }}
|
||||
contentContainerStyle={{ gap: GAP }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onEndReachedThreshold={0.6}
|
||||
onEndReached={() => {
|
||||
if (data?.nextOffset && !isFetching) setOffset(data.nextOffset);
|
||||
}}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onPress={() => onSelect(item)} accessibilityRole="button" accessibilityLabel="GIF">
|
||||
<Image source={{ uri: item.url }} style={{ width: tile, height: tile, borderRadius: 8, backgroundColor: "#2a2a2a" }} contentFit="cover" autoplay alt="GIF" />
|
||||
</Pressable>
|
||||
)}
|
||||
ListFooterComponent={isFetching ? <ActivityIndicator color="#f97316" style={{ marginVertical: 12 }} /> : null}
|
||||
/>
|
||||
<View style={styles.attribution}>
|
||||
<Text style={styles.attributionText}>Powered by GIPHY</Text>
|
||||
</View>
|
||||
</PickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
|
||||
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 13 },
|
||||
attribution: { alignItems: "center", paddingTop: 4 },
|
||||
attributionText: { color: "#a3a3a3", fontFamily: fontFamily.bold, fontSize: 12, letterSpacing: 0.5 },
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { Text, TextInput, Pressable, FlatList, ActivityIndicator, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
import { PetAvatar } from "../PetAvatar";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { PickerSheet } from "./PickerSheet";
|
||||
|
||||
/**
|
||||
* Tier zum Markieren suchen. Der Server liefert nur Tiere, die man laut ihrer
|
||||
* Einstellung „Wer darf die Follower sehen?“ markieren darf (und keine
|
||||
* blockierten) — darauf weist der Hinweis unten hin.
|
||||
*/
|
||||
export function MentionPicker({
|
||||
visible,
|
||||
petId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
petId: string;
|
||||
onSelect: (pet: { id: string; name: string }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const trpc = useTRPC();
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounce(query.trim(), 300);
|
||||
|
||||
const { data: pets = [], isFetching } = useQuery({
|
||||
...trpc.stories.searchMentionable.queryOptions({ petId, query: debounced }),
|
||||
enabled: visible && debounced.length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<PickerSheet visible={visible} title={t("storyEditor.mentionTitle")} onClose={onClose}>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t("storyEditor.mentionSearch")}
|
||||
placeholderTextColor="#a3a3a3"
|
||||
autoFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={styles.search}
|
||||
/>
|
||||
{isFetching && <ActivityIndicator color="#f97316" />}
|
||||
<FlatList
|
||||
data={debounced ? pets : []}
|
||||
keyExtractor={(p) => p.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => (
|
||||
<Pressable style={styles.row} onPress={() => onSelect({ id: item.id, name: item.name })} accessibilityRole="button">
|
||||
<PetAvatar name={item.name} avatarKey={item.avatarKey} size="sm" owner={item.owner} />
|
||||
<Text style={styles.name}>{item.name}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
debounced && !isFetching ? <Text style={styles.hint}>{t("storyEditor.mentionNoResults")}</Text> : null
|
||||
}
|
||||
/>
|
||||
<Text style={styles.hint}>{t("storyEditor.mentionPrivacyHint")}</Text>
|
||||
</PickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
search: { backgroundColor: "#2a2a2a", color: "#ffffff", borderRadius: 12, paddingHorizontal: 14, height: 44, fontFamily: fontFamily.regular, fontSize: 15 },
|
||||
row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10 },
|
||||
name: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 15 },
|
||||
hint: { color: "#a3a3a3", fontFamily: fontFamily.regular, fontSize: 12, paddingVertical: 6 },
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { memo } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import Svg, { Circle, Path, Rect as SvgRect } from "react-native-svg";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { OVERLAY_SIZES, contrastTextColor, type StoryOverlay, type TextOverlay } from "../../lib/story-overlays";
|
||||
import { STORY_STICKERS, STICKER_OUTLINE, type StickerPart } from "../../lib/story-stickers";
|
||||
|
||||
/**
|
||||
* Sichtbarer Inhalt eines Story-Overlays, ohne Position/Drehung/Skalierung —
|
||||
* das übernehmen StoryOverlayLayer (Anzeige) bzw. EditableOverlay (Editor).
|
||||
* Alle Größen hängen nur von `baseWidth` (Breite der Medienfläche) ab, damit
|
||||
* es auf jedem Bildschirm und im Web (StoryOverlayLayer.tsx dort) gleich aussieht.
|
||||
*/
|
||||
export const OverlayContent = memo(function OverlayContent({
|
||||
overlay,
|
||||
baseWidth,
|
||||
mentionName,
|
||||
}: {
|
||||
overlay: StoryOverlay;
|
||||
baseWidth: number;
|
||||
mentionName?: string;
|
||||
}) {
|
||||
switch (overlay.kind) {
|
||||
case "text":
|
||||
return <TextContent overlay={overlay} baseWidth={baseWidth} />;
|
||||
case "mention":
|
||||
return <MentionChip name={mentionName ?? "…"} baseWidth={baseWidth} />;
|
||||
case "gif": {
|
||||
const width = OVERLAY_SIZES.gifWidth * baseWidth;
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: overlay.url }}
|
||||
style={{ width, height: (width * overlay.height) / overlay.width, borderRadius: baseWidth * 0.02 }}
|
||||
autoplay
|
||||
contentFit="cover"
|
||||
accessibilityLabel="GIF"
|
||||
alt="GIF"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "sticker":
|
||||
return <StickerContent stickerId={overlay.stickerId} size={OVERLAY_SIZES.sticker * baseWidth} />;
|
||||
}
|
||||
});
|
||||
|
||||
function TextContent({ overlay, baseWidth }: { overlay: TextOverlay; baseWidth: number }) {
|
||||
const fontSize = OVERLAY_SIZES.textFont * baseWidth;
|
||||
const { textColor, background } = textAppearance(overlay);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
maxWidth: OVERLAY_SIZES.textMaxWidth * baseWidth,
|
||||
backgroundColor: background,
|
||||
borderRadius: fontSize * 0.3,
|
||||
paddingHorizontal: background ? fontSize * 0.4 : 0,
|
||||
paddingVertical: background ? fontSize * 0.18 : 0,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
{
|
||||
fontSize,
|
||||
lineHeight: fontSize * 1.2,
|
||||
color: textColor,
|
||||
fontFamily: overlay.font === "bold" ? fontFamily.bold : fontFamily.regular,
|
||||
},
|
||||
!background && styles.textShadow,
|
||||
]}
|
||||
>
|
||||
{overlay.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Farben je Textstil — identische Regeln im Web (src/components/stories/StoryOverlayLayer.tsx). */
|
||||
export function textAppearance(overlay: Pick<TextOverlay, "color" | "style">): { textColor: string; background?: string } {
|
||||
switch (overlay.style) {
|
||||
case "box":
|
||||
return { textColor: contrastTextColor(overlay.color), background: overlay.color };
|
||||
case "soft":
|
||||
return {
|
||||
textColor: overlay.color,
|
||||
background: overlay.color === "#171717" ? "rgba(255,255,255,0.85)" : "rgba(0,0,0,0.55)",
|
||||
};
|
||||
default:
|
||||
return { textColor: overlay.color };
|
||||
}
|
||||
}
|
||||
|
||||
export function MentionChip({ name, baseWidth }: { name: string; baseWidth: number }) {
|
||||
const fontSize = OVERLAY_SIZES.mentionFont * baseWidth;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: fontSize,
|
||||
paddingHorizontal: fontSize * 0.6,
|
||||
paddingVertical: fontSize * 0.25,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize, lineHeight: fontSize * 1.25, color: "#f97316", fontFamily: fontFamily.bold }} numberOfLines={1}>
|
||||
@{name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function StickerContent({ stickerId, size }: { stickerId: string; size: number }) {
|
||||
const sticker = STORY_STICKERS.find((s) => s.id === stickerId);
|
||||
if (!sticker) return null;
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="-6 -6 112 112" accessibilityLabel={sticker.label}>
|
||||
{sticker.parts.map((part, i) => (
|
||||
<StickerShape key={`o${i}`} part={part} fill={STICKER_OUTLINE.color} stroke={STICKER_OUTLINE.color} strokeWidth={STICKER_OUTLINE.width} />
|
||||
))}
|
||||
{sticker.parts.map((part, i) => (
|
||||
<StickerShape key={`f${i}`} part={part} fill={part.fill} />
|
||||
))}
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerShape({ part, fill, stroke, strokeWidth }: { part: StickerPart; fill: string; stroke?: string; strokeWidth?: number }) {
|
||||
const strokeProps = stroke ? { stroke, strokeWidth, strokeLinejoin: "round" as const } : {};
|
||||
switch (part.type) {
|
||||
case "circle":
|
||||
return <Circle cx={part.cx} cy={part.cy} r={part.r} fill={fill} {...strokeProps} />;
|
||||
case "rect":
|
||||
return <SvgRect x={part.x} y={part.y} width={part.width} height={part.height} rx={part.rx} fill={fill} {...strokeProps} />;
|
||||
case "path":
|
||||
return <Path d={part.d} fill={fill} {...strokeProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: { textAlign: "center" },
|
||||
textShadow: { textShadowColor: "rgba(0,0,0,0.45)", textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 6 },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Modal, View, Text, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
|
||||
/** Dunkles Bottom-Sheet für die Auswahlfenster des Story-Editors (Markieren, GIF, Sticker). */
|
||||
export function PickerSheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<Pressable style={styles.dismiss} onPress={onClose} accessibilityLabel={title} />
|
||||
<View style={[styles.sheet, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button">
|
||||
<X color="#ffffff" size={22} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, justifyContent: "flex-end" },
|
||||
dismiss: { flex: 1 },
|
||||
sheet: { backgroundColor: "#1c1c1c", borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingTop: 12, paddingHorizontal: 16, maxHeight: "75%", gap: 12 },
|
||||
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||
title: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 17 },
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { View, Pressable, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { STORY_STICKERS } from "../../lib/story-stickers";
|
||||
import { StickerContent } from "./OverlayContent";
|
||||
import { PickerSheet } from "./PickerSheet";
|
||||
|
||||
export function StickerPicker({ visible, onSelect, onClose }: { visible: boolean; onSelect: (stickerId: string) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PickerSheet visible={visible} title={t("storyEditor.stickerTitle")} onClose={onClose}>
|
||||
<View style={styles.grid}>
|
||||
{STORY_STICKERS.map((sticker) => (
|
||||
<Pressable
|
||||
key={sticker.id}
|
||||
style={styles.cell}
|
||||
onPress={() => onSelect(sticker.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={sticker.label}
|
||||
>
|
||||
<StickerContent stickerId={sticker.id} size={64} />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</PickerSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
grid: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, paddingBottom: 8 },
|
||||
cell: { width: "23%", aspectRatio: 1, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: "#2a2a2a" },
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { STORY_BACKGROUNDS } from "../../lib/story-overlays";
|
||||
|
||||
/**
|
||||
* Farbverlauf einer Text-Story. Über react-native-svg statt expo-linear-gradient:
|
||||
* letzteres wäre ein neues natives Modul (= Store-Build), svg ist schon drin.
|
||||
*/
|
||||
export function StoryBackground({ backgroundKey, width, height, radius = 0 }: { backgroundKey: string; width: number; height: number; radius?: number }) {
|
||||
const [from, to] = STORY_BACKGROUNDS[backgroundKey] ?? STORY_BACKGROUNDS.sunset;
|
||||
const id = `bg-${backgroundKey}`;
|
||||
return (
|
||||
<Svg width={width} height={height}>
|
||||
<Defs>
|
||||
<LinearGradient id={id} x1="0" y1="0" x2="0.35" y2="1">
|
||||
<Stop offset="0" stopColor={from} />
|
||||
<Stop offset="1" stopColor={to} />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Rect x={0} y={0} width={width} height={height} rx={radius} fill={`url(#${id})`} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, Image as RNImage, ActivityIndicator, Alert, StyleSheet, type LayoutChangeEvent } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { AtSign, Smile, Trash2, Type, X } from "lucide-react-native";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import {
|
||||
MAX_GIFS,
|
||||
MAX_MENTIONS,
|
||||
MAX_OVERLAYS,
|
||||
STORY_BACKGROUND_KEYS,
|
||||
STORY_BACKGROUNDS,
|
||||
TEXT_STORY_ASPECT,
|
||||
containRect,
|
||||
type StoryOverlay,
|
||||
type TextOverlay,
|
||||
} from "../../lib/story-overlays";
|
||||
import { EditableOverlay } from "./EditableOverlay";
|
||||
import { StoryBackground } from "./StoryBackground";
|
||||
import { TextEditorModal, type TextDraft } from "./TextEditorModal";
|
||||
import { MentionPicker } from "./MentionPicker";
|
||||
import { GifPicker } from "./GifPicker";
|
||||
import { StickerPicker } from "./StickerPicker";
|
||||
|
||||
export type EditorMedia =
|
||||
| { kind: "image"; uri: string; width: number; height: number }
|
||||
| { kind: "video"; uri: string; width: number; height: number; mimeType?: string }
|
||||
| { kind: "text" };
|
||||
|
||||
export type EditorResult = { overlays: StoryOverlay[]; background?: string; mentionNames: Record<string, string> };
|
||||
|
||||
type Picker = "text" | "mention" | "gif" | "sticker" | null;
|
||||
|
||||
const TRASH_ZONE_HEIGHT = 120;
|
||||
|
||||
/**
|
||||
* Story-Editor (feature/story-editor): Foto, Video oder Farbverlauf als Fläche,
|
||||
* darauf Text, @-Markierungen, GIFs und Sticker. Positionen werden relativ zur
|
||||
* Medienfläche gespeichert (lib/story-overlays.ts), damit sie im Viewer — App
|
||||
* und Web — an derselben Stelle sitzen.
|
||||
*/
|
||||
export function StoryEditor({
|
||||
media,
|
||||
petId,
|
||||
isSubmitting,
|
||||
submitLabel,
|
||||
progress,
|
||||
toolsEnabled = true,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
media: EditorMedia;
|
||||
/** Notschalter aus → nur Vorschau + Teilen, keine Werkzeuge (wie vor dem Editor). */
|
||||
toolsEnabled?: boolean;
|
||||
petId: string;
|
||||
isSubmitting: boolean;
|
||||
submitLabel: string;
|
||||
/** Upload-Fortschritt/Status unter dem Teilen-Knopf (Video). */
|
||||
progress?: React.ReactNode;
|
||||
onSubmit: (result: EditorResult) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [canvas, setCanvas] = useState({ width: 0, height: 0 });
|
||||
const [overlays, setOverlays] = useState<StoryOverlay[]>([]);
|
||||
const [background, setBackground] = useState<string>(STORY_BACKGROUND_KEYS[0]);
|
||||
const [mentionNames, setMentionNames] = useState<Record<string, string>>({});
|
||||
const [picker, setPicker] = useState<Picker>(media.kind === "text" ? "text" : null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [overTrash, setOverTrash] = useState(false);
|
||||
const canvasTopRef = useRef(0);
|
||||
const canvasRef = useRef<View>(null);
|
||||
|
||||
const mediaSize = media.kind === "text" ? TEXT_STORY_ASPECT : { width: media.width, height: media.height };
|
||||
const rect = useMemo(
|
||||
() => containRect(canvas.width, canvas.height, mediaSize.width, mediaSize.height),
|
||||
[canvas.width, canvas.height, mediaSize.width, mediaSize.height]
|
||||
);
|
||||
|
||||
const onCanvasLayout = (e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setCanvas({ width, height });
|
||||
canvasRef.current?.measureInWindow((_x, y) => {
|
||||
canvasTopRef.current = y;
|
||||
});
|
||||
};
|
||||
|
||||
const trashTop = () => canvasTopRef.current + canvas.height - TRASH_ZONE_HEIGHT;
|
||||
const isOverTrash = useCallback((pageY: number) => pageY >= trashTop(), [canvas.height]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const gifCount = overlays.filter((o) => o.kind === "gif").length;
|
||||
const mentionCount = new Set(overlays.flatMap((o) => (o.kind === "mention" ? [o.petId] : []))).size;
|
||||
const isFull = overlays.length >= MAX_OVERLAYS;
|
||||
|
||||
const add = (overlay: StoryOverlay) => {
|
||||
if (isFull) {
|
||||
Alert.alert(t("storyEditor.limitOverlays", { max: MAX_OVERLAYS }));
|
||||
return;
|
||||
}
|
||||
setOverlays((list) => [...list, overlay]);
|
||||
};
|
||||
|
||||
// Neue Overlays erscheinen leicht versetzt in der Mitte, damit sie sich nicht genau überdecken.
|
||||
const spawnPosition = () => {
|
||||
const offset = (overlays.length % 5) * 0.04;
|
||||
return { x: 0.5, y: 0.42 + offset, scale: 1, rotation: 0 };
|
||||
};
|
||||
|
||||
const handleTextDone = (draft: TextDraft) => {
|
||||
const text = draft.text.trim();
|
||||
if (editingIndex !== null) {
|
||||
setOverlays((list) =>
|
||||
text
|
||||
? list.map((o, i) => (i === editingIndex && o.kind === "text" ? { ...o, ...draft, text } : o))
|
||||
: list.filter((_, i) => i !== editingIndex)
|
||||
);
|
||||
} else if (text) {
|
||||
add({ kind: "text", ...spawnPosition(), ...draft, text });
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setPicker(null);
|
||||
};
|
||||
|
||||
const editingText = editingIndex !== null && overlays[editingIndex]?.kind === "text" ? (overlays[editingIndex] as TextOverlay) : undefined;
|
||||
const hasText = overlays.some((o) => o.kind === "text");
|
||||
const canSubmit = !isSubmitting && rect.width > 0 && (media.kind !== "text" || hasText);
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View ref={canvasRef} style={styles.canvas} onLayout={onCanvasLayout}>
|
||||
{rect.width > 0 && (
|
||||
<View style={{ position: "absolute", left: rect.x, top: rect.y, width: rect.width, height: rect.height }}>
|
||||
{media.kind === "text" ? (
|
||||
<StoryBackground backgroundKey={background} width={rect.width} height={rect.height} />
|
||||
) : media.kind === "video" ? (
|
||||
<EditorVideo uri={media.uri} />
|
||||
) : (
|
||||
<RNImage source={{ uri: media.uri }} style={StyleSheet.absoluteFill} resizeMode="cover" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{overlays.map((overlay, index) => (
|
||||
<EditableOverlay
|
||||
key={index}
|
||||
overlay={overlay}
|
||||
rect={rect}
|
||||
mentionName={overlay.kind === "mention" ? mentionNames[overlay.petId] : undefined}
|
||||
onChange={(next) => setOverlays((list) => list.map((o, i) => (i === index ? next : o)))}
|
||||
onTap={() => {
|
||||
if (overlay.kind === "text") {
|
||||
setEditingIndex(index);
|
||||
setPicker("text");
|
||||
}
|
||||
}}
|
||||
onDragStateChange={(isDragging, pageY) => {
|
||||
setDragging(isDragging);
|
||||
setOverTrash(isDragging && isOverTrash(pageY));
|
||||
}}
|
||||
isOverTrash={isOverTrash}
|
||||
onDelete={() => setOverlays((list) => list.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{toolsEnabled && overlays.length === 0 && media.kind !== "text" && rect.width > 0 && (
|
||||
<View pointerEvents="none" style={[styles.hintWrap, { top: rect.y + rect.height / 2 - 20 }]}>
|
||||
<Text style={styles.hint}>{t("storyEditor.emptyHint")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<View pointerEvents="none" style={[styles.trashZone, { height: TRASH_ZONE_HEIGHT }]}>
|
||||
<View style={[styles.trashCircle, overTrash && styles.trashCircleActive]}>
|
||||
<Trash2 color="#ffffff" size={24} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.topBar, { top: insets.top + 8 }]}>
|
||||
<ToolButton onPress={onCancel} label={t("common.cancel")}>
|
||||
<X color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
{toolsEnabled && <View style={styles.tools}>
|
||||
{media.kind === "text" && (
|
||||
<ToolButton
|
||||
label={t("storyEditor.background")}
|
||||
onPress={() => setBackground((bg) => STORY_BACKGROUND_KEYS[(STORY_BACKGROUND_KEYS.indexOf(bg) + 1) % STORY_BACKGROUND_KEYS.length])}
|
||||
>
|
||||
<View style={[styles.swatch, { backgroundColor: STORY_BACKGROUNDS[background][0], borderColor: STORY_BACKGROUNDS[background][1] }]} />
|
||||
</ToolButton>
|
||||
)}
|
||||
<ToolButton label={t("storyEditor.addText")} onPress={() => setPicker("text")}>
|
||||
<Type color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={t("storyEditor.addMention")}
|
||||
onPress={() => (mentionCount >= MAX_MENTIONS ? Alert.alert(t("storyEditor.limitMentions", { max: MAX_MENTIONS })) : setPicker("mention"))}
|
||||
>
|
||||
<AtSign color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
label={t("storyEditor.addGif")}
|
||||
onPress={() => (gifCount >= MAX_GIFS ? Alert.alert(t("storyEditor.limitGifs", { max: MAX_GIFS })) : setPicker("gif"))}
|
||||
>
|
||||
<Text style={styles.gifLabel}>GIF</Text>
|
||||
</ToolButton>
|
||||
<ToolButton label={t("storyEditor.addSticker")} onPress={() => setPicker("sticker")}>
|
||||
<Smile color="#ffffff" size={22} />
|
||||
</ToolButton>
|
||||
</View>}
|
||||
</View>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<Pressable
|
||||
style={[styles.shareButton, !canSubmit && styles.shareButtonDisabled]}
|
||||
disabled={!canSubmit}
|
||||
onPress={() => onSubmit({ overlays, background: media.kind === "text" ? background : undefined, mentionNames })}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.shareText}>{submitLabel}</Text>}
|
||||
</Pressable>
|
||||
{progress}
|
||||
</View>
|
||||
|
||||
<TextEditorModal
|
||||
visible={picker === "text"}
|
||||
initial={editingText ? { text: editingText.text, color: editingText.color, style: editingText.style, font: editingText.font } : undefined}
|
||||
onDone={handleTextDone}
|
||||
onCancel={() => {
|
||||
setEditingIndex(null);
|
||||
setPicker(null);
|
||||
if (media.kind === "text" && !hasText && editingIndex === null && overlays.length === 0) onCancel();
|
||||
}}
|
||||
/>
|
||||
<MentionPicker
|
||||
visible={picker === "mention"}
|
||||
petId={petId}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(pet) => {
|
||||
setMentionNames((names) => ({ ...names, [pet.id]: pet.name }));
|
||||
if (!overlays.some((o) => o.kind === "mention" && o.petId === pet.id)) add({ kind: "mention", ...spawnPosition(), petId: pet.id });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
<GifPicker
|
||||
visible={picker === "gif"}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(gif) => {
|
||||
add({ kind: "gif", ...spawnPosition(), gifId: gif.id, url: gif.url, width: gif.width, height: gif.height });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
<StickerPicker
|
||||
visible={picker === "sticker"}
|
||||
onClose={() => setPicker(null)}
|
||||
onSelect={(stickerId) => {
|
||||
add({ kind: "sticker", ...spawnPosition(), stickerId });
|
||||
setPicker(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolButton({ label, onPress, children }: { label: string; onPress: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<Pressable style={styles.toolButton} onPress={onPress} hitSlop={6} accessibilityRole="button" accessibilityLabel={label}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stumm in Schleife — eigene Komponente, damit useVideoPlayer nicht bedingt aufgerufen wird. */
|
||||
function EditorVideo({ uri }: { uri: string }) {
|
||||
const player = useVideoPlayer(uri, (p) => {
|
||||
p.loop = true;
|
||||
p.muted = true;
|
||||
p.play();
|
||||
});
|
||||
return <VideoView player={player} style={StyleSheet.absoluteFill} contentFit="cover" nativeControls={false} />;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1, backgroundColor: "#111111" },
|
||||
canvas: { flex: 1 },
|
||||
topBar: { position: "absolute", left: 12, right: 12, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
|
||||
tools: { flexDirection: "row", gap: 8 },
|
||||
toolButton: { width: 42, height: 42, borderRadius: 21, backgroundColor: "rgba(0,0,0,0.45)", alignItems: "center", justifyContent: "center" },
|
||||
gifLabel: { color: "#ffffff", fontFamily: fontFamily.extraBold, fontSize: 13, letterSpacing: 0.5 },
|
||||
swatch: { width: 22, height: 22, borderRadius: 11, borderWidth: 3 },
|
||||
bottomBar: { paddingHorizontal: 16, paddingTop: 12, gap: 8, backgroundColor: "#111111" },
|
||||
shareButton: { height: 48, borderRadius: 24, backgroundColor: "#f97316", alignItems: "center", justifyContent: "center" },
|
||||
shareButtonDisabled: { opacity: 0.5 },
|
||||
shareText: { color: "#ffffff", fontFamily: fontFamily.bold, fontSize: 16 },
|
||||
hintWrap: { position: "absolute", left: 24, right: 24, alignItems: "center" },
|
||||
hint: {
|
||||
color: "#ffffff",
|
||||
fontFamily: fontFamily.semiBold,
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0,0,0,0.6)",
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
trashZone: { position: "absolute", left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
|
||||
trashCircle: { width: 56, height: 56, borderRadius: 28, backgroundColor: "rgba(0,0,0,0.55)", alignItems: "center", justifyContent: "center", borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
|
||||
trashCircleActive: { backgroundColor: "#dc2626", transform: [{ scale: 1.2 }] },
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { View, Pressable, StyleSheet } from "react-native";
|
||||
import { OverlayContent } from "./OverlayContent";
|
||||
import { overlayCenter, type Rect, type StoryOverlay } from "../../lib/story-overlays";
|
||||
|
||||
type MentionedPet = { id: string; name: string };
|
||||
|
||||
/**
|
||||
* Anzeige-Ebene im Story-Viewer: legt die Overlays auf die Medienfläche `rect`
|
||||
* (gleiches Koordinatensystem wie im Editor). Nur Markierungen sind antippbar
|
||||
* (→ Tierprofil), alles andere lässt Berührungen zu den Weiter/Zurück-Zonen durch.
|
||||
*/
|
||||
export function StoryOverlayLayer({
|
||||
overlays,
|
||||
rect,
|
||||
mentionedPets,
|
||||
onPressMention,
|
||||
}: {
|
||||
overlays: readonly StoryOverlay[];
|
||||
rect: Rect;
|
||||
mentionedPets: readonly MentionedPet[];
|
||||
onPressMention?: (petId: string) => void;
|
||||
}) {
|
||||
if (!overlays.length || rect.width <= 0) return null;
|
||||
const names = new Map(mentionedPets.map((p) => [p.id, p.name]));
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
||||
{overlays.map((overlay, i) => {
|
||||
const content = (
|
||||
<OverlayContent overlay={overlay} baseWidth={rect.width} mentionName={overlay.kind === "mention" ? names.get(overlay.petId) : undefined} />
|
||||
);
|
||||
return (
|
||||
<PositionedOverlay key={i} overlay={overlay} rect={rect} interactive={overlay.kind === "mention" && !!onPressMention}>
|
||||
{overlay.kind === "mention" && onPressMention ? (
|
||||
<Pressable onPress={() => onPressMention(overlay.petId)} hitSlop={8} accessibilityRole="link">
|
||||
{content}
|
||||
</Pressable>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</PositionedOverlay>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zentriert den Inhalt auf (x, y) der Medienfläche, ohne ihn messen zu müssen:
|
||||
* ein großes, unsichtbares Quadrat um den Mittelpunkt mit zentriertem Inhalt.
|
||||
* Drehung und Skalierung wirken um die Mitte — wie im Web (transform-origin center).
|
||||
*/
|
||||
export function PositionedOverlay({
|
||||
overlay,
|
||||
rect,
|
||||
interactive,
|
||||
children,
|
||||
}: {
|
||||
overlay: StoryOverlay;
|
||||
rect: Rect;
|
||||
interactive: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const box = rect.width * 2;
|
||||
const { left, top } = overlayCenter(rect, overlay);
|
||||
return (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={{ position: "absolute", left: left - box / 2, top: top - box / 2, width: box, height: box, alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<View
|
||||
pointerEvents={interactive ? "auto" : "none"}
|
||||
style={{ transform: [{ rotate: `${overlay.rotation}deg` }, { scale: overlay.scale }] }}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fontFamily } from "../../theme/tokens";
|
||||
import { MAX_TEXT_LENGTH, TEXT_COLORS, TEXT_STYLES, type TextColor, type TextFont, type TextStyle } from "../../lib/story-overlays";
|
||||
import { textAppearance } from "./OverlayContent";
|
||||
|
||||
export type TextDraft = { text: string; color: TextColor; style: TextStyle; font: TextFont };
|
||||
|
||||
const DEFAULT_DRAFT: TextDraft = { text: "", color: "#ffffff", style: "plain", font: "bold" };
|
||||
|
||||
/**
|
||||
* Vollbild-Texteingabe über dem Editor (wie Instagram): Text tippen, Farbe
|
||||
* (8 Farben), Stil (schlicht → Box → weich) und Schrift (fett/normal) wählen.
|
||||
* Leerer Text beim Fertig-Drücken = Overlay entfernen bzw. nicht anlegen.
|
||||
*/
|
||||
export function TextEditorModal({
|
||||
visible,
|
||||
initial,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
visible: boolean;
|
||||
initial?: TextDraft;
|
||||
onDone: (draft: TextDraft) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<TextDraft>(initial ?? DEFAULT_DRAFT);
|
||||
|
||||
// Beim Öffnen mit dem bearbeiteten Overlay bzw. leer starten.
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Zurücksetzen beim Öffnen ist hier gewollt
|
||||
if (visible) setDraft(initial ?? DEFAULT_DRAFT);
|
||||
}, [visible, initial]);
|
||||
|
||||
const { textColor, background } = textAppearance(draft);
|
||||
const nextStyle = TEXT_STYLES[(TEXT_STYLES.indexOf(draft.style) + 1) % TEXT_STYLES.length];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel} statusBarTranslucent>
|
||||
<KeyboardAvoidingView style={styles.backdrop} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable onPress={onCancel} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={styles.topButton}>{t("common.cancel")}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.topTools}>
|
||||
<Pressable
|
||||
style={styles.toolButton}
|
||||
onPress={() => setDraft((d) => ({ ...d, style: nextStyle }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.textStyle")}
|
||||
>
|
||||
<Text style={styles.toolButtonText}>{t(`storyEditor.style.${draft.style}`)}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.toolButton}
|
||||
onPress={() => setDraft((d) => ({ ...d, font: d.font === "bold" ? "regular" : "bold" }))}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("storyEditor.textFont")}
|
||||
>
|
||||
<Text style={[styles.toolButtonText, { fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular }]}>Aa</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable onPress={() => onDone(draft)} hitSlop={12} accessibilityRole="button">
|
||||
<Text style={[styles.topButton, styles.done]}>{t("common.done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.center}>
|
||||
<View style={[styles.preview, background ? { backgroundColor: background } : null]}>
|
||||
<TextInput
|
||||
value={draft.text}
|
||||
onChangeText={(text) => setDraft((d) => ({ ...d, text }))}
|
||||
autoFocus
|
||||
multiline
|
||||
maxLength={MAX_TEXT_LENGTH}
|
||||
placeholder={t("storyEditor.textPlaceholder")}
|
||||
placeholderTextColor="rgba(255,255,255,0.6)"
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: textColor, fontFamily: draft.font === "bold" ? fontFamily.bold : fontFamily.regular },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.colors}>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<Pressable
|
||||
key={color}
|
||||
onPress={() => setDraft((d) => ({ ...d, color }))}
|
||||
style={[styles.colorDot, { backgroundColor: color }, draft.color === color && styles.colorDotActive]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={color}
|
||||
accessibilityState={{ selected: draft.color === color }}
|
||||
hitSlop={4}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.7)", paddingTop: 48, paddingBottom: 16 },
|
||||
topBar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 },
|
||||
topButton: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 16 },
|
||||
done: { color: "#f97316" },
|
||||
topTools: { flexDirection: "row", gap: 10 },
|
||||
toolButton: { borderWidth: 1.5, borderColor: "#ffffff", borderRadius: 999, paddingHorizontal: 12, paddingVertical: 4 },
|
||||
toolButtonText: { color: "#ffffff", fontFamily: fontFamily.semiBold, fontSize: 14 },
|
||||
center: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24 },
|
||||
preview: { borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, maxWidth: "100%" },
|
||||
input: { fontSize: 28, lineHeight: 34, textAlign: "center", minWidth: 120, textShadowColor: "rgba(0,0,0,0.35)", textShadowRadius: 4 },
|
||||
colors: { flexDirection: "row", justifyContent: "center", gap: 12, paddingVertical: 8 },
|
||||
colorDot: { width: 28, height: 28, borderRadius: 14, borderWidth: 2, borderColor: "rgba(255,255,255,0.6)" },
|
||||
colorDotActive: { borderColor: "#ffffff", borderWidth: 3, transform: [{ scale: 1.15 }] },
|
||||
});
|
||||
@@ -20,6 +20,7 @@
|
||||
"done": "Fertig",
|
||||
"optional": "optional",
|
||||
"noResultsFound": "Keine Einträge gefunden.",
|
||||
"searchPlaceholder": "Suchen …",
|
||||
"edit": "Bearbeiten",
|
||||
"authLoadTimeout": "Das dauert gerade länger als erwartet. Bitte erneut versuchen."
|
||||
},
|
||||
@@ -48,7 +49,8 @@
|
||||
"feed": {
|
||||
"empty": "Noch keine Beiträge im Feed.",
|
||||
"storyLabel": "Story",
|
||||
"trendingBadge": "Trending"
|
||||
"trendingBadge": "Trending",
|
||||
"yourStory": "Deine Story"
|
||||
},
|
||||
"explore": {
|
||||
"allTab": "Alle",
|
||||
@@ -65,7 +67,7 @@
|
||||
"breedLabel": "Rasse",
|
||||
"birthdayLabel": "Geburtstag (optional)",
|
||||
"adoptedAtLabel": "Adoptionstag (optional)",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben oder leer lassen.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben oder leer lassen.",
|
||||
"deleteConfirmTitle": "Tier wirklich löschen?",
|
||||
"deleteConfirmMessage": "{{name}} und alle zugehörigen Beiträge, Kommentare und Follower-Beziehungen werden unwiderruflich gelöscht.",
|
||||
"deleteConfirmMessageFallback": "Dieses Tier",
|
||||
@@ -84,11 +86,16 @@
|
||||
"shareButton": "Teilen",
|
||||
"deleteConfirmTitle": "Beitrag wirklich löschen?",
|
||||
"deleteConfirmMessage": "Dieser Beitrag wird unwiderruflich gelöscht.",
|
||||
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut."
|
||||
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut.",
|
||||
"unsupportedFormat": "Einige Fotos haben ein Format, das PawFeed nicht verarbeiten kann, und wurden übersprungen."
|
||||
},
|
||||
"story": {
|
||||
"changePhotoHint": "Antippen, um ein anderes Foto zu wählen",
|
||||
"shareButton": "Story teilen"
|
||||
"changeVideoHint": "Antippen, um ein anderes Video zu wählen",
|
||||
"shareButton": "Story teilen",
|
||||
"videoTooLong": "Videos für Stories dürfen höchstens {{max}} Sekunden lang sein.",
|
||||
"preparingUpload": "Upload wird vorbereitet …",
|
||||
"uploadingVideo": "Video wird hochgeladen … {{percent}} %"
|
||||
},
|
||||
"milestoneTypes": {
|
||||
"BIRTHDAY": "Geburtstag",
|
||||
@@ -133,19 +140,25 @@
|
||||
"manageDesc": "Ein Widerruf löscht sofort und unwiderruflich alle Gesundheitsdaten dieses Haustiers.",
|
||||
"revokeLink": "Widerrufen",
|
||||
"weightTitle": "Gewicht",
|
||||
"weightDatePlaceholder": "TT-MM-JJJJ",
|
||||
"weightDatePlaceholder": "TT.MM.JJJJ",
|
||||
"weightKgPlaceholder": "Gewicht (kg), z.B. 4,2",
|
||||
"notesPlaceholder": "Notizen (optional)",
|
||||
"weightEmpty": "Noch keine Gewichtseinträge",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT-MM-JJJJ eingeben.",
|
||||
"weightChartTitle": "Verlauf",
|
||||
"weightChartSinceFirst": "seit erster Messung",
|
||||
"weightChartA11y": "Gewichtsverlauf von {{from}} am {{fromDate}} auf {{to}} am {{toDate}}. Tippen, um eine Messung auszuwählen.",
|
||||
"invalidDate": "Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
|
||||
"vetTitle": "Tierarzt",
|
||||
"vetReasonPlaceholder": "Grund, z.B. Jahresuntersuchung",
|
||||
"vetNamePlaceholder": "Tierarzt / Klinik (optional)",
|
||||
"vetEmpty": "Noch keine Tierarztbesuche",
|
||||
"vaccineTitle": "Impfungen",
|
||||
"vaccineNamePlaceholder": "Impfstoff, z.B. Tollwut, DHPP",
|
||||
"vaccineGivenPlaceholder": "Gegeben am (TT-MM-JJJJ)",
|
||||
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT-MM-JJJJ)",
|
||||
"vaccineGivenPlaceholder": "Gegeben am (TT.MM.JJJJ)",
|
||||
"vaccineNextDuePlaceholder": "Nächste Fälligkeit (optional, TT.MM.JJJJ)",
|
||||
"vaccineNextDuePreset_one": "+{{count}} Jahr",
|
||||
"vaccineNextDuePreset_other": "+{{count}} Jahre",
|
||||
"vaccineReminderHint": "Mit Folgetermin erinnern wir dich 30 Tage vorher.",
|
||||
"vaccineEmpty": "Noch keine Impfungen eingetragen",
|
||||
"vaccineGiven": "Gegeben {{date}}",
|
||||
"vaccineDue": "Fällig {{date}}",
|
||||
@@ -184,6 +197,8 @@
|
||||
"moreActions": "Weitere Aktionen"
|
||||
},
|
||||
"notifications": {
|
||||
"vaccineDue": "💉 Die Impfung „{{name}}“ ist am {{date}} fällig – Zeit, einen Termin zu vereinbaren.",
|
||||
"vaccineDueGeneric": "💉 Eine Impfung wird bald fällig.",
|
||||
"follow": "folgt dir jetzt.",
|
||||
"reaction": "hat deinen Beitrag mit einer Pfote versehen.",
|
||||
"comment": "hat deinen Beitrag kommentiert.",
|
||||
@@ -195,7 +210,10 @@
|
||||
"petSuggestion": "könnte dir gefallen!",
|
||||
"empty": "Keine Benachrichtigungen.",
|
||||
"referralJoined": "ist über deine Einladung zu PawFeed gekommen.",
|
||||
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!"
|
||||
"referralRankUp": "Du hast einen neuen Empfehlungsrang erreicht!",
|
||||
"storyMention": "hat dich in einer Story markiert.",
|
||||
"storyComment": "hat deine Story kommentiert.",
|
||||
"storyReaction": "hat deiner Story eine Pfote gegeben."
|
||||
},
|
||||
"pets": {
|
||||
"empty": "Noch keine Tiere angelegt.",
|
||||
@@ -399,5 +417,72 @@
|
||||
"source_STORE_SEARCH": "App Store oder Google Play",
|
||||
"source_PRESS": "Zeitung, Radio oder Blog",
|
||||
"source_OTHER": "Anders"
|
||||
},
|
||||
"storyViewer": {
|
||||
"paw": "Pfote geben",
|
||||
"removePaw": "Pfote zurücknehmen",
|
||||
"openComments": "Kommentare öffnen",
|
||||
"commentsTitle": "Kommentare",
|
||||
"deleteComment": "Kommentar löschen",
|
||||
"commentError": "Kommentar konnte nicht gesendet werden. Bitte versuch es noch einmal.",
|
||||
"viewersTitle_one": "{{count}} Aufruf",
|
||||
"viewersTitle_other": "{{count}} Aufrufe",
|
||||
"noViewers": "Noch hat niemand deine Story gesehen.",
|
||||
"deleteStory": "Story löschen",
|
||||
"deleteTitle": "Story löschen?",
|
||||
"deleteDescription": "Die Story wird mit allen Aufrufen, Pfoten und Kommentaren endgültig gelöscht.",
|
||||
"deleteConfirm": "Löschen",
|
||||
"deleteError": "Die Story konnte nicht gelöscht werden. Bitte versuch es noch einmal.",
|
||||
"expiredOwnerOnly": "Abgelaufen – nur für dich sichtbar",
|
||||
"readOnlyExpired": "Die Story ist abgelaufen. Kommentare kannst du noch lesen, aber nicht mehr schreiben.",
|
||||
"pawCount_one": "{{count}} Pfote",
|
||||
"pawCount_other": "{{count}} Pfoten"
|
||||
},
|
||||
"foundingMember": {
|
||||
"label": "Gründungsmitglied",
|
||||
"tooltip": "Gründungsmitglied – eines der ersten 250 Tiere auf PawFeed"
|
||||
},
|
||||
"privacy": {
|
||||
"heading": "Privatsphäre",
|
||||
"whoCanMessage": "Wer kann {{name}} Nachrichten schreiben?",
|
||||
"everyone": "Alle",
|
||||
"onlyFollowedPets": "Nur Tiere, denen ich folge",
|
||||
"dmFollowersOnlyHint": "Nur Tiere, denen du folgst, können dir Direktnachrichten schreiben.",
|
||||
"dmEveryoneHint": "Jedes Tier auf PawFeed kann dir Direktnachrichten schreiben.",
|
||||
"whoCanSeeFollowers": "Wer darf die Follower von {{name}} sehen?",
|
||||
"onlyMyFollowers": "Nur meine Follower",
|
||||
"onlyMe": "Nur ich",
|
||||
"followerEveryoneHint": "Jeder kann die Follower- und Folgt-Listen von {{name}} sehen und {{name}} in Storys markieren.",
|
||||
"followerFollowersOnlyHint": "Nur Tiere, die {{name}} folgen, können die Follower- und Folgt-Listen sehen und {{name}} in Storys markieren.",
|
||||
"followerNobodyHint": "Nur du kannst die Follower- und Folgt-Listen von {{name}} sehen — markieren in Storys kann {{name}} niemand.",
|
||||
"saveFailed": "Privatsphäre-Einstellung konnte nicht gespeichert werden"
|
||||
},
|
||||
"storyEditor": {
|
||||
"textStory": "Text-Story",
|
||||
"addText": "Text hinzufügen",
|
||||
"addMention": "Tier markieren",
|
||||
"addGif": "GIF hinzufügen",
|
||||
"addSticker": "Sticker hinzufügen",
|
||||
"background": "Hintergrund wechseln",
|
||||
"emptyHint": "Füge Text, @Markierungen, GIFs oder Sticker hinzu — oder teile direkt.",
|
||||
"textPlaceholder": "Schreib etwas …",
|
||||
"textStyle": "Textstil",
|
||||
"textFont": "Schrift",
|
||||
"style": {
|
||||
"plain": "Schlicht",
|
||||
"box": "Box",
|
||||
"soft": "Weich"
|
||||
},
|
||||
"mentionTitle": "Tier markieren",
|
||||
"mentionSearch": "Tiername suchen",
|
||||
"mentionNoResults": "Kein Tier gefunden, das du markieren darfst.",
|
||||
"mentionPrivacyHint": "Tiere, deren Follower nur für ihre Follower oder niemanden sichtbar sind, kannst du nur markieren, wenn du ihnen folgst bzw. gar nicht.",
|
||||
"gifTitle": "GIF auswählen",
|
||||
"gifSearch": "GIPHY durchsuchen",
|
||||
"gifError": "Die GIF-Suche ist gerade nicht erreichbar.",
|
||||
"stickerTitle": "Sticker",
|
||||
"limitOverlays": "Höchstens {{max}} Elemente pro Story.",
|
||||
"limitMentions": "Höchstens {{max}} Tiere pro Story markieren.",
|
||||
"limitGifs": "Höchstens {{max}} GIFs pro Story."
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
"done": "Done",
|
||||
"optional": "optional",
|
||||
"noResultsFound": "No entries found.",
|
||||
"searchPlaceholder": "Search …",
|
||||
"edit": "Edit",
|
||||
"authLoadTimeout": "This is taking longer than expected. Please try again."
|
||||
},
|
||||
@@ -48,7 +49,8 @@
|
||||
"feed": {
|
||||
"empty": "No posts in your feed yet.",
|
||||
"storyLabel": "Story",
|
||||
"trendingBadge": "Trending"
|
||||
"trendingBadge": "Trending",
|
||||
"yourStory": "Your story"
|
||||
},
|
||||
"explore": {
|
||||
"allTab": "All",
|
||||
@@ -65,7 +67,7 @@
|
||||
"breedLabel": "Breed",
|
||||
"birthdayLabel": "Birthday (optional)",
|
||||
"adoptedAtLabel": "Adoption date (optional)",
|
||||
"invalidDate": "Please enter a valid date as DD-MM-YYYY, or leave it empty.",
|
||||
"invalidDate": "Please enter a valid date as DD.MM.YYYY, or leave it empty.",
|
||||
"deleteConfirmTitle": "Delete this pet?",
|
||||
"deleteConfirmMessage": "{{name}} and all associated posts, comments, and follower relationships will be permanently deleted.",
|
||||
"deleteConfirmMessageFallback": "This pet",
|
||||
@@ -84,11 +86,16 @@
|
||||
"shareButton": "Share",
|
||||
"deleteConfirmTitle": "Delete this post?",
|
||||
"deleteConfirmMessage": "This post will be permanently deleted.",
|
||||
"deleteError": "Couldn't delete this post. Please try again."
|
||||
"deleteError": "Couldn't delete this post. Please try again.",
|
||||
"unsupportedFormat": "Some photos use a format PawFeed cannot process and were skipped."
|
||||
},
|
||||
"story": {
|
||||
"changePhotoHint": "Tap to choose a different photo",
|
||||
"shareButton": "Share story"
|
||||
"changeVideoHint": "Tap to choose a different video",
|
||||
"shareButton": "Share story",
|
||||
"videoTooLong": "Story videos can be at most {{max}} seconds long.",
|
||||
"preparingUpload": "Preparing upload …",
|
||||
"uploadingVideo": "Uploading video … {{percent}}%"
|
||||
},
|
||||
"milestoneTypes": {
|
||||
"BIRTHDAY": "Birthday",
|
||||
@@ -133,19 +140,25 @@
|
||||
"manageDesc": "Revoking immediately and permanently deletes all health data for this pet.",
|
||||
"revokeLink": "Revoke",
|
||||
"weightTitle": "Weight",
|
||||
"weightDatePlaceholder": "DD-MM-YYYY",
|
||||
"weightDatePlaceholder": "DD.MM.YYYY",
|
||||
"weightKgPlaceholder": "Weight (kg), e.g. 4.2",
|
||||
"notesPlaceholder": "Notes (optional)",
|
||||
"weightEmpty": "No weight entries yet",
|
||||
"invalidDate": "Please enter a valid date in DD-MM-YYYY format.",
|
||||
"weightChartTitle": "History",
|
||||
"weightChartSinceFirst": "since first entry",
|
||||
"weightChartA11y": "Weight history from {{from}} on {{fromDate}} to {{to}} on {{toDate}}. Tap to select an entry.",
|
||||
"invalidDate": "Please enter a valid date in DD.MM.YYYY format.",
|
||||
"vetTitle": "Vet",
|
||||
"vetReasonPlaceholder": "Reason, e.g. annual checkup",
|
||||
"vetNamePlaceholder": "Vet / clinic (optional)",
|
||||
"vetEmpty": "No vet visits yet",
|
||||
"vaccineTitle": "Vaccinations",
|
||||
"vaccineNamePlaceholder": "Vaccine, e.g. rabies, DHPP",
|
||||
"vaccineGivenPlaceholder": "Given on (DD-MM-YYYY)",
|
||||
"vaccineNextDuePlaceholder": "Next due (optional, DD-MM-YYYY)",
|
||||
"vaccineGivenPlaceholder": "Given on (DD.MM.YYYY)",
|
||||
"vaccineNextDuePlaceholder": "Next due (optional, DD.MM.YYYY)",
|
||||
"vaccineNextDuePreset_one": "+{{count}} year",
|
||||
"vaccineNextDuePreset_other": "+{{count}} years",
|
||||
"vaccineReminderHint": "With a next due date, we'll remind you 30 days ahead.",
|
||||
"vaccineEmpty": "No vaccinations recorded yet",
|
||||
"vaccineGiven": "Given {{date}}",
|
||||
"vaccineDue": "Due {{date}}",
|
||||
@@ -184,6 +197,8 @@
|
||||
"moreActions": "More actions"
|
||||
},
|
||||
"notifications": {
|
||||
"vaccineDue": "💉 The \"{{name}}\" vaccination is due on {{date}} – time to book an appointment.",
|
||||
"vaccineDueGeneric": "💉 A vaccination is due soon.",
|
||||
"follow": "is now following you.",
|
||||
"reaction": "pawed your post.",
|
||||
"comment": "commented on your post.",
|
||||
@@ -195,7 +210,10 @@
|
||||
"petSuggestion": "might be a good match!",
|
||||
"empty": "No notifications.",
|
||||
"referralJoined": "joined PawFeed through your invite.",
|
||||
"referralRankUp": "You reached a new referral rank!"
|
||||
"referralRankUp": "You reached a new referral rank!",
|
||||
"storyMention": "tagged you in a story.",
|
||||
"storyComment": "commented on your story.",
|
||||
"storyReaction": "gave your story a paw."
|
||||
},
|
||||
"pets": {
|
||||
"empty": "No pets added yet.",
|
||||
@@ -399,5 +417,72 @@
|
||||
"source_STORE_SEARCH": "App Store or Google Play",
|
||||
"source_PRESS": "Newspaper, radio or blog",
|
||||
"source_OTHER": "Other"
|
||||
},
|
||||
"storyViewer": {
|
||||
"paw": "Give a paw",
|
||||
"removePaw": "Remove paw",
|
||||
"openComments": "Open comments",
|
||||
"commentsTitle": "Comments",
|
||||
"deleteComment": "Delete comment",
|
||||
"commentError": "Your comment could not be sent. Please try again.",
|
||||
"viewersTitle_one": "{{count}} view",
|
||||
"viewersTitle_other": "{{count}} views",
|
||||
"noViewers": "Nobody has seen your story yet.",
|
||||
"deleteStory": "Delete story",
|
||||
"deleteTitle": "Delete story?",
|
||||
"deleteDescription": "The story and all its views, paws and comments will be deleted permanently.",
|
||||
"deleteConfirm": "Delete",
|
||||
"deleteError": "The story could not be deleted. Please try again.",
|
||||
"expiredOwnerOnly": "Expired – only visible to you",
|
||||
"readOnlyExpired": "This story has expired. You can still read comments but not add new ones.",
|
||||
"pawCount_one": "{{count}} paw",
|
||||
"pawCount_other": "{{count}} paws"
|
||||
},
|
||||
"foundingMember": {
|
||||
"label": "Founding member",
|
||||
"tooltip": "Founding member – one of the first 250 pets on PawFeed"
|
||||
},
|
||||
"privacy": {
|
||||
"heading": "Privacy",
|
||||
"whoCanMessage": "Who can message {{name}}?",
|
||||
"everyone": "Everyone",
|
||||
"onlyFollowedPets": "Only pets I follow",
|
||||
"dmFollowersOnlyHint": "Only pets that you follow can send you direct messages.",
|
||||
"dmEveryoneHint": "Any pet on PawFeed can send you direct messages.",
|
||||
"whoCanSeeFollowers": "Who can see {{name}}'s followers?",
|
||||
"onlyMyFollowers": "Only my followers",
|
||||
"onlyMe": "Only me",
|
||||
"followerEveryoneHint": "Anyone can see {{name}}'s followers and following lists and tag {{name}} in stories.",
|
||||
"followerFollowersOnlyHint": "Only pets that follow {{name}} can see the followers and following lists and tag {{name}} in stories.",
|
||||
"followerNobodyHint": "Only you can see {{name}}'s followers and following lists — nobody can tag {{name}} in stories.",
|
||||
"saveFailed": "Failed to save privacy setting"
|
||||
},
|
||||
"storyEditor": {
|
||||
"textStory": "Text story",
|
||||
"addText": "Add text",
|
||||
"addMention": "Tag a pet",
|
||||
"addGif": "Add GIF",
|
||||
"addSticker": "Add sticker",
|
||||
"background": "Change background",
|
||||
"emptyHint": "Add text, @tags, GIFs or stickers — or share right away.",
|
||||
"textPlaceholder": "Write something …",
|
||||
"textStyle": "Text style",
|
||||
"textFont": "Font",
|
||||
"style": {
|
||||
"plain": "Plain",
|
||||
"box": "Box",
|
||||
"soft": "Soft"
|
||||
},
|
||||
"mentionTitle": "Tag a pet",
|
||||
"mentionSearch": "Search pet name",
|
||||
"mentionNoResults": "No pet found that you're allowed to tag.",
|
||||
"mentionPrivacyHint": "Pets whose followers are visible only to their followers or nobody can only be tagged if you follow them, or not at all.",
|
||||
"gifTitle": "Choose a GIF",
|
||||
"gifSearch": "Search GIPHY",
|
||||
"gifError": "GIF search is unavailable right now.",
|
||||
"stickerTitle": "Stickers",
|
||||
"limitOverlays": "At most {{max}} items per story.",
|
||||
"limitMentions": "Tag at most {{max}} pets per story.",
|
||||
"limitGifs": "At most {{max}} GIFs per story."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { addYearsToDisplayDate, displayDateToIso, formatDateInput, isoToDisplayDate } from "./date-format";
|
||||
|
||||
describe("mobile date-format", () => {
|
||||
it("displays ISO dates with dots", () => {
|
||||
expect(isoToDisplayDate("2026-10-04")).toBe("04.10.2026");
|
||||
});
|
||||
|
||||
it("parses dotted, dashed and slashed input to ISO", () => {
|
||||
expect(displayDateToIso("04.10.2026")).toBe("2026-10-04");
|
||||
expect(displayDateToIso("04-10-2026")).toBe("2026-10-04");
|
||||
expect(displayDateToIso("04/10/2026")).toBe("2026-10-04");
|
||||
});
|
||||
|
||||
it("pads one-digit day and month", () => {
|
||||
expect(displayDateToIso("4.1.2026")).toBe("2026-01-04");
|
||||
});
|
||||
|
||||
it("rejects impossible calendar dates and garbage", () => {
|
||||
expect(displayDateToIso("31.02.2026")).toBeNull();
|
||||
expect(displayDateToIso("2026-10-04")).toBeNull();
|
||||
expect(displayDateToIso("abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("masks typed digits as DD.MM.YYYY", () => {
|
||||
expect(formatDateInput("04")).toBe("04");
|
||||
expect(formatDateInput("041")).toBe("04.1");
|
||||
expect(formatDateInput("0410")).toBe("04.10");
|
||||
expect(formatDateInput("04102026")).toBe("04.10.2026");
|
||||
expect(formatDateInput("041020269")).toBe("04.10.2026");
|
||||
});
|
||||
|
||||
it("lets backspace remove a trailing separator instead of re-adding it", () => {
|
||||
expect(formatDateInput("04.")).toBe("04");
|
||||
});
|
||||
|
||||
it("suggests a next due date some years ahead", () => {
|
||||
expect(addYearsToDisplayDate("04.10.2026", 1)).toBe("04.10.2027");
|
||||
expect(addYearsToDisplayDate("04.10.2026", 3)).toBe("04.10.2029");
|
||||
expect(addYearsToDisplayDate("29.02.2028", 1)).toBe("01.03.2029");
|
||||
expect(addYearsToDisplayDate("", 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,30 @@
|
||||
/**
|
||||
* The health forms take a free-text date field validated against the
|
||||
* backend's strict `YYYY-MM-DD` regex (src/trpc/routers/health.ts). Typing
|
||||
* dates in that order isn't the German convention (DD-MM-YYYY) — found.md:
|
||||
* users typing the natural order got a validation error the mutations never
|
||||
* surfaced (no onError handler), which looked exactly like "data isn't being
|
||||
* saved". Both problems share one fix: accept DD-MM-YYYY in the UI, convert
|
||||
* to ISO right before the mutation call, and reject anything that doesn't
|
||||
* parse as a real calendar date instead of silently sending garbage.
|
||||
* dates in that order isn't the German convention — found.md: users typing
|
||||
* the natural order got a validation error the mutations never surfaced (no
|
||||
* onError handler), which looked exactly like "data isn't being saved". Fix:
|
||||
* accept DD.MM.YYYY in the UI, convert to ISO right before the mutation call,
|
||||
* and reject anything that doesn't parse as a real calendar date instead of
|
||||
* silently sending garbage.
|
||||
*
|
||||
* Display uses "." (found.md Neu7: "-" is unusual in German). Parsing still
|
||||
* accepts "-" and "/" and one-digit day/month so older habits keep working.
|
||||
*/
|
||||
|
||||
const DISPLAY_PATTERN = /^(\d{2})-(\d{2})-(\d{4})$/;
|
||||
const DISPLAY_SEPARATOR = ".";
|
||||
const DISPLAY_PATTERN = /^(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})$/;
|
||||
const ISO_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const MAX_DATE_DIGITS = 8;
|
||||
|
||||
export function isoToDisplayDate(iso: string): string {
|
||||
const match = ISO_PATTERN.exec(iso);
|
||||
if (!match) return iso;
|
||||
const [, year, month, day] = match;
|
||||
return `${day}-${month}-${year}`;
|
||||
return [day, month, year].join(DISPLAY_SEPARATOR);
|
||||
}
|
||||
|
||||
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD-MM-YYYY calendar date. */
|
||||
/** Returns the equivalent `YYYY-MM-DD` string, or null if not a valid DD.MM.YYYY calendar date. */
|
||||
export function displayDateToIso(display: string): string | null {
|
||||
const match = DISPLAY_PATTERN.exec(display.trim());
|
||||
if (!match) return null;
|
||||
@@ -33,7 +38,32 @@ export function displayDateToIso(display: string): string | null {
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
if (!isRealDate) return null;
|
||||
|
||||
return `${yearStr}-${monthStr}-${dayStr}`;
|
||||
return `${yearStr}-${monthStr.padStart(2, "0")}-${dayStr.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live mask for typing: keeps digits only and inserts the separators itself
|
||||
* (so the number pad, which has no "." on iOS, is enough). A separator is only
|
||||
* added once a following digit exists — otherwise backspace over "04." would
|
||||
* immediately re-add the dot and the field would get stuck.
|
||||
*/
|
||||
export function formatDateInput(text: string): string {
|
||||
const digits = text.replace(/\D/g, "").slice(0, MAX_DATE_DIGITS);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}${DISPLAY_SEPARATOR}${digits.slice(2, 4)}${DISPLAY_SEPARATOR}${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folgetermin-Vorschlag „+1 / +3 Jahre“ für Impfungen. Null bei ungültigem
|
||||
* Ausgangsdatum. 29.02. + 1 Jahr landet (wie Date selbst) auf dem 01.03.
|
||||
*/
|
||||
export function addYearsToDisplayDate(display: string, years: number): string | null {
|
||||
const iso = displayDateToIso(display);
|
||||
if (!iso) return null;
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
const shifted = new Date(Date.UTC(year + years, month - 1, day));
|
||||
return isoToDisplayDate(shifted.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
export function todayAsDisplayDate(): string {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterOptionsByName, normalizeForSearch } from "./filter-options";
|
||||
|
||||
const breeds = [
|
||||
{ id: "1", name: "Bichon Frisé" },
|
||||
{ id: "2", name: "Shiba Inu" },
|
||||
{ id: "3", name: "Europäisch Kurzhaar" },
|
||||
{ id: "4", name: "Bengalkatze" },
|
||||
];
|
||||
|
||||
describe("filterOptionsByName", () => {
|
||||
it("returns every option for an empty or blank query", () => {
|
||||
expect(filterOptionsByName(breeds, "")).toEqual(breeds);
|
||||
expect(filterOptionsByName(breeds, " ")).toEqual(breeds);
|
||||
});
|
||||
|
||||
it("matches case-insensitively anywhere in the name", () => {
|
||||
expect(filterOptionsByName(breeds, "INU").map((b) => b.id)).toEqual(["2"]);
|
||||
expect(filterOptionsByName(breeds, "be").map((b) => b.id)).toEqual(["4"]);
|
||||
});
|
||||
|
||||
it("ignores accents and umlauts", () => {
|
||||
expect(filterOptionsByName(breeds, "frise").map((b) => b.id)).toEqual(["1"]);
|
||||
expect(filterOptionsByName(breeds, "europaisch").map((b) => b.id)).toEqual(["3"]);
|
||||
});
|
||||
|
||||
it("returns nothing when no name matches", () => {
|
||||
expect(filterOptionsByName(breeds, "pudel")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeForSearch", () => {
|
||||
it("lowercases, trims and strips diacritics", () => {
|
||||
expect(normalizeForSearch(" Bichon Frisé ")).toBe("bichon frise");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Lowercase + strip diacritics, so "eu" finds "Européen" and "BICHON" finds "Bichon Frisé". */
|
||||
export function normalizeForSearch(text: string): string {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Keeps options whose name contains the query; an empty query keeps all of them. */
|
||||
export function filterOptionsByName<T extends { name: string }>(options: T[], query: string): T[] {
|
||||
const needle = normalizeForSearch(query);
|
||||
if (!needle) return options;
|
||||
return options.filter((option) => normalizeForSearch(option.name).includes(needle));
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
* TS type — always coerce with `new Date(...)` before comparing.
|
||||
*/
|
||||
export function isActiveSponsor(owner: {
|
||||
sponsorSince: Date | string | null;
|
||||
sponsorPeriodEnd: Date | string | null;
|
||||
sponsorSince?: Date | string | null;
|
||||
sponsorPeriodEnd?: Date | string | null;
|
||||
}): boolean {
|
||||
if (!owner.sponsorSince) return false;
|
||||
if (!owner.sponsorPeriodEnd) return true;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Story-Editor: Web/Server und App haben je eine Kopie der Overlay-Konstanten,
|
||||
* Sticker und Geometrie (die App kann nichts aus src/ importieren, der
|
||||
* Docker-Build nichts aus mobile/). Dieser Test hält beide identisch. Er liegt
|
||||
* bewusst in mobile/ (vom Docker-Build und der Web-tsconfig ausgeschlossen) und
|
||||
* läuft über das Root-Vitest mit.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as web from "@/lib/story-overlays";
|
||||
import * as webStickers from "@/lib/story-stickers";
|
||||
import * as app from "./story-overlays";
|
||||
import * as appStickers from "./story-stickers";
|
||||
|
||||
describe("Story-Overlay-Spiegel Web ↔ App", () => {
|
||||
it("has identical constants", () => {
|
||||
for (const key of [
|
||||
"MAX_OVERLAYS",
|
||||
"MAX_GIFS",
|
||||
"MAX_MENTIONS",
|
||||
"MAX_TEXT_LENGTH",
|
||||
"TEXT_COLORS",
|
||||
"TEXT_STYLES",
|
||||
"TEXT_FONTS",
|
||||
"STORY_BACKGROUNDS",
|
||||
"STORY_BACKGROUND_KEYS",
|
||||
"TEXT_STORY_ASPECT",
|
||||
"OVERLAY_SIZES",
|
||||
"MIN_SCALE",
|
||||
"MAX_SCALE",
|
||||
] as const) {
|
||||
expect(app[key], key).toEqual(web[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has identical stickers", () => {
|
||||
expect(appStickers.STORY_STICKERS).toEqual(webStickers.STORY_STICKERS);
|
||||
expect(appStickers.STICKER_OUTLINE).toEqual(webStickers.STICKER_OUTLINE);
|
||||
});
|
||||
|
||||
it("computes identical geometry", () => {
|
||||
const cases: [number, number, number, number][] = [
|
||||
[400, 800, 1080, 1350],
|
||||
[390, 844, 1920, 1080],
|
||||
[1280, 720, 9, 16],
|
||||
[0, 100, 10, 10],
|
||||
];
|
||||
for (const c of cases) expect(app.containRect(...c)).toEqual(web.containRect(...c));
|
||||
const rect = { x: 10, y: 20, width: 300, height: 500 };
|
||||
expect(app.overlayCenter(rect, { x: 0.25, y: 0.75 })).toEqual(web.overlayCenter(rect, { x: 0.25, y: 0.75 }));
|
||||
for (const color of web.TEXT_COLORS) expect(app.contrastTextColor(color)).toBe(web.contrastTextColor(color));
|
||||
for (const story of [{ mediaType: "TEXT" }, { mediaType: "VIDEO", aspectRatio: "16:9" }, { mediaType: "IMAGE", mediaWidth: 3, mediaHeight: 4 }]) {
|
||||
expect(app.storyMediaSize(story)).toEqual(web.storyMediaSize(story));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Story-Editor — App-Spiegel von src/lib/story-overlays.ts (Web/Server).
|
||||
* Konstanten und Geometrie müssen identisch bleiben, damit Overlays in App und
|
||||
* Web an derselben Stelle sitzen (src/__tests__/story-overlays-parity.test.ts
|
||||
* prüft das). Validiert wird nur auf dem Server; hier gibt es deshalb kein Zod.
|
||||
*
|
||||
* Positionen sind relativ zur Medienfläche (0–1), Größen relativ zu deren Breite.
|
||||
*/
|
||||
|
||||
export const MAX_OVERLAYS = 15;
|
||||
export const MAX_GIFS = 3;
|
||||
export const MAX_MENTIONS = 10;
|
||||
export const MAX_TEXT_LENGTH = 200;
|
||||
|
||||
export const TEXT_COLORS = ["#ffffff", "#171717", "#f97316", "#facc15", "#22c55e", "#3b82f6", "#ec4899", "#a855f7"] as const;
|
||||
export const TEXT_STYLES = ["plain", "box", "soft"] as const;
|
||||
export const TEXT_FONTS = ["regular", "bold"] as const;
|
||||
|
||||
export const STORY_BACKGROUNDS: Record<string, readonly [string, string]> = {
|
||||
sunset: ["#f97316", "#ec4899"],
|
||||
ocean: ["#0ea5e9", "#6366f1"],
|
||||
forest: ["#22c55e", "#0f766e"],
|
||||
night: ["#1e1b4b", "#7c3aed"],
|
||||
peach: ["#fdba74", "#fde68a"],
|
||||
ink: ["#262626", "#525252"],
|
||||
};
|
||||
export const STORY_BACKGROUND_KEYS = Object.keys(STORY_BACKGROUNDS);
|
||||
|
||||
export const TEXT_STORY_ASPECT = { width: 9, height: 16 } as const;
|
||||
|
||||
export const OVERLAY_SIZES = {
|
||||
textFont: 0.07,
|
||||
textMaxWidth: 0.82,
|
||||
mentionFont: 0.055,
|
||||
sticker: 0.26,
|
||||
gifWidth: 0.42,
|
||||
} as const;
|
||||
export const MIN_SCALE = 0.3;
|
||||
export const MAX_SCALE = 4;
|
||||
|
||||
export type TextColor = (typeof TEXT_COLORS)[number];
|
||||
export type TextStyle = (typeof TEXT_STYLES)[number];
|
||||
export type TextFont = (typeof TEXT_FONTS)[number];
|
||||
|
||||
type Base = { x: number; y: number; scale: number; rotation: number };
|
||||
export type TextOverlay = Base & { kind: "text"; text: string; color: TextColor; style: TextStyle; font: TextFont };
|
||||
export type MentionOverlay = Base & { kind: "mention"; petId: string };
|
||||
export type GifOverlay = Base & { kind: "gif"; gifId: string; url: string; width: number; height: number };
|
||||
export type StickerOverlay = Base & { kind: "sticker"; stickerId: string };
|
||||
export type StoryOverlay = TextOverlay | MentionOverlay | GifOverlay | StickerOverlay;
|
||||
|
||||
export type Rect = { x: number; y: number; width: number; height: number };
|
||||
|
||||
export function containRect(containerWidth: number, containerHeight: number, mediaWidth: number, mediaHeight: number): Rect {
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || mediaWidth <= 0 || mediaHeight <= 0) {
|
||||
return { x: 0, y: 0, width: Math.max(containerWidth, 0), height: Math.max(containerHeight, 0) };
|
||||
}
|
||||
const scale = Math.min(containerWidth / mediaWidth, containerHeight / mediaHeight);
|
||||
const width = mediaWidth * scale;
|
||||
const height = mediaHeight * scale;
|
||||
return { x: (containerWidth - width) / 2, y: (containerHeight - height) / 2, width, height };
|
||||
}
|
||||
|
||||
export function overlayCenter(rect: Rect, overlay: { x: number; y: number }): { left: number; top: number } {
|
||||
return { left: rect.x + overlay.x * rect.width, top: rect.y + overlay.y * rect.height };
|
||||
}
|
||||
|
||||
export function contrastTextColor(background: string): "#171717" | "#ffffff" {
|
||||
const hex = background.replace("#", "");
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return luminance > 0.6 ? "#171717" : "#ffffff";
|
||||
}
|
||||
|
||||
export function storyMediaSize(story: {
|
||||
mediaType: string;
|
||||
mediaWidth?: number | null;
|
||||
mediaHeight?: number | null;
|
||||
aspectRatio?: string | null;
|
||||
}): { width: number; height: number } | null {
|
||||
if (story.mediaType === "TEXT") return { ...TEXT_STORY_ASPECT };
|
||||
if (story.mediaWidth && story.mediaHeight) return { width: story.mediaWidth, height: story.mediaHeight };
|
||||
const match = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.exec(story.aspectRatio ?? "");
|
||||
if (match) return { width: Number(match[1]), height: Number(match[2]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Nur App (Editor) ─────────────────────────────────────────────────────────
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* Neue Lage eines Overlays aus einer Geste: Verschiebung in Pixeln (dx/dy),
|
||||
* Zwei-Finger-Zoom (pinch = aktueller / Start-Fingerabstand) und Drehung
|
||||
* (Grad). Bleibt innerhalb der Medienfläche und der erlaubten Größe.
|
||||
*/
|
||||
export function applyGesture<T extends Base>(
|
||||
start: T,
|
||||
rect: Rect,
|
||||
gesture: { dx: number; dy: number; pinch: number; rotationDelta: number }
|
||||
): T {
|
||||
if (rect.width <= 0 || rect.height <= 0) return start;
|
||||
return {
|
||||
...start,
|
||||
x: clamp(start.x + gesture.dx / rect.width, 0, 1),
|
||||
y: clamp(start.y + gesture.dy / rect.height, 0, 1),
|
||||
scale: clamp(start.scale * gesture.pinch, MIN_SCALE, MAX_SCALE),
|
||||
rotation: normalizeRotation(start.rotation + gesture.rotationDelta),
|
||||
};
|
||||
}
|
||||
|
||||
/** Auf -180…180 Grad bringen (Server erlaubt -360…360). */
|
||||
export function normalizeRotation(deg: number): number {
|
||||
let r = deg % 360;
|
||||
if (r > 180) r -= 360;
|
||||
if (r < -180) r += 360;
|
||||
return Math.round(r * 10) / 10;
|
||||
}
|
||||
|
||||
/** Fingerabstand und -winkel zweier Berührungen (für Zoom/Drehen). */
|
||||
export function touchGeometry(a: { pageX: number; pageY: number }, b: { pageX: number; pageY: number }) {
|
||||
const dx = b.pageX - a.pageX;
|
||||
const dy = b.pageY - a.pageY;
|
||||
return { distance: Math.hypot(dx, dy), angle: (Math.atan2(dy, dx) * 180) / Math.PI };
|
||||
}
|
||||
|
||||
/** Winkeldifferenz ohne Sprung bei ±180°. */
|
||||
export function angleDelta(from: number, to: number): number {
|
||||
let d = to - from;
|
||||
if (d > 180) d -= 360;
|
||||
if (d < -180) d += 360;
|
||||
return d;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Story-Sticker (Story-Editor). Reine Formdaten im 100×100-Raster, damit Web
|
||||
* (DOM-SVG) und App (react-native-svg) exakt dasselbe zeichnen. Jeder Sticker
|
||||
* wird zweimal gezeichnet: erst alle Teile als weißer Umriss (STICKER_OUTLINE),
|
||||
* dann in Farbe — das gibt den typischen Aufkleber-Look auf jedem Hintergrund.
|
||||
*
|
||||
* ⚠️ Spiegel von src/lib/story-stickers.ts (Web/Server) — identisch halten
|
||||
* (src/__tests__/story-overlays-parity.test.ts prüft das).
|
||||
*/
|
||||
|
||||
export type StickerPart =
|
||||
| { type: "circle"; cx: number; cy: number; r: number; fill: string }
|
||||
| { type: "rect"; x: number; y: number; width: number; height: number; rx: number; fill: string }
|
||||
| { type: "path"; d: string; fill: string };
|
||||
|
||||
export type StickerDef = { id: string; label: string; parts: StickerPart[] };
|
||||
|
||||
export const STICKER_OUTLINE = { color: "#ffffff", width: 8 } as const;
|
||||
|
||||
export const STORY_STICKERS: StickerDef[] = [
|
||||
{
|
||||
id: "paw",
|
||||
label: "Pfote",
|
||||
parts: [
|
||||
{ type: "circle", cx: 50, cy: 64, r: 20, fill: "#f97316" },
|
||||
{ type: "circle", cx: 22, cy: 42, r: 9, fill: "#f97316" },
|
||||
{ type: "circle", cx: 38, cy: 26, r: 10, fill: "#f97316" },
|
||||
{ type: "circle", cx: 62, cy: 26, r: 10, fill: "#f97316" },
|
||||
{ type: "circle", cx: 78, cy: 42, r: 9, fill: "#f97316" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "heart",
|
||||
label: "Herz",
|
||||
parts: [{ type: "path", d: "M50 86 C22 66 8 50 13 32 C18 15 40 12 50 29 C60 12 82 15 87 32 C92 50 78 66 50 86 Z", fill: "#ec4899" }],
|
||||
},
|
||||
{
|
||||
id: "bone",
|
||||
label: "Knochen",
|
||||
parts: [
|
||||
{ type: "rect", x: 24, y: 41, width: 52, height: 18, rx: 4, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 22, cy: 40, r: 11, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 22, cy: 60, r: 11, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 78, cy: 40, r: 11, fill: "#fde68a" },
|
||||
{ type: "circle", cx: 78, cy: 60, r: 11, fill: "#fde68a" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "star",
|
||||
label: "Stern",
|
||||
parts: [{ type: "path", d: "M50 8 L61 37 L93 38 L68 58 L77 90 L50 72 L23 90 L32 58 L7 38 L39 37 Z", fill: "#facc15" }],
|
||||
},
|
||||
{
|
||||
id: "sparkle",
|
||||
label: "Funkeln",
|
||||
parts: [
|
||||
{ type: "path", d: "M44 10 C47 34 54 41 78 44 C54 47 47 54 44 78 C41 54 34 47 10 44 C34 41 41 34 44 10 Z", fill: "#a855f7" },
|
||||
{ type: "path", d: "M78 62 C79 72 82 75 92 76 C82 77 79 80 78 90 C77 80 74 77 64 76 C74 75 77 72 78 62 Z", fill: "#a855f7" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fish",
|
||||
label: "Fisch",
|
||||
parts: [
|
||||
{ type: "path", d: "M74 50 L92 34 L92 66 Z", fill: "#3b82f6" },
|
||||
{ type: "path", d: "M12 50 C24 28 58 26 76 50 C58 74 24 72 12 50 Z", fill: "#3b82f6" },
|
||||
{ type: "circle", cx: 30, cy: 46, r: 5, fill: "#171717" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "crown",
|
||||
label: "Krone",
|
||||
parts: [
|
||||
{ type: "path", d: "M14 76 L18 30 L36 50 L50 22 L64 50 L82 30 L86 76 Z", fill: "#facc15" },
|
||||
{ type: "circle", cx: 50, cy: 62, r: 6, fill: "#ec4899" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sun",
|
||||
label: "Sonne",
|
||||
parts: [
|
||||
{ type: "path", d: "M50 4 L57 22 L43 22 Z M50 96 L43 78 L57 78 Z M4 50 L22 43 L22 57 Z M96 50 L78 57 L78 43 Z M17 17 L33 26 L26 33 Z M83 83 L67 74 L74 67 Z M83 17 L74 33 L67 26 Z M17 83 L26 67 L33 74 Z", fill: "#fb923c" },
|
||||
{ type: "circle", cx: 50, cy: 50, r: 22, fill: "#facc15" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const STICKER_IDS = STORY_STICKERS.map((s) => s.id);
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Mirrors src/lib/story-video-limits.ts on web — mobile and web don't share
|
||||
* a package, so this value is duplicated. Keep both in sync manually if the
|
||||
* limit ever changes.
|
||||
*/
|
||||
export const MAX_STORY_VIDEO_DURATION_SECS = 30;
|
||||
@@ -20,3 +20,33 @@ export async function uploadImageToSupabase(uri: string, presignedUrl: string, m
|
||||
throw new Error(`Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs a local video file directly to a Mux direct-upload URL (from
|
||||
* `stories.createVideoUpload`). Separate function rather than reusing
|
||||
* `uploadImageToSupabase` above — same `File.upload` primitive, but a
|
||||
* different destination (Mux, not Supabase) and no signature-matching
|
||||
* requirement on Content-Type, so keeping the working photo path untouched.
|
||||
*/
|
||||
export async function uploadFileToUrl(
|
||||
uri: string,
|
||||
uploadUrl: string,
|
||||
mimeType: string,
|
||||
onProgress?: (fraction: number) => void
|
||||
): Promise<void> {
|
||||
const file = new File(uri);
|
||||
const result = await file.upload(uploadUrl, {
|
||||
httpMethod: "PUT",
|
||||
mimeType,
|
||||
headers: { "Content-Type": mimeType },
|
||||
onProgress: onProgress
|
||||
? ({ bytesSent, totalBytes }) => {
|
||||
if (totalBytes > 0) onProgress(Math.min(bytesSent / totalBytes, 1));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildWeightChart, nearestPointIndex } from "./weight-chart";
|
||||
|
||||
const box = { width: 300, height: 180, left: 40, right: 10, top: 10, bottom: 30 };
|
||||
|
||||
describe("buildWeightChart", () => {
|
||||
it("needs at least two entries", () => {
|
||||
expect(buildWeightChart([], box)).toBeNull();
|
||||
expect(buildWeightChart([{ date: "2026-01-01", weightGrams: 4000 }], box)).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts by date and spaces x by time, not by index", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-31", weightGrams: 4200 },
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-04", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points.map((p) => p.kg)).toEqual([4, 4.1, 4.2]);
|
||||
expect(chart.points[0].x).toBe(40);
|
||||
expect(chart.points[2].x).toBe(290);
|
||||
// 3 of 30 days → 10 % of the 250px plot width, not the middle.
|
||||
expect(chart.points[1].x).toBeCloseTo(65, 5);
|
||||
});
|
||||
|
||||
it("puts heavier weights higher and reports the change since the first entry", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-02-01", weightGrams: 4350 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points[1].y).toBeLessThan(chart.points[0].y);
|
||||
expect(chart.deltaKg).toBeCloseTo(0.35, 5);
|
||||
});
|
||||
|
||||
it("keeps a flat series inside the plot and never goes below 0 kg", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 300 },
|
||||
{ date: "2026-01-02", weightGrams: 300 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
for (const p of chart.points) {
|
||||
expect(p.y).toBeGreaterThan(box.top);
|
||||
expect(p.y).toBeLessThan(chart.baselineY);
|
||||
}
|
||||
expect(chart.yTicks[0].kg).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to index spacing when all entries share a date", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-01", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.points.map((p) => p.x)).toEqual([40, 290]);
|
||||
});
|
||||
|
||||
it("labels at most a handful of dates and always the newest", () => {
|
||||
const entries = Array.from({ length: 12 }, (_, i) => ({ date: new Date(Date.UTC(2026, 0, 1 + i)), weightGrams: 4000 + i }));
|
||||
const chart = buildWeightChart(entries, box)!;
|
||||
expect(chart.xLabelIndices.length).toBeLessThanOrEqual(4);
|
||||
expect(chart.xLabelIndices).toContain(11);
|
||||
});
|
||||
|
||||
it("never places two date labels closer than a quarter of the plot", () => {
|
||||
// Two weigh-ins a day apart, then one a year later: the first two must not both get a label.
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2025-01-01", weightGrams: 4000 },
|
||||
{ date: "2025-01-02", weightGrams: 4010 },
|
||||
{ date: "2026-01-01", weightGrams: 4500 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
const xs = chart.xLabelIndices.map((i) => chart.points[i].x);
|
||||
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(250 / 4);
|
||||
expect(chart.xLabelIndices.at(-1)).toBe(2);
|
||||
});
|
||||
|
||||
it("builds closed area and open line paths", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-11", weightGrams: 4100 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.linePath.startsWith("M 40 ")).toBe(true);
|
||||
expect(chart.areaPath.endsWith("Z")).toBe(true);
|
||||
expect(chart.areaPath).toContain(` ${chart.baselineY} `);
|
||||
});
|
||||
});
|
||||
|
||||
describe("y axis ticks", () => {
|
||||
it("uses round, distinct tick values even for a tiny range", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-09-01", weightGrams: 4200 },
|
||||
{ date: "2026-10-01", weightGrams: 4150 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
const labels = chart.yTicks.map((t) => t.kg.toFixed(chart.yTickDecimals));
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
expect(chart.yTicks[0].kg).toBeLessThanOrEqual(4.15);
|
||||
expect(chart.yTicks.at(-1)!.kg).toBeGreaterThanOrEqual(4.2);
|
||||
expect(chart.yTickDecimals).toBe(2);
|
||||
});
|
||||
|
||||
it("uses whole kilos for a big dog over a wide range", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2025-01-01", weightGrams: 25000 },
|
||||
{ date: "2026-01-01", weightGrams: 33000 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(chart.yTickDecimals).toBe(0);
|
||||
for (const t of chart.yTicks) expect(Number.isInteger(t.kg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearestPointIndex", () => {
|
||||
it("picks the point closest to the tap", () => {
|
||||
const chart = buildWeightChart(
|
||||
[
|
||||
{ date: "2026-01-01", weightGrams: 4000 },
|
||||
{ date: "2026-01-11", weightGrams: 4100 },
|
||||
{ date: "2026-01-21", weightGrams: 4200 },
|
||||
],
|
||||
box
|
||||
)!;
|
||||
expect(nearestPointIndex(chart.points, 0)).toBe(0);
|
||||
expect(nearestPointIndex(chart.points, 170)).toBe(1);
|
||||
expect(nearestPointIndex(chart.points, 999)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Pure geometry for the mobile weight-history chart (port of web's
|
||||
* src/components/health/WeightChart.tsx). Kept free of React/SVG so it can
|
||||
* be unit-tested.
|
||||
*
|
||||
* One deliberate difference from web: x is proportional to the date, not to
|
||||
* the entry's index — weigh-ins are irregular, and index spacing would make a
|
||||
* jump over three months look as steep as one over three days.
|
||||
*/
|
||||
|
||||
export type WeightEntry = { date: Date | string; weightGrams: number };
|
||||
|
||||
export type ChartBox = { width: number; height: number; left: number; right: number; top: number; bottom: number };
|
||||
|
||||
export type ChartPoint = { x: number; y: number; kg: number; time: number; entry: WeightEntry };
|
||||
|
||||
export type WeightChartGeometry = {
|
||||
points: ChartPoint[];
|
||||
linePath: string;
|
||||
areaPath: string;
|
||||
yTicks: { y: number; kg: number }[];
|
||||
/** Decimal places that make every y tick label distinct (derived from the tick step). */
|
||||
yTickDecimals: number;
|
||||
xLabelIndices: number[];
|
||||
baselineY: number;
|
||||
deltaKg: number;
|
||||
};
|
||||
|
||||
const TARGET_Y_TICKS = 4;
|
||||
const MAX_X_LABELS = 4;
|
||||
const RANGE_PADDING = 0.18;
|
||||
const FLAT_RANGE_PADDING_KG = 0.5;
|
||||
|
||||
/** Rounds a raw step up to 1, 2 or 5 × 10^n, so tick labels are round numbers (4,0 / 4,2 / 4,4 — not 4,13 / 4,27). */
|
||||
function niceStep(rawStep: number): number {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
|
||||
const normalized = rawStep / magnitude;
|
||||
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
function smoothPath(points: ChartPoint[]): string {
|
||||
return points
|
||||
.map((p, i) => {
|
||||
if (i === 0) return `M ${p.x} ${p.y}`;
|
||||
const prev = points[i - 1];
|
||||
const cpX = (prev.x + p.x) / 2;
|
||||
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Date labels chosen by on-screen distance, not by index (x is time-based, so
|
||||
* index spacing could stack labels on top of each other): walk back from the
|
||||
* newest point and keep one whenever it is far enough from the last kept one.
|
||||
*/
|
||||
function pickLabelIndices(points: { x: number }[], plotWidth: number): number[] {
|
||||
const minGap = plotWidth / MAX_X_LABELS;
|
||||
const kept = [points.length - 1];
|
||||
for (let i = points.length - 2; i >= 0 && kept.length < MAX_X_LABELS; i--) {
|
||||
if (points[kept[kept.length - 1]].x - points[i].x >= minGap) kept.push(i);
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
|
||||
/** Returns null for fewer than two entries — a single weigh-in is not a trend. */
|
||||
export function buildWeightChart(entries: WeightEntry[], box: ChartBox): WeightChartGeometry | null {
|
||||
if (entries.length < 2) return null;
|
||||
|
||||
const sorted = [...entries]
|
||||
.map((entry) => ({ entry, time: new Date(entry.date).getTime(), kg: entry.weightGrams / 1000 }))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
|
||||
const kgs = sorted.map((e) => e.kg);
|
||||
const minKg = Math.min(...kgs);
|
||||
const maxKg = Math.max(...kgs);
|
||||
const rawRange = maxKg - minKg;
|
||||
const pad = rawRange === 0 ? FLAT_RANGE_PADDING_KG : rawRange * RANGE_PADDING;
|
||||
const step = niceStep((maxKg + pad - Math.max(0, minKg - pad)) / TARGET_Y_TICKS);
|
||||
// Snap the padded range outward to whole steps so every gridline sits on a round value.
|
||||
const yMin = Math.max(0, Math.floor((minKg - pad) / step) * step);
|
||||
const yMax = Math.ceil((maxKg + pad) / step) * step;
|
||||
const tickCount = Math.round((yMax - yMin) / step);
|
||||
|
||||
const plotW = box.width - box.left - box.right;
|
||||
const plotH = box.height - box.top - box.bottom;
|
||||
const firstTime = sorted[0].time;
|
||||
const timeSpan = sorted[sorted.length - 1].time - firstTime;
|
||||
|
||||
const xOf = (time: number, index: number) =>
|
||||
box.left +
|
||||
(timeSpan > 0 ? (time - firstTime) / timeSpan : index / (sorted.length - 1)) * plotW;
|
||||
const yOf = (kg: number) => box.top + plotH - ((kg - yMin) / (yMax - yMin)) * plotH;
|
||||
|
||||
const points = sorted.map((e, i) => ({ x: xOf(e.time, i), y: yOf(e.kg), kg: e.kg, time: e.time, entry: e.entry }));
|
||||
const baselineY = box.top + plotH;
|
||||
const linePath = smoothPath(points);
|
||||
const areaPath =
|
||||
`M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y} ` +
|
||||
linePath.replace(/^M [^ ]+ [^ ]+ ?/, "") +
|
||||
` L ${points[points.length - 1].x} ${baselineY} Z`;
|
||||
|
||||
const yTicks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
||||
// Rounded to kill float noise (0.1 + 0.2), the step decides the precision.
|
||||
const kg = Number((yMin + step * i).toFixed(6));
|
||||
return { kg, y: yOf(kg) };
|
||||
});
|
||||
|
||||
return {
|
||||
points,
|
||||
linePath,
|
||||
areaPath,
|
||||
yTicks,
|
||||
yTickDecimals: Math.max(0, -Math.floor(Math.log10(step) + 1e-9)),
|
||||
xLabelIndices: pickLabelIndices(points, plotW),
|
||||
baselineY,
|
||||
deltaKg: kgs[kgs.length - 1] - kgs[0],
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the point closest to a tapped x position. */
|
||||
export function nearestPointIndex(points: ChartPoint[], x: number): number {
|
||||
let best = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
if (Math.abs(points[i].x - x) < Math.abs(points[best].x - x)) best = i;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export interface ColorTokens {
|
||||
quietGray: string;
|
||||
alertRed: string;
|
||||
alertRedTint: string;
|
||||
/**
|
||||
* Data line in charts (weight history). Checked with the dataviz palette
|
||||
* validator: >= 3:1 against both surfaces and inside the lightness band in
|
||||
* both modes — the brighter marigoldOrange fails one of the two each way.
|
||||
*/
|
||||
chartLine: string;
|
||||
}
|
||||
|
||||
export const colors: ColorTokens = {
|
||||
@@ -41,6 +47,7 @@ export const colors: ColorTokens = {
|
||||
quietGray: "#737373",
|
||||
alertRed: "#dc2626",
|
||||
alertRedTint: "#fee2e2",
|
||||
chartLine: "#ea580c",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,6 +68,7 @@ export const darkColors: ColorTokens = {
|
||||
quietGray: "#a3a3a3",
|
||||
alertRed: "#f87171",
|
||||
alertRedTint: "#450a0a",
|
||||
chartLine: "#ea580c",
|
||||
};
|
||||
|
||||
export const radii = {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Gründungsmitglieder: alle bestehenden Tiere unter den ersten 250 (nach
|
||||
-- Erstellzeitpunkt) nachträglich markieren. Neue Tiere setzt pets.create
|
||||
-- selbst (src/lib/new-pet-welcome.ts). Idempotent, nach dem prisma db push
|
||||
-- einmal ausführen.
|
||||
UPDATE "Pet" SET "isFoundingMember" = true
|
||||
WHERE "id" IN (SELECT "id" FROM "Pet" ORDER BY "createdAt" ASC LIMIT 250)
|
||||
AND "isFoundingMember" = false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Kurator-Begrüßung: neue Tabelle für den PostgREST-Zugriff sperren (siehe enable_rls.sql).
|
||||
-- Nach dem prisma db push einmal ausführen.
|
||||
ALTER TABLE public."CuratorSettings" ENABLE ROW LEVEL SECURITY;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Story-Editor (feature/story-editor): neue Tabelle für den PostgREST-Zugriff
|
||||
-- sperren, wie alle anderen Tabellen (siehe enable_rls.sql). Die App greift
|
||||
-- ausschließlich über Prisma (Service-Rolle) zu.
|
||||
-- Nach dem prisma db push einmal ausführen.
|
||||
ALTER TABLE public."StoryMention" ENABLE ROW LEVEL SECURITY;
|
||||
+79
-15
@@ -106,17 +106,38 @@ model ReferralRank {
|
||||
// Wer hat welchen Rang wann erreicht — Grundlage für Bestandsschutz,
|
||||
// einmalige Rang-Benachrichtigungen und spätere Gutscheine.
|
||||
model ReferralRankAchievement {
|
||||
id String @id @default(cuid())
|
||||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
ownerId String
|
||||
rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
|
||||
rankId String
|
||||
achievedAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
owner Owner @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
ownerId String
|
||||
rank ReferralRank @relation(fields: [rankId], references: [id], onDelete: Restrict)
|
||||
rankId String
|
||||
achievedAt DateTime @default(now())
|
||||
// Admin-Steuerung (Tierverwaltung): ein entzogener Rang bleibt als Zeile
|
||||
// stehen, damit ihn awardRanks/recomputeReferralRanks nicht neu vergeben —
|
||||
// er zählt erst wieder, wenn ein Admin ihn freigibt (revokedAt = null).
|
||||
revokedAt DateTime?
|
||||
revokedBy String? // ownerId des Admins
|
||||
revokeReason String? @db.VarChar(500)
|
||||
grantedManually Boolean @default(false) // vom Admin vergeben (besondere Leistung), nicht über den Zähler
|
||||
grantedBy String? // ownerId des Admins
|
||||
|
||||
@@unique([ownerId, rankId])
|
||||
@@index([rankId])
|
||||
}
|
||||
|
||||
// Kurator-Konto „PawFeed Team“: Begrüßung neuer Tiere. Genau eine Zeile
|
||||
// (id = 1), im Admin-Panel unter /p/[secret]/curator bearbeitbar.
|
||||
// {name} im Text wird durch den Tiernamen ersetzt.
|
||||
model CuratorSettings {
|
||||
id Int @id @default(1)
|
||||
welcomeEnabled Boolean @default(true) // Begrüßungs-Direktnachricht an jedes neue Tier
|
||||
autoFollowEnabled Boolean @default(true) // Team-Tier folgt jedem neuen Tier
|
||||
welcomeTextDe String @db.VarChar(1000)
|
||||
welcomeTextEn String @db.VarChar(1000)
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String? // ownerId des Admins
|
||||
}
|
||||
|
||||
// Genau eine Zeile (id = 1), im Admin-Panel bearbeitbar.
|
||||
model ReferralSettings {
|
||||
id Int @id @default(1)
|
||||
@@ -244,6 +265,7 @@ model Pet {
|
||||
adoptionStory String?
|
||||
avatarKey String? // Supabase Storage object key, NOT a full URL
|
||||
isVerified Boolean @default(false) // admin-granted verification checkmark (Gitea #10 permission matrix)
|
||||
isFoundingMember Boolean @default(false) // eines der ersten 250 Tiere — einmalig in pets.create gesetzt (src/lib/new-pet-welcome.ts), nie wieder entzogen
|
||||
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
|
||||
followerVisibility FollowerVisibility @default(EVERYONE) // who can view this pet's followers/following lists (Gitea #33)
|
||||
birthday DateTime? @db.Date // actual birthday for anniversary notifications
|
||||
@@ -301,6 +323,9 @@ model Pet {
|
||||
|
||||
verificationRequests VerificationRequest[]
|
||||
|
||||
// Story-Editor: Storys, in denen dieses Tier markiert wurde
|
||||
storyMentions StoryMention[] @relation("StoryMentions")
|
||||
|
||||
// Gitea #18 — legal AI-content disclosure. Set once at avatar upload,
|
||||
// owner-immutable afterward; only a moderator can correct it (see
|
||||
// admin.setAiDisclosure). Overlay-badge only, never baked into pixels.
|
||||
@@ -490,6 +515,17 @@ model Story {
|
||||
musicOffsetSecs Float? @default(0) // start point within the track, since story display duration is fixed
|
||||
musicMuteOriginal Boolean @default(false) // VIDEO stories only — mute the baked-in Mux audio in favor of the track
|
||||
|
||||
// Story-Editor (feature/story-editor): Text, Markierungen, GIFs und Sticker
|
||||
// als Daten über dem Medium, nicht ins Bild gerechnet — validiert über
|
||||
// storyOverlaysSchema (src/lib/story-overlays.ts). Positionen relativ zur
|
||||
// Medienfläche, daher mediaWidth/-Height auch für Bilder.
|
||||
overlays Json?
|
||||
background String? @db.VarChar(30) // Farbverlauf-Schlüssel, nur TEXT-Storys
|
||||
mediaWidth Int?
|
||||
mediaHeight Int?
|
||||
mentions StoryMention[]
|
||||
notifications Notification[]
|
||||
|
||||
@@index([petId, expiresAt]) // supports lazy-delete filter query
|
||||
@@index([muxUploadId])
|
||||
@@index([muxAssetId])
|
||||
@@ -518,6 +554,22 @@ model MusicTrack {
|
||||
enum StoryMediaType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
TEXT // reine Text-Story auf Farbverlauf (Story.background), ohne Foto/Video
|
||||
}
|
||||
|
||||
// Markierte Tiere einer Story — eigene Tabelle (nicht nur im overlays-JSON),
|
||||
// damit Block-/Sichtbarkeitsregeln, Benachrichtigungen und Löschungen sauber
|
||||
// greifen. Cascade in beide Richtungen.
|
||||
model StoryMention {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||||
storyId String
|
||||
pet Pet @relation("StoryMentions", fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([storyId, petId])
|
||||
@@index([petId])
|
||||
}
|
||||
|
||||
model StoryView {
|
||||
@@ -736,14 +788,18 @@ model VetVisit {
|
||||
}
|
||||
|
||||
model Vaccine {
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
name String @db.VarChar(100)
|
||||
dateGiven DateTime @db.Date
|
||||
nextDueDate DateTime? @db.Date
|
||||
notes String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||
petId String
|
||||
name String @db.VarChar(100)
|
||||
dateGiven DateTime @db.Date
|
||||
nextDueDate DateTime? @db.Date
|
||||
notes String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now())
|
||||
// Gesetzt vom Cron /api/cron/vaccine-reminders, sobald die Erinnerung
|
||||
// (30 Tage vor nextDueDate) raus ist — verhindert Doppel-Erinnerungen.
|
||||
reminderSentAt DateTime?
|
||||
notifications Notification[]
|
||||
|
||||
@@index([petId, dateGiven(sort: Desc)])
|
||||
}
|
||||
@@ -815,6 +871,10 @@ enum NotificationType {
|
||||
PET_SUGGESTION
|
||||
REFERRAL_JOINED // ein Tier ist über die eigene Einladung zu PawFeed gekommen
|
||||
REFERRAL_RANK_UP // eigener Empfehlungsrang aufgestiegen (actorPetId = null)
|
||||
VACCINE_DUE // Impfung wird in <= 30 Tagen fällig (vaccineId gesetzt, actorPetId = null)
|
||||
STORY_MENTION // Tier wurde in einer Story markiert (storyId gesetzt)
|
||||
STORY_COMMENT // Kommentar auf eine eigene Story (storyId gesetzt)
|
||||
STORY_REACTION // Pfote auf eine eigene Story (storyId gesetzt)
|
||||
}
|
||||
|
||||
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
|
||||
@@ -830,6 +890,10 @@ model Notification {
|
||||
postId String?
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
commentId String?
|
||||
vaccine Vaccine? @relation(fields: [vaccineId], references: [id], onDelete: Cascade)
|
||||
vaccineId String?
|
||||
story Story? @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||||
storyId String?
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -975,7 +1039,7 @@ model AdRepost {
|
||||
|
||||
model ModerationLog {
|
||||
id String @id @default(cuid())
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK
|
||||
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST | HIDE_POST | UNHIDE_POST | SHADOWBAN_USER | UNSHADOWBAN_USER | SET_AI_DISCLOSURE | ENABLE_MAINTENANCE_MODE | DISABLE_MAINTENANCE_MODE | PIN_POST | UNPIN_POST | SEND_BROADCAST | DELETE_BROADCAST | CREATE_MUSIC_TRACK | UPDATE_MUSIC_TRACK | DELETE_MUSIC_TRACK | CREATE_REFERRAL_RANK | UPDATE_REFERRAL_RANK | DEACTIVATE_REFERRAL_RANK | UPDATE_REFERRAL_SETTINGS | APPROVE_REFERRAL | REVOKE_REFERRAL | REVOKE_REFERRAL_RANK | UPDATE_CURATOR_SETTINGS | GRANT_REFERRAL_RANK | RESTORE_REFERRAL_RANK | DELETE_PET | SET_FOUNDING_MEMBER | CLEAR_PET_CONTENT | ENABLE_STORY_EDITOR | DISABLE_STORY_EDITOR
|
||||
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET | SYSTEM | MUSIC_TRACK
|
||||
targetId String
|
||||
reason String? @db.VarChar(500)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* DSGVO-Pfade rund um das Empfehlungsprogramm: Datenauskunft (gdpr-export.ts)
|
||||
* und Kontolöschung (delete-account.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
const mockDeleteUser = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
clerkClient: vi.fn().mockResolvedValue({
|
||||
users: { getUser: vi.fn().mockResolvedValue(null), deleteUser: (...a: unknown[]) => mockDeleteUser(...a) },
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock("@/lib/media-url", () => ({ getMediaUrl: (key: string) => `media/${key}` }));
|
||||
vi.mock("@/lib/avatar-url", () => ({ getAvatarUrl: (key: string | null) => (key ? `avatar/${key}` : null) }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
import { deleteOwnerAccount } from "@/lib/delete-account";
|
||||
import { buildDataExport } from "@/lib/gdpr-export";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
|
||||
|
||||
describe("deleteOwnerAccount", () => {
|
||||
it("removes the deleted owner's id from other owners' referral and legacy invite links", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findMany.mockResolvedValue([]);
|
||||
prisma.pet.deleteMany.mockResolvedValue({ count: 0 });
|
||||
prisma.owner.updateMany.mockResolvedValue({ count: 2 });
|
||||
prisma.owner.delete.mockResolvedValue({});
|
||||
|
||||
await deleteOwnerAccount(asDb(prisma), "owner-gone");
|
||||
|
||||
expect(prisma.owner.updateMany).toHaveBeenCalledWith({
|
||||
where: { invitedById: "owner-gone" },
|
||||
data: { invitedById: null },
|
||||
});
|
||||
expect(prisma.inviteCode.updateMany).toHaveBeenCalledWith({
|
||||
where: { usedById: "owner-gone" },
|
||||
data: { usedById: null },
|
||||
});
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith("owner-gone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDataExport — referral program", () => {
|
||||
it("includes code, ranks and invites but names other people only by pet name", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.owner.findUnique.mockResolvedValue({
|
||||
id: "owner-1",
|
||||
createdAt: new Date("2026-09-01"),
|
||||
invitedById: "user_inviter_secret_id",
|
||||
invitedAt: new Date("2026-09-02"),
|
||||
referralQualifiedAt: new Date("2026-09-03"),
|
||||
referralFlaggedAt: null,
|
||||
referralRevokedAt: null,
|
||||
referralCode: "LUNA-7K3M",
|
||||
qualifiedReferralCount: 1,
|
||||
referralPerks: ["PROFILE_RING"],
|
||||
acquisitionSource: "FRIENDS",
|
||||
acquisitionAnsweredAt: new Date("2026-09-02"),
|
||||
legalAcknowledgedAt: null,
|
||||
contentRightsConsentedAt: null,
|
||||
dataProcessingConsentedAt: null,
|
||||
ban: null,
|
||||
adminRole: null,
|
||||
pets: [],
|
||||
rankAchievements: [{ achievedAt: new Date("2026-09-10"), rank: { nameDe: "Pfotenfreund", threshold: 1 } }],
|
||||
issuedInvites: [],
|
||||
});
|
||||
prisma.pet.findFirst.mockResolvedValue({ name: "Bubi" });
|
||||
prisma.owner.findMany.mockResolvedValue([
|
||||
{ invitedAt: new Date("2026-09-09"), referralQualifiedAt: new Date("2026-09-10"), referralRevokedAt: null, pets: [{ name: "Kiki" }] },
|
||||
]);
|
||||
|
||||
const data = await buildDataExport(asDb(prisma), "owner-1");
|
||||
|
||||
expect(data?.referralProgram).toMatchObject({
|
||||
ownCode: "LUNA-7K3M",
|
||||
acquisitionSource: "FRIENDS",
|
||||
invitedBy: { petName: "Bubi" },
|
||||
countedInvites: 1,
|
||||
invitesMade: [{ petName: "Kiki" }],
|
||||
ranksReached: [{ rank: "Pfotenfreund", threshold: 1 }],
|
||||
currentPerks: ["PROFILE_RING"],
|
||||
});
|
||||
expect(JSON.stringify(data)).not.toContain("user_inviter_secret_id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* found.md: Rückmeldung an den Melder per Team-Nachricht — Begründung bei
|
||||
* abgewiesenem Report, Dank bei bestätigter Löschung.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
auth: vi.fn().mockResolvedValue({ userId: "admin-1" }),
|
||||
clerkClient: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/supabase-storage", () => ({
|
||||
createSignedUploadUrl: vi.fn(),
|
||||
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024,
|
||||
MUSIC_STORAGE_BUCKET: "music-tracks",
|
||||
MUSIC_ALLOWED_CONTENT_TYPES: ["audio/mpeg", "audio/mp4", "audio/aac"],
|
||||
MAX_MUSIC_FILE_SIZE: 5 * 1024 * 1024,
|
||||
}));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/gdpr-export", () => ({ buildDataExport: vi.fn() }));
|
||||
vi.mock("@/lib/data-export-storage", () => ({ uploadExport: vi.fn() }));
|
||||
vi.mock("@/lib/mail", () => ({ sendMail: vi.fn() }));
|
||||
vi.mock("@/lib/maintenance", () => ({ isMaintenanceModeEnabled: vi.fn(), setMaintenanceMode: vi.fn() }));
|
||||
|
||||
const { mockSendTeamMessage } = vi.hoisted(() => ({ mockSendTeamMessage: vi.fn() }));
|
||||
vi.mock("@/lib/team-pet", () => ({
|
||||
ensureTeamPet: vi.fn(),
|
||||
sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args),
|
||||
}));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
async function createAdminCaller() {
|
||||
const { adminRouter } = await import("@/trpc/routers/admin");
|
||||
const { initTRPC } = await import("@trpc/server");
|
||||
const mockPrisma = createMockPrisma();
|
||||
mockPrisma.$transaction.mockImplementation((arg: unknown) => Promise.all(arg as unknown[]));
|
||||
const t = initTRPC.context<{ userId: string | null; prisma: typeof mockPrisma }>().create();
|
||||
const testRouter = t.router({ admin: adminRouter });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const caller = testRouter.createCaller({ userId: "admin-1", prisma: mockPrisma as any });
|
||||
return { caller, mockPrisma };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
mockSendTeamMessage.mockReset();
|
||||
});
|
||||
|
||||
describe("admin.dismissReport reporter feedback", () => {
|
||||
it("sends the reporter a team message with the reason, naming the reported post's pet", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: "post-1", targetPetId: null });
|
||||
mockPrisma.post.findUnique.mockResolvedValue({ pet: { name: "Bubi" } });
|
||||
mockSendTeamMessage.mockResolvedValue(undefined);
|
||||
|
||||
await caller.admin.dismissReport({ reportId: "r1", message: "Das ist ein normales Spielvideo." });
|
||||
|
||||
expect(mockSendTeamMessage).toHaveBeenCalledTimes(1);
|
||||
const [, petId, body] = mockSendTeamMessage.mock.calls[0];
|
||||
expect(petId).toBe("reporter-1");
|
||||
expect(body).toContain("Meldung zum Beitrag von Bubi");
|
||||
expect(body).toContain("keinen Verstoß");
|
||||
expect(body).toContain("Begründung: Das ist ein normales Spielvideo.");
|
||||
expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ reason: "Das ist ein normales Spielvideo." }) })
|
||||
);
|
||||
});
|
||||
|
||||
it("names the profile for a pet report and omits an empty reason", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.report.findUnique.mockResolvedValue({ id: "r2", reporterPetId: "reporter-1", targetPostId: null, targetPetId: "pet-9" });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ name: "Luna" });
|
||||
mockSendTeamMessage.mockResolvedValue(undefined);
|
||||
|
||||
await caller.admin.dismissReport({ reportId: "r2" });
|
||||
|
||||
const body = mockSendTeamMessage.mock.calls[0][2] as string;
|
||||
expect(body).toContain("Meldung zum Profil von Luna");
|
||||
expect(body).not.toContain("Begründung");
|
||||
});
|
||||
|
||||
it("sends nothing when notifyReporter is false", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: null });
|
||||
|
||||
await caller.admin.dismissReport({ reportId: "r1", notifyReporter: false });
|
||||
|
||||
expect(mockSendTeamMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still succeeds when the message fails", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.report.findUnique.mockResolvedValue({ id: "r1", reporterPetId: "reporter-1", targetPostId: null, targetPetId: null });
|
||||
mockSendTeamMessage.mockRejectedValue(new Error("db down"));
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(caller.admin.dismissReport({ reportId: "r1" })).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin.deleteReportedPost reporter feedback", () => {
|
||||
it("deletes the post's reports and thanks every reporter once", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
|
||||
mockPrisma.report.findMany.mockResolvedValue([
|
||||
{ reporterPetId: "reporter-1" },
|
||||
{ reporterPetId: "reporter-2" },
|
||||
{ reporterPetId: "reporter-1" },
|
||||
]);
|
||||
mockSendTeamMessage.mockResolvedValue(undefined);
|
||||
|
||||
await caller.admin.deleteReportedPost({ postId: "post-1", reason: "Gewaltdarstellung" });
|
||||
|
||||
expect(mockPrisma.report.deleteMany).toHaveBeenCalledWith({ where: { targetPostId: "post-1" } });
|
||||
expect(mockSendTeamMessage.mock.calls.map((c) => c[1])).toEqual(["reporter-1", "reporter-2"]);
|
||||
const body = mockSendTeamMessage.mock.calls[0][2] as string;
|
||||
expect(body).toContain("Meldung zum Beitrag von Bubi");
|
||||
expect(body).toContain("Inhalt entfernt");
|
||||
// Die interne Lösch-Begründung geht nicht an den Melder.
|
||||
expect(body).not.toContain("Gewaltdarstellung");
|
||||
});
|
||||
|
||||
it("keeps notifying the other reporters when one message fails", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
|
||||
mockPrisma.report.findMany.mockResolvedValue([{ reporterPetId: "reporter-1" }, { reporterPetId: "reporter-2" }]);
|
||||
mockSendTeamMessage.mockRejectedValueOnce(new Error("fail")).mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(caller.admin.deleteReportedPost({ postId: "post-1" })).resolves.toEqual({ success: true });
|
||||
expect(mockSendTeamMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sends nothing when notifyReporters is false", async () => {
|
||||
vi.stubEnv("ADMIN_OWNER_ID", "admin-1");
|
||||
const { caller, mockPrisma } = await createAdminCaller();
|
||||
mockPrisma.post.findUnique.mockResolvedValue({ repost: null, pet: { name: "Bubi" } });
|
||||
mockPrisma.report.findMany.mockResolvedValue([{ reporterPetId: "reporter-1" }]);
|
||||
|
||||
await caller.admin.deleteReportedPost({ postId: "post-1", notifyReporters: false });
|
||||
|
||||
expect(mockSendTeamMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -198,7 +198,7 @@ describe("admin transactional moderation actions", () => {
|
||||
mockPrisma.report.delete.mockResolvedValue({ id: "report-1" });
|
||||
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
||||
|
||||
const result = await caller.admin.dismissReport({ reportId: "report-1" });
|
||||
const result = await caller.admin.dismissReport({ reportId: "report-1", notifyReporter: false });
|
||||
|
||||
expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array));
|
||||
expect(mockPrisma.report.delete).toHaveBeenCalledWith({ where: { id: "report-1" } });
|
||||
@@ -211,6 +211,7 @@ describe("admin transactional moderation actions", () => {
|
||||
mockPrisma.post.findUnique.mockResolvedValue({ repost: null });
|
||||
mockPrisma.post.delete.mockResolvedValue({ id: "post-1" });
|
||||
mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" });
|
||||
mockPrisma.report.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await caller.admin.deleteReportedPost({ postId: "post-1" });
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* /app — Download-Weiterleitung (src/lib/app-redirect.ts, app-redirect-stats.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
const redisStore = new Map<string, Map<string, number>>();
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
hincrby: vi.fn(async (key: string, field: string, by: number) => {
|
||||
const hash = redisStore.get(key) ?? new Map<string, number>();
|
||||
hash.set(field, (hash.get(field) ?? 0) + by);
|
||||
redisStore.set(key, hash);
|
||||
return hash.get(field);
|
||||
}),
|
||||
expire: vi.fn(async () => 1),
|
||||
hgetall: vi.fn(async (key: string) => Object.fromEntries(redisStore.get(key) ?? new Map())),
|
||||
},
|
||||
}));
|
||||
|
||||
import { PLAY_STORE_URL } from "@/lib/app-links";
|
||||
import { detectPlatform, resolveAppRedirect } from "@/lib/app-redirect";
|
||||
import { countAppRedirect, getAppRedirectStats } from "@/lib/app-redirect-stats";
|
||||
|
||||
const ORIGIN = "https://pawfeed.org";
|
||||
const ANDROID = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36";
|
||||
const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1";
|
||||
const DESKTOP = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128 Safari/537.36";
|
||||
|
||||
const params = (query: string) => new URLSearchParams(query);
|
||||
|
||||
describe("detectPlatform", () => {
|
||||
it("recognises Android, iPhone and everything else", () => {
|
||||
expect(detectPlatform(ANDROID)).toBe("android");
|
||||
expect(detectPlatform(IPHONE)).toBe("ios");
|
||||
expect(detectPlatform(DESKTOP)).toBe("other");
|
||||
expect(detectPlatform(null)).toBe("other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAppRedirect", () => {
|
||||
it("sends Android to Google Play with the UTM values as Play referrer", () => {
|
||||
const result = resolveAppRedirect(ANDROID, params("utm_source=flyer&utm_campaign=tierheim-xy"), ORIGIN);
|
||||
expect(result.platform).toBe("android");
|
||||
expect(result.source).toBe("flyer");
|
||||
expect(result.location.startsWith(PLAY_STORE_URL)).toBe(true);
|
||||
const referrer = new URL(result.location).searchParams.get("referrer");
|
||||
expect(referrer).toBe("utm_source=flyer&utm_campaign=tierheim-xy");
|
||||
});
|
||||
|
||||
it("sends iPhones to the web start page while there is no App Store URL", () => {
|
||||
const result = resolveAppRedirect(IPHONE, params("utm_source=sticker"), ORIGIN, null);
|
||||
expect(result.location).toBe("https://pawfeed.org/?utm_source=sticker");
|
||||
});
|
||||
|
||||
it("sends iPhones to the App Store once the URL is configured", () => {
|
||||
const store = "https://apps.apple.com/app/id123";
|
||||
expect(resolveAppRedirect(IPHONE, params(""), ORIGIN, store).location).toBe(store);
|
||||
});
|
||||
|
||||
it("turns ?ref=<code> into the invite page on the web", () => {
|
||||
const result = resolveAppRedirect(DESKTOP, params("ref=bubi-7k3m&utm_source=qr"), ORIGIN, null);
|
||||
expect(result.location).toBe("https://pawfeed.org/r/BUBI-7K3M?utm_source=qr");
|
||||
});
|
||||
|
||||
it("drops unsafe UTM values and invalid codes instead of passing them on", () => {
|
||||
const result = resolveAppRedirect(DESKTOP, params("utm_source=<script>&ref=nope"), ORIGIN, null);
|
||||
expect(result.location).toBe("https://pawfeed.org/");
|
||||
expect(result.source).toBe("direct");
|
||||
});
|
||||
|
||||
it("uses the plain Play URL without any campaign data", () => {
|
||||
expect(resolveAppRedirect(ANDROID, params(""), ORIGIN).location).toBe(PLAY_STORE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("app redirect stats", () => {
|
||||
beforeEach(() => redisStore.clear());
|
||||
|
||||
it("sums clicks per source and platform over the requested days", async () => {
|
||||
const today = new Date("2026-10-02T10:00:00Z");
|
||||
const yesterday = new Date("2026-10-01T10:00:00Z");
|
||||
await countAppRedirect("flyer", "android", today);
|
||||
await countAppRedirect("flyer", "android", yesterday);
|
||||
await countAppRedirect("flyer", "ios", today);
|
||||
await countAppRedirect("direct", "other", today);
|
||||
|
||||
const stats = await getAppRedirectStats(30, today);
|
||||
|
||||
expect(stats[0]).toEqual({ source: "flyer", android: 2, ios: 1, other: 0, total: 3 });
|
||||
expect(stats[1]).toEqual({ source: "direct", android: 0, ios: 0, other: 1, total: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* /api/cron/cleanup-stories — löscht Storys, die älter als 7 Tage sind.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) }));
|
||||
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/mux", () => ({ mux: { video: { assets: { delete: (...a: unknown[]) => mockMuxDelete(...a) } } } }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
const mockPrisma = createMockPrisma();
|
||||
// Getter: vi.mock wird nach oben gehoben, mockPrisma existiert erst beim Zugriff.
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
get prisma() {
|
||||
return mockPrisma;
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/cron/cleanup-stories/route";
|
||||
|
||||
const request = (auth?: string) =>
|
||||
new Request("https://pawfeed.org/api/cron/cleanup-stories", { headers: auth ? { authorization: auth } : {} });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.CRON_SECRET = "test-secret";
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/cron/cleanup-stories", () => {
|
||||
it("rejects requests without the cron secret", async () => {
|
||||
expect((await GET(request())).status).toBe(401);
|
||||
expect((await GET(request("Bearer wrong"))).status).toBe(401);
|
||||
expect(mockPrisma.story.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes stories older than 7 days with their media", async () => {
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{ id: "old-photo", storageKey: "stories/a.jpg", muxAssetId: null },
|
||||
{ id: "old-video", storageKey: null, muxAssetId: "asset-1" },
|
||||
]);
|
||||
|
||||
const res = await GET(request("Bearer test-secret"));
|
||||
|
||||
expect(await res.json()).toEqual({ ok: true, deleted: 2, failed: 0, more: false });
|
||||
const where = mockPrisma.story.findMany.mock.calls[0][0].where;
|
||||
const cutoffAgeDays = (Date.now() - where.createdAt.lt.getTime()) / (24 * 60 * 60 * 1000);
|
||||
expect(Math.round(cutoffAgeDays)).toBe(7);
|
||||
expect(mockPrisma.storyView.deleteMany).toHaveBeenCalledWith({ where: { storyId: "old-photo" } });
|
||||
expect(mockDeleteFiles).toHaveBeenCalledWith(["stories/a.jpg"]);
|
||||
expect(mockMuxDelete).toHaveBeenCalledWith("asset-1");
|
||||
});
|
||||
|
||||
it("keeps going when one story fails and reports it", async () => {
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{ id: "s1", storageKey: null, muxAssetId: null },
|
||||
{ id: "s2", storageKey: null, muxAssetId: null },
|
||||
]);
|
||||
mockPrisma.$transaction.mockRejectedValueOnce(new Error("db hiccup")).mockResolvedValueOnce([]);
|
||||
|
||||
const body = await (await GET(request("Bearer test-secret"))).json();
|
||||
|
||||
expect(body).toEqual({ ok: false, deleted: 1, failed: 1, more: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* deletePetCompletely (src/lib/delete-pet.ts) — von pets.delete,
|
||||
* Kontolöschung und Admin genutzt.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) }));
|
||||
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/mux", () => ({ mux: { video: { assets: { delete: (...a: unknown[]) => mockMuxDelete(...a) } } } }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
import { deletePetCompletely } from "@/lib/delete-pet";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
|
||||
|
||||
const sqlOf = (call: unknown[]) => (call[0] as TemplateStringsArray).join("?");
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("deletePetCompletely", () => {
|
||||
function setup() {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue({
|
||||
avatarKey: "avatars/a.png",
|
||||
posts: [
|
||||
{ images: [{ storageKey: "posts/p1.jpg" }, { storageKey: "posts/p2.jpg" }], videoPost: null },
|
||||
{ images: [], videoPost: { muxAssetId: "asset-post" } },
|
||||
],
|
||||
stories: [{ storageKey: "stories/s1.jpg", muxAssetId: null }, { storageKey: null, muxAssetId: "asset-story" }],
|
||||
});
|
||||
prisma.pet.delete.mockResolvedValue({});
|
||||
prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma));
|
||||
return prisma;
|
||||
}
|
||||
|
||||
it("fixes foreign counters, removes blockers and then deletes the pet", async () => {
|
||||
const prisma = setup();
|
||||
|
||||
await deletePetCompletely(asDb(prisma), "pet-1");
|
||||
|
||||
const statements = prisma.$executeRaw.mock.calls.map(sqlOf);
|
||||
expect(statements.some((s) => s.includes(`"reactionCount"`) && s.includes(`"Reaction"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`"commentCount"`) && s.includes(`"Comment"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`"repostCount"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`"StoryReaction"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`"StoryComment"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`DELETE FROM "StoryView"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`DELETE FROM "Report"`))).toBe(true);
|
||||
expect(statements.some((s) => s.includes(`DELETE FROM "Conversation"`))).toBe(true);
|
||||
expect(prisma.pet.delete).toHaveBeenCalledWith({ where: { id: "pet-1" } });
|
||||
// Pet-Delete erst nach allen Vorbereitungen
|
||||
const lastRaw = Math.max(...prisma.$executeRaw.mock.invocationCallOrder);
|
||||
expect(prisma.pet.delete.mock.invocationCallOrder[0]).toBeGreaterThan(lastRaw);
|
||||
});
|
||||
|
||||
it("removes avatar, post images, story images and Mux assets", async () => {
|
||||
const prisma = setup();
|
||||
await deletePetCompletely(asDb(prisma), "pet-1");
|
||||
expect(mockDeleteFiles).toHaveBeenCalledWith(["avatars/a.png", "posts/p1.jpg", "posts/p2.jpg", "stories/s1.jpg"]);
|
||||
expect(mockMuxDelete).toHaveBeenCalledWith("asset-post");
|
||||
expect(mockMuxDelete).toHaveBeenCalledWith("asset-story");
|
||||
});
|
||||
|
||||
it("does nothing for an unknown pet", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue(null);
|
||||
await deletePetCompletely(asDb(prisma), "nope");
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* HEIC/HEIF-Erkennung und -Umwandlung vor dem Upload (src/lib/heic-to-jpeg.ts).
|
||||
* jsdom kann keine Bilder dekodieren — der Fehlerpfad steht damit für
|
||||
* "Browser ohne HEIC-Unterstützung" (z. B. Chrome unter Windows).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { HeicUnsupportedError, isHeicFile, normalizeImageFile } from "@/lib/heic-to-jpeg";
|
||||
|
||||
const file = (name: string, type: string) => new File([new Uint8Array([1, 2, 3])], name, { type });
|
||||
|
||||
describe("isHeicFile", () => {
|
||||
it("detects HEIC/HEIF by MIME type or by extension when the type is empty", () => {
|
||||
expect(isHeicFile(file("IMG_0001.HEIC", "image/heic"))).toBe(true);
|
||||
expect(isHeicFile(file("photo.heif", "image/heif"))).toBe(true);
|
||||
expect(isHeicFile(file("IMG_0002.heic", ""))).toBe(true);
|
||||
expect(isHeicFile(file("photo.jpg", "image/jpeg"))).toBe(false);
|
||||
expect(isHeicFile(file("photo.png", "image/png"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeImageFile", () => {
|
||||
it("passes JPEG/PNG/WebP through unchanged", async () => {
|
||||
const jpeg = file("photo.jpg", "image/jpeg");
|
||||
expect(await normalizeImageFile(jpeg)).toBe(jpeg);
|
||||
});
|
||||
|
||||
it("reports HeicUnsupportedError when the browser cannot decode HEIC", async () => {
|
||||
await expect(normalizeImageFile(file("IMG_0001.HEIC", "image/heic"))).rejects.toBeInstanceOf(HeicUnsupportedError);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,9 @@ interface MockPetClient {
|
||||
findFirst: MockFn;
|
||||
update: MockFn;
|
||||
delete: MockFn;
|
||||
deleteMany: MockFn;
|
||||
count: MockFn;
|
||||
groupBy: MockFn;
|
||||
}
|
||||
|
||||
interface MockOwnerClient {
|
||||
@@ -31,12 +33,15 @@ interface MockOwnerClient {
|
||||
create: MockFn;
|
||||
update: MockFn;
|
||||
updateMany: MockFn;
|
||||
delete: MockFn;
|
||||
count: MockFn;
|
||||
}
|
||||
|
||||
interface MockInviteCodeClient {
|
||||
count: MockFn;
|
||||
createMany: MockFn;
|
||||
deleteMany: MockFn;
|
||||
updateMany: MockFn;
|
||||
}
|
||||
|
||||
interface MockLegalDocumentVersionClient {
|
||||
@@ -138,6 +143,7 @@ interface MockReportClient {
|
||||
findMany: MockFn;
|
||||
findUnique: MockFn;
|
||||
delete: MockFn;
|
||||
deleteMany: MockFn;
|
||||
count: MockFn;
|
||||
}
|
||||
|
||||
@@ -279,6 +285,7 @@ interface MockVaccineClient {
|
||||
create: MockFn;
|
||||
delete: MockFn;
|
||||
deleteMany: MockFn;
|
||||
updateMany: MockFn;
|
||||
}
|
||||
|
||||
interface MockHealthCardClient {
|
||||
@@ -337,7 +344,9 @@ interface MockReferralRankClient {
|
||||
|
||||
interface MockReferralRankAchievementClient {
|
||||
findMany: MockFn;
|
||||
findUnique: MockFn;
|
||||
createMany: MockFn;
|
||||
upsert: MockFn;
|
||||
deleteMany: MockFn;
|
||||
groupBy: MockFn;
|
||||
}
|
||||
@@ -347,6 +356,11 @@ interface MockReferralSettingsClient {
|
||||
upsert: MockFn;
|
||||
}
|
||||
|
||||
interface MockCuratorSettingsClient {
|
||||
findUnique: MockFn;
|
||||
upsert: MockFn;
|
||||
}
|
||||
|
||||
interface MockPrismaClient {
|
||||
pet: MockPetClient;
|
||||
owner: MockOwnerClient;
|
||||
@@ -358,6 +372,7 @@ interface MockPrismaClient {
|
||||
postImage: MockPostImageClient;
|
||||
milestone: MockMilestoneClient;
|
||||
story: MockStoryClient;
|
||||
storyMention: { createMany: MockFn; findMany: MockFn; deleteMany: MockFn };
|
||||
storyView: MockStoryViewClient;
|
||||
follow: MockFollowClient;
|
||||
block: MockBlockClient;
|
||||
@@ -396,6 +411,7 @@ interface MockPrismaClient {
|
||||
referralRank: MockReferralRankClient;
|
||||
referralRankAchievement: MockReferralRankAchievementClient;
|
||||
referralSettings: MockReferralSettingsClient;
|
||||
curatorSettings: MockCuratorSettingsClient;
|
||||
$transaction: MockFn;
|
||||
$executeRaw: MockFn;
|
||||
$disconnect: MockFn;
|
||||
@@ -410,7 +426,9 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
owner: {
|
||||
upsert: vi.fn(),
|
||||
@@ -419,6 +437,7 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
// Legacy InviteCode table (pre-referral-program one-time codes) — only
|
||||
@@ -426,6 +445,8 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
inviteCode: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
legalDocumentVersion: {
|
||||
create: vi.fn(),
|
||||
@@ -484,6 +505,11 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
deleteMany: vi.fn(),
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
storyMention: {
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
storyView: {
|
||||
upsert: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
@@ -517,6 +543,7 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
userBan: {
|
||||
@@ -636,6 +663,7 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
healthCard: {
|
||||
findUnique: vi.fn(),
|
||||
@@ -683,7 +711,9 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
},
|
||||
referralRankAchievement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
upsert: vi.fn().mockResolvedValue({}),
|
||||
deleteMany: vi.fn(),
|
||||
groupBy: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
@@ -692,6 +722,11 @@ export function createMockPrisma(): MockPrismaClient {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
// Ohne Zeile greifen die Standardwerte (getCuratorSettings in new-pet-welcome.ts).
|
||||
curatorSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
||||
$disconnect: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Gründungsmitglieder + Kurator-Begrüßung beim Anlegen eines Tiers
|
||||
* (src/lib/new-pet-welcome.ts, src/trpc/routers/curator-admin.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
|
||||
const mockSendTeamMessage = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/team-pet", () => ({
|
||||
ensureTeamPet: vi.fn().mockResolvedValue({ id: "pet-team" }),
|
||||
sendTeamMessage: (...args: unknown[]) => mockSendTeamMessage(...args),
|
||||
}));
|
||||
vi.mock("@/lib/redis", () => ({ redis: {} }));
|
||||
const mockBackfillOnFollow = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: (...args: unknown[]) => mockBackfillOnFollow(...args) }));
|
||||
const mockAssertAdmin = vi.fn();
|
||||
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...args: unknown[]) => mockAssertAdmin(...args) }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
import { notify } from "@/lib/notify";
|
||||
import { FOUNDING_MEMBER_LIMIT, markFoundingMember, renderWelcomeText, welcomeNewPet } from "@/lib/new-pet-welcome";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const asDb = (m: ReturnType<typeof createMockPrisma>) => m as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertAdmin.mockResolvedValue("SUPER_ADMIN");
|
||||
});
|
||||
|
||||
describe("renderWelcomeText", () => {
|
||||
it("replaces every {name} with the pet's name", () => {
|
||||
expect(renderWelcomeText("Hallo {name}! Schön, dass {name} da ist.", "Luna")).toBe("Hallo Luna! Schön, dass Luna da ist.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("markFoundingMember", () => {
|
||||
it("uses one guarded SQL update with the 250 limit", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.$executeRaw.mockResolvedValue(1);
|
||||
|
||||
expect(await markFoundingMember(asDb(prisma), "pet-1")).toBe(true);
|
||||
const values = prisma.$executeRaw.mock.calls[0].slice(1);
|
||||
expect(values).toContain("pet-1");
|
||||
expect(values).toContain(FOUNDING_MEMBER_LIMIT);
|
||||
expect(FOUNDING_MEMBER_LIMIT).toBe(250);
|
||||
});
|
||||
|
||||
it("reports false once the 250 places are taken", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.$executeRaw.mockResolvedValue(0);
|
||||
expect(await markFoundingMember(asDb(prisma), "pet-251")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("welcomeNewPet", () => {
|
||||
it("follows the new pet as the team and sends a German welcome with the pet's name", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.follow.upsert.mockResolvedValue({});
|
||||
|
||||
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
|
||||
|
||||
expect(prisma.follow.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create: { followerPetId: "pet-team", followeePetId: "pet-luna" } })
|
||||
);
|
||||
expect(notify).toHaveBeenCalledWith(expect.anything(), { recipientPetId: "pet-luna", type: "FOLLOW", actorPetId: "pet-team" });
|
||||
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-luna", expect.stringContaining("Hallo Luna"));
|
||||
});
|
||||
|
||||
it("makes the new pet follow the team back and seeds its feed with the team's posts", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.follow.upsert.mockResolvedValue({});
|
||||
|
||||
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
|
||||
|
||||
expect(prisma.follow.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create: { followerPetId: "pet-luna", followeePetId: "pet-team" } })
|
||||
);
|
||||
expect(mockBackfillOnFollow).toHaveBeenCalledWith(expect.anything(), "pet-luna", "pet-team", expect.anything());
|
||||
});
|
||||
|
||||
it("still sends the welcome when following the team back fails", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.follow.upsert.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error("db down"));
|
||||
|
||||
await welcomeNewPet(asDb(prisma), { id: "pet-luna", name: "Luna" }, "de");
|
||||
|
||||
expect(mockBackfillOnFollow).not.toHaveBeenCalled();
|
||||
expect(mockSendTeamMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the English text for English users", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.follow.upsert.mockResolvedValue({});
|
||||
await welcomeNewPet(asDb(prisma), { id: "pet-max", name: "Max" }, "en");
|
||||
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-max", expect.stringContaining("Hi Max"));
|
||||
});
|
||||
|
||||
it("respects the admin switches and the edited text", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.curatorSettings.findUnique.mockResolvedValue({
|
||||
welcomeEnabled: true,
|
||||
autoFollowEnabled: false,
|
||||
welcomeTextDe: "Servus {name}!",
|
||||
welcomeTextEn: "Hey {name}!",
|
||||
});
|
||||
|
||||
await welcomeNewPet(asDb(prisma), { id: "pet-kiki", name: "Kiki" }, "de");
|
||||
|
||||
expect(prisma.follow.upsert).not.toHaveBeenCalled();
|
||||
expect(mockBackfillOnFollow).not.toHaveBeenCalled();
|
||||
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-kiki", "Servus Kiki!");
|
||||
});
|
||||
|
||||
it("never welcomes the team pet itself", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
await welcomeNewPet(asDb(prisma), { id: "pet-team", name: "PawFeed Team" }, "de");
|
||||
expect(prisma.follow.upsert).not.toHaveBeenCalled();
|
||||
expect(mockSendTeamMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("curatorAdmin.update", () => {
|
||||
async function caller(prisma = createMockPrisma()) {
|
||||
const { curatorAdminRouter } = await import("@/trpc/routers/curator-admin");
|
||||
const { initTRPC } = await import("@trpc/server");
|
||||
const t = initTRPC.context<{ userId: string; prisma: unknown; ip: string }>().create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { c: t.router({ curatorAdmin: curatorAdminRouter }).createCaller({ userId: "admin-1", prisma: prisma as any, ip: "127.0.0.1" } as any), prisma };
|
||||
}
|
||||
|
||||
const valid = { welcomeEnabled: true, autoFollowEnabled: true, welcomeTextDe: "Hallo {name}", welcomeTextEn: "Hi {name}" };
|
||||
|
||||
it("rejects a text without the {name} placeholder", async () => {
|
||||
const { c } = await caller();
|
||||
await expect(c.curatorAdmin.update({ ...valid, welcomeTextDe: "Hallo du" })).rejects.toThrow("{name}");
|
||||
});
|
||||
|
||||
it("requires SUPER_ADMIN and writes the audit log", async () => {
|
||||
const { c, prisma } = await caller();
|
||||
prisma.$transaction.mockResolvedValue([{}]);
|
||||
await c.curatorAdmin.update(valid);
|
||||
expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "SUPER_ADMIN");
|
||||
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "UPDATE_CURATOR_SETTINGS" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Admin-Tierverwaltung (pet-admin.ts) und dauerhaftes Entziehen/Vergeben von
|
||||
* Empfehlungsrängen (referral-admin.ts, referrals.ts).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/redis", () => ({ redis: {} }));
|
||||
vi.mock("@/lib/notify", () => ({ notify: vi.fn() }));
|
||||
vi.mock("@/lib/feed-helpers", () => ({ backfillOnFollow: vi.fn() }));
|
||||
const mockDeletePet = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...a: unknown[]) => mockDeletePet(...a) }));
|
||||
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/supabase-storage", () => ({ deleteFiles: (...a: unknown[]) => mockDeleteFiles(...a) }));
|
||||
const mockSendTeamMessage = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/team-pet", () => ({
|
||||
ensureTeamPet: vi.fn().mockResolvedValue({ id: "pet-team" }),
|
||||
sendTeamMessage: (...a: unknown[]) => mockSendTeamMessage(...a),
|
||||
}));
|
||||
const mockSendMail = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/mail", () => ({ sendMail: (...a: unknown[]) => mockSendMail(...a) }));
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
clerkClient: vi.fn().mockResolvedValue({
|
||||
users: {
|
||||
getUser: vi.fn().mockResolvedValue({
|
||||
firstName: "Dani",
|
||||
primaryEmailAddressId: "e1",
|
||||
emailAddresses: [{ id: "e1", emailAddress: "owner@example.com" }],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const mockAssertAdmin = vi.fn();
|
||||
vi.mock("@/trpc/routers/admin", () => ({ assertAdmin: (...a: unknown[]) => mockAssertAdmin(...a) }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
import { awardRanks } from "@/lib/referrals";
|
||||
|
||||
type MockPrisma = ReturnType<typeof createMockPrisma>;
|
||||
|
||||
async function createCaller(prisma: MockPrisma = createMockPrisma()) {
|
||||
const { petAdminRouter } = await import("@/trpc/routers/pet-admin");
|
||||
const { referralAdminRouter } = await import("@/trpc/routers/referral-admin");
|
||||
const { initTRPC } = await import("@trpc/server");
|
||||
const t = initTRPC.context<{ userId: string; prisma: unknown; ip: string }>().create();
|
||||
const r = t.router({ petAdmin: petAdminRouter, referralAdmin: referralAdminRouter });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { c: r.createCaller({ userId: "admin-1", prisma: prisma as any, ip: "127.0.0.1" } as any), prisma };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAssertAdmin.mockResolvedValue("SUPER_ADMIN");
|
||||
});
|
||||
|
||||
describe("petAdmin.delete", () => {
|
||||
it("deletes completely and tells the owner by DM on another of their pets", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" });
|
||||
prisma.pet.findFirst.mockResolvedValue({ id: "pet-other" });
|
||||
prisma.moderationLog.create.mockResolvedValue({});
|
||||
const { c } = await createCaller(prisma);
|
||||
|
||||
const res = await c.petAdmin.delete({ petId: "pet-luna", reason: "Fake-Profil", notifyOwner: true });
|
||||
|
||||
expect(res).toEqual({ notified: "dm" });
|
||||
expect(mockAssertAdmin).toHaveBeenCalledWith(expect.anything(), "admin-1", "SUPER_ADMIN");
|
||||
expect(mockDeletePet).toHaveBeenCalledWith(prisma, "pet-luna");
|
||||
expect(mockSendTeamMessage).toHaveBeenCalledWith(expect.anything(), "pet-other", expect.stringContaining("Fake-Profil"));
|
||||
expect(prisma.moderationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "DELETE_PET", targetType: "PET", targetId: "pet-luna" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to e-mail when it was the owner's only pet", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue({ name: "Luna", ownerId: "owner-1" });
|
||||
prisma.pet.findFirst.mockResolvedValue(null);
|
||||
prisma.moderationLog.create.mockResolvedValue({});
|
||||
const { c } = await createCaller(prisma);
|
||||
|
||||
const res = await c.petAdmin.delete({ petId: "pet-luna", reason: "<b>Spam</b>", notifyOwner: true });
|
||||
|
||||
expect(res).toEqual({ notified: "email" });
|
||||
const mail = mockSendMail.mock.calls[0][0];
|
||||
expect(mail.to).toBe("owner@example.com");
|
||||
expect(mail.html).toContain("<b>Spam</b>");
|
||||
expect(mail.html).not.toContain("<b>Spam</b>");
|
||||
});
|
||||
|
||||
it("refuses to delete the curator pet", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue({ name: "PawFeed Team", ownerId: "admin" });
|
||||
const { c } = await createCaller(prisma);
|
||||
await expect(c.petAdmin.delete({ petId: "pet-team", reason: "x", notifyOwner: false })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mockDeletePet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("petAdmin.speciesDistribution", () => {
|
||||
it("counts pets per species and breed, includes empty species and excludes the curator pet", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
mockAssertAdmin.mockResolvedValue("MODERATOR");
|
||||
prisma.species.findMany.mockResolvedValue([
|
||||
{ id: "cat", name: "Cat", translations: [{ name: "Katze" }], breeds: [{ id: "bengal", name: "Bengal", translations: [] }] },
|
||||
{ id: "dog", name: "Dog", translations: [{ name: "Hund" }], breeds: [] },
|
||||
{ id: "spider", name: "Spider", translations: [{ name: "Spinne" }], breeds: [] },
|
||||
]);
|
||||
prisma.pet.groupBy
|
||||
.mockResolvedValueOnce([
|
||||
{ speciesId: "cat", _count: { _all: 3 } },
|
||||
{ speciesId: "dog", _count: { _all: 1 } },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ speciesId: "cat", breedId: "bengal", _count: { _all: 2 } },
|
||||
{ speciesId: "cat", breedId: null, _count: { _all: 1 } },
|
||||
{ speciesId: "dog", breedId: null, _count: { _all: 1 } },
|
||||
]);
|
||||
const { c } = await createCaller(prisma);
|
||||
|
||||
const result = await c.petAdmin.speciesDistribution();
|
||||
|
||||
expect(result.total).toBe(4);
|
||||
expect(result.species.map((s) => [s.name, s.count])).toEqual([["Katze", 3], ["Hund", 1], ["Spinne", 0]]);
|
||||
expect(result.species[0].breeds).toEqual([{ name: "Bengal", count: 2 }, { name: "Ohne Rassenangabe", count: 1 }]);
|
||||
expect(prisma.pet.groupBy.mock.calls[0][0].where).toEqual({ id: { not: "pet-team" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("petAdmin.clearContent", () => {
|
||||
it("clears the chosen fields, removes the avatar file and logs it", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.pet.findUnique.mockResolvedValue({ avatarKey: "avatars/x.png" });
|
||||
prisma.pet.update.mockResolvedValue({});
|
||||
prisma.moderationLog.create.mockResolvedValue({});
|
||||
mockAssertAdmin.mockResolvedValue("MODERATOR");
|
||||
const { c } = await createCaller(prisma);
|
||||
|
||||
await c.petAdmin.clearContent({ petId: "pet-1", fields: ["avatar", "bio"], reason: "anstößig", notifyOwner: false });
|
||||
|
||||
expect(prisma.pet.update).toHaveBeenCalledWith({ where: { id: "pet-1" }, data: { avatarKey: null, bio: null } });
|
||||
expect(mockDeleteFiles).toHaveBeenCalledWith(["avatars/x.png"]);
|
||||
expect(mockSendTeamMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("referral ranks: revoke and grant", () => {
|
||||
function txPrisma() {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => fn(prisma));
|
||||
prisma.owner.update.mockResolvedValue({});
|
||||
prisma.moderationLog.create.mockResolvedValue({});
|
||||
return prisma;
|
||||
}
|
||||
|
||||
it("revoking keeps the row with revokedAt instead of deleting it", async () => {
|
||||
const prisma = txPrisma();
|
||||
const { c } = await createCaller(prisma);
|
||||
await c.referralAdmin.revokeRank({ ownerId: "owner-1", rankId: "r3", reason: "Fake-Accounts" });
|
||||
expect(prisma.referralRankAchievement.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ update: expect.objectContaining({ revokedAt: expect.any(Date), revokeReason: "Fake-Accounts" }) })
|
||||
);
|
||||
expect(prisma.referralRankAchievement.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("granting restores a revoked rank, or awards one manually", async () => {
|
||||
const restored = txPrisma();
|
||||
restored.referralRankAchievement.findUnique.mockResolvedValue({ revokedAt: new Date() });
|
||||
expect(await (await createCaller(restored)).c.referralAdmin.grantRank({ ownerId: "owner-1", rankId: "r3" })).toEqual({ success: true, restored: true });
|
||||
|
||||
const manual = txPrisma();
|
||||
manual.pet.findFirst.mockResolvedValue({ id: "pet-1" });
|
||||
await (await createCaller(manual)).c.referralAdmin.grantRank({ ownerId: "owner-1", rankId: "r10", note: "Tierheim-Aktion" });
|
||||
expect(manual.referralRankAchievement.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create: expect.objectContaining({ grantedManually: true, grantedBy: "admin-1" }) })
|
||||
);
|
||||
expect(manual.moderationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "GRANT_REFERRAL_RANK" }) });
|
||||
});
|
||||
|
||||
it("awardRanks never re-awards a revoked rank and leaves it out of the badge cache", async () => {
|
||||
const prisma = createMockPrisma();
|
||||
prisma.referralRank.findMany.mockResolvedValue([
|
||||
{ id: "r1", threshold: 1, isActive: true, perks: ["PROFILE_RING"] },
|
||||
{ id: "r3", threshold: 3, isActive: true, perks: ["NAME_BADGE"] },
|
||||
]);
|
||||
prisma.referralRankAchievement.findMany.mockResolvedValue([
|
||||
{ rankId: "r1", revokedAt: null },
|
||||
{ rankId: "r3", revokedAt: new Date() },
|
||||
]);
|
||||
prisma.owner.update.mockResolvedValue({});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const newRanks = await awardRanks(prisma as any, "owner-1", 5);
|
||||
|
||||
expect(newRanks).toEqual([]);
|
||||
expect(prisma.owner.update).toHaveBeenCalledWith({
|
||||
where: { id: "owner-1" },
|
||||
data: { referralRankId: "r1", referralPerks: ["PROFILE_RING"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import { petsRouter } from "@/trpc/routers/pets";
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
const mockDeletePetCompletely = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/delete-pet", () => ({ deletePetCompletely: (...args: unknown[]) => mockDeletePetCompletely(...args) }));
|
||||
|
||||
// Mock Clerk server auth — pets router imports from @clerk/nextjs/server via trpc/init
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
@@ -188,20 +190,18 @@ describe("PET-03: Pet deletion by owner", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("pets.delete removes the pet row when called by the pet's owner", async () => {
|
||||
it("pets.delete deletes the pet completely when called by the pet's owner", async () => {
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({
|
||||
...MOCK_PET,
|
||||
ownerId: TEST_OWNER_ID,
|
||||
});
|
||||
mockPrisma.pet.delete.mockResolvedValue(MOCK_PET);
|
||||
|
||||
const caller = createTestCaller(TEST_OWNER_ID, mockPrisma);
|
||||
const result = await caller.delete({ petId: PET_ID });
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockPrisma.pet.delete).toHaveBeenCalledWith({
|
||||
where: { id: PET_ID },
|
||||
});
|
||||
// Vollständiges Löschen (Story-Aufrufe, Paw-Backs, Zähler, Dateien) — siehe delete-pet.test.ts
|
||||
expect(mockDeletePetCompletely).toHaveBeenCalledWith(mockPrisma, PET_ID);
|
||||
});
|
||||
|
||||
it("pets.delete throws FORBIDDEN when called by a non-owner userId", async () => {
|
||||
@@ -218,7 +218,7 @@ describe("PET-03: Pet deletion by owner", () => {
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
expect(mockPrisma.pet.delete).not.toHaveBeenCalled();
|
||||
expect(mockDeletePetCompletely).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* found.md: Profil-Ring (Vorteil PROFILE_RING) überall am Tierbild und
|
||||
* Story-Pfote in der Rangfarbe.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const GOLD = "#c7951f";
|
||||
const { storyState } = vi.hoisted(() => ({ storyState: { hasStory: false, allSeen: false } }));
|
||||
|
||||
vi.mock("@/hooks/useReferralRanks", () => ({
|
||||
useReferralRanks: () => ({
|
||||
ranks: [],
|
||||
byId: new Map([["gold", { id: "gold", name: "Goldene Pfote", description: "", icon: "paw-print", color: GOLD }]]),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/trpc/client", () => ({
|
||||
useTRPC: () => ({ stories: { hasActiveStory: { queryOptions: () => ({ queryKey: ["x"] }) } } }),
|
||||
}));
|
||||
vi.mock("@/context/ActivePetContext", () => ({ useActivePet: () => ({ activePetId: "me" }) }));
|
||||
vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: storyState }) }));
|
||||
|
||||
import { RankRing } from "@/components/referrals/RankRing";
|
||||
import { PawRing } from "@/components/stories/PawRing";
|
||||
|
||||
const ranked = { referralRankId: "gold", referralPerks: ["PROFILE_RING", "NAME_BADGE"] };
|
||||
const badgeOnly = { referralRankId: "gold", referralPerks: ["NAME_BADGE"] };
|
||||
|
||||
beforeEach(() => {
|
||||
storyState.hasStory = false;
|
||||
storyState.allSeen = false;
|
||||
});
|
||||
|
||||
describe("RankRing", () => {
|
||||
it("draws a ring in the rank colour with the PROFILE_RING perk", () => {
|
||||
const html = renderToStaticMarkup(<RankRing owner={ranked}><b /></RankRing>);
|
||||
expect(html).toContain(`0 0 0 4px ${GOLD}`);
|
||||
});
|
||||
|
||||
it("renders the avatar unchanged without the perk or without an owner", () => {
|
||||
expect(renderToStaticMarkup(<RankRing owner={badgeOnly}><b /></RankRing>)).toBe("<b></b>");
|
||||
expect(renderToStaticMarkup(<RankRing owner={null}><b /></RankRing>)).toBe("<b></b>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PawRing with rank", () => {
|
||||
it("colours an unseen story paw in the rank colour", () => {
|
||||
storyState.hasStory = true;
|
||||
const html = renderToStaticMarkup(<PawRing petId="p1" owner={ranked}><b /></PawRing>);
|
||||
expect(html).toContain(`fill="${GOLD}"`);
|
||||
expect(html).not.toContain("#f97316");
|
||||
});
|
||||
|
||||
it("keeps a seen story gray", () => {
|
||||
storyState.hasStory = true;
|
||||
storyState.allSeen = true;
|
||||
const html = renderToStaticMarkup(<PawRing petId="p1" owner={ranked}><b /></PawRing>);
|
||||
expect(html).toContain("#9ca3af");
|
||||
expect(html).not.toContain(GOLD);
|
||||
});
|
||||
|
||||
it("draws a plain rank ring without a story", () => {
|
||||
const html = renderToStaticMarkup(<PawRing petId="p1" owner={ranked}><b /></PawRing>);
|
||||
expect(html).toContain(`stroke="${GOLD}"`);
|
||||
});
|
||||
|
||||
it("stays orange / ringless for owners without the perk", () => {
|
||||
expect(renderToStaticMarkup(<PawRing petId="p1" owner={badgeOnly}><b /></PawRing>)).not.toContain("<svg");
|
||||
storyState.hasStory = true;
|
||||
expect(renderToStaticMarkup(<PawRing petId="p1" owner={badgeOnly}><b /></PawRing>)).toContain("#f97316");
|
||||
});
|
||||
});
|
||||
@@ -77,17 +77,17 @@ describe("referrals.setAcquisitionSource", () => {
|
||||
describe("referrals.onboardingState", () => {
|
||||
it("shows the step once, inside the redeem window", async () => {
|
||||
const { caller, prisma } = await createCaller();
|
||||
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedById: null, acquisitionAnsweredAt: null });
|
||||
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedAt: null, acquisitionAnsweredAt: null });
|
||||
expect(await caller.referrals.onboardingState()).toEqual({ showStep: true, canRedeem: true });
|
||||
|
||||
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedById: null, acquisitionAnsweredAt: new Date() });
|
||||
prisma.owner.findUnique.mockResolvedValue({ createdAt: new Date(), invitedAt: null, acquisitionAnsweredAt: new Date() });
|
||||
expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: true });
|
||||
});
|
||||
|
||||
it("hides everything for owners older than the window", async () => {
|
||||
const { caller, prisma } = await createCaller();
|
||||
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
prisma.owner.findUnique.mockResolvedValue({ createdAt: old, invitedById: null, acquisitionAnsweredAt: null });
|
||||
prisma.owner.findUnique.mockResolvedValue({ createdAt: old, invitedAt: null, acquisitionAnsweredAt: null });
|
||||
expect(await caller.referrals.onboardingState()).toEqual({ showStep: false, canRedeem: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("redeemReferralCode", () => {
|
||||
|
||||
expect(result).toEqual({ inviterPetName: "Bubi" });
|
||||
expect(mockPrisma.owner.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "owner-invitee", invitedById: null },
|
||||
where: { id: "owner-invitee", invitedById: null, invitedAt: null },
|
||||
data: { invitedById: "owner-inviter", invitedAt: NOW },
|
||||
});
|
||||
expect(mockPrisma.follow.upsert).toHaveBeenCalledTimes(2);
|
||||
@@ -196,6 +196,14 @@ describe("redeemReferralCode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a second redemption after the inviter's account was deleted", async () => {
|
||||
// delete-account.ts leert invitedById, invitedAt bleibt als Marker stehen.
|
||||
const mockPrisma = setupRedeem({ invitee: { createdAt: NOW, invitedById: null, invitedAt: new Date(NOW.getTime() - DAY) } });
|
||||
await expect(redeemReferralCode(asDb(mockPrisma), "owner-invitee", "BUBI-7K3M", NOW)).rejects.toThrow(
|
||||
REFERRAL_ERRORS.ALREADY_REDEEMED
|
||||
);
|
||||
});
|
||||
|
||||
it("lets only one of two concurrent redemptions win", async () => {
|
||||
const mockPrisma = setupRedeem();
|
||||
mockPrisma.owner.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
@@ -16,8 +16,16 @@ vi.mock("@/lib/supabase-storage", () => ({
|
||||
}),
|
||||
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024,
|
||||
deleteFiles: (...args: unknown[]) => mockDeleteFiles(...args),
|
||||
}));
|
||||
|
||||
const mockDeleteFiles = vi.fn().mockResolvedValue(undefined);
|
||||
const mockMuxDelete = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("@/lib/mux", () => ({
|
||||
mux: { video: { assets: { delete: (...args: unknown[]) => mockMuxDelete(...args), retrieve: vi.fn() } } },
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
|
||||
|
||||
vi.mock("@clerk/nextjs/server", () => ({
|
||||
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
|
||||
}));
|
||||
@@ -141,6 +149,153 @@ describe("stories.create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.delete", () => {
|
||||
beforeEach(() => {
|
||||
mockDeleteFiles.mockClear();
|
||||
mockMuxDelete.mockClear();
|
||||
});
|
||||
|
||||
it("deletes the owner's photo story with its views and storage file", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s1", petId: "pet-1", storageKey: "stories/a.jpg", muxAssetId: null });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await expect(caller.delete({ storyId: "s1" })).resolves.toEqual({ deleted: true });
|
||||
|
||||
expect(mockPrisma.storyView.deleteMany).toHaveBeenCalledWith({ where: { storyId: "s1" } });
|
||||
expect(mockPrisma.story.delete).toHaveBeenCalledWith({ where: { id: "s1" } });
|
||||
expect(mockDeleteFiles).toHaveBeenCalledWith(["stories/a.jpg"]);
|
||||
expect(mockMuxDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also removes the Mux asset of a video story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s2", petId: "pet-1", storageKey: null, muxAssetId: "asset-9" });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await caller.delete({ storyId: "s2" });
|
||||
|
||||
expect(mockMuxDelete).toHaveBeenCalledWith("asset-9");
|
||||
expect(mockDeleteFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to delete another owner's story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s3", petId: "pet-2", storageKey: "stories/b.jpg", muxAssetId: null });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-2", ownerId: "owner-2" });
|
||||
|
||||
await expect(caller.delete({ storyId: "s3" })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(mockDeleteFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still succeeds when the storage cleanup fails", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ id: "s4", petId: "pet-1", storageKey: "stories/c.jpg", muxAssetId: null });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
mockDeleteFiles.mockRejectedValueOnce(new Error("storage down"));
|
||||
|
||||
await expect(caller.delete({ storyId: "s4" })).resolves.toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for unknown stories", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findUnique.mockResolvedValue(null);
|
||||
await expect(caller.delete({ storyId: "nope" })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.listActive", () => {
|
||||
it("marks stories the viewer already pawed and strips the raw reactions", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{ id: "s1", petId: "pet-a", reactionCount: 2, reactions: [{ petId: "pet-me" }] },
|
||||
{ id: "s2", petId: "pet-a", reactionCount: 0, reactions: [] },
|
||||
]);
|
||||
|
||||
const result = await caller.listActive({ petId: "pet-a", viewerPetId: "pet-me" });
|
||||
|
||||
expect(result.map((s) => s.reactedByViewer)).toEqual([true, false]);
|
||||
expect(result[0]).not.toHaveProperty("reactions");
|
||||
expect(mockPrisma.story.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: expect.objectContaining({ reactions: { where: { petId: "pet-me" }, select: { petId: true }, take: 1 } }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("stays compatible with older clients that send only petId", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ id: "s1", petId: "pet-a", reactionCount: 1 }]);
|
||||
|
||||
const result = await caller.listActive({ petId: "pet-a" });
|
||||
|
||||
expect(result[0].reactedByViewer).toBe(false);
|
||||
const include = mockPrisma.story.findMany.mock.calls[0][0].include;
|
||||
expect(include.musicTrack).toBe(true);
|
||||
// Ohne viewerPetId keine Reaktions-Abfrage (ältere Clients)
|
||||
expect(include.reactions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories — 7-Tage-Ansicht für den Ersteller", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("listActive shows the owner their stories of the last 7 days, marked as expired", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{ id: "old", expiresAt: new Date(Date.now() - 60_000), reactionCount: 1 },
|
||||
{ id: "new", expiresAt: new Date(Date.now() + 60_000), reactionCount: 0 },
|
||||
]);
|
||||
|
||||
const result = await caller.listActive({ petId: "pet-1" });
|
||||
|
||||
expect(result.map((s) => s.isExpired)).toEqual([true, false]);
|
||||
const where = mockPrisma.story.findMany.mock.calls[0][0].where;
|
||||
expect(where.createdAt.gte).toBeInstanceOf(Date);
|
||||
expect(where.expiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listActive still shows other viewers only the active 24 hours", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-2" });
|
||||
mockPrisma.story.findMany.mockResolvedValue([]);
|
||||
|
||||
await caller.listActive({ petId: "pet-2" });
|
||||
|
||||
const where = mockPrisma.story.findMany.mock.calls[0][0].where;
|
||||
expect(where.expiresAt.gt).toBeInstanceOf(Date);
|
||||
expect(where.createdAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hasActiveStory reports a gray ring for the owner's expired stories only with includeOwnRecent", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findMany.mockResolvedValue([]); // nichts aktiv
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
|
||||
mockPrisma.story.count.mockResolvedValue(2);
|
||||
|
||||
expect(await caller.hasActiveStory({ petId: "pet-1", includeOwnRecent: true })).toEqual({
|
||||
hasStory: true,
|
||||
allSeen: true,
|
||||
expiredOnly: true,
|
||||
});
|
||||
expect(await caller.hasActiveStory({ petId: "pet-1" })).toEqual({ hasStory: false, allSeen: false, expiredOnly: false });
|
||||
});
|
||||
|
||||
it("hasActiveStory never shows someone else's expired stories", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.story.findMany.mockResolvedValue([]);
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-2" });
|
||||
mockPrisma.story.count.mockResolvedValue(3);
|
||||
|
||||
expect(await caller.hasActiveStory({ petId: "pet-2", includeOwnRecent: true })).toMatchObject({ hasStory: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.hasActiveStory", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Story-Editor (feature/story-editor): Overlays, Markierungen, Text-Storys,
|
||||
* Story-Benachrichtigungen, GIF-Suche, Notschalter.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/supabase-storage", () => ({
|
||||
createSignedUploadUrl: vi.fn(),
|
||||
ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"],
|
||||
MAX_FILE_SIZE: 10 * 1024 * 1024,
|
||||
deleteFiles: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/mux", () => ({
|
||||
mux: { video: { uploads: { create: vi.fn().mockResolvedValue({ id: "up-1", url: "https://mux/up" }) }, assets: { delete: vi.fn(), retrieve: vi.fn() } } },
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ success: true }) }));
|
||||
vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }) }));
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
const { mockNotify, redisStore } = vi.hoisted(() => ({ mockNotify: vi.fn(), redisStore: new Map<string, string>() }));
|
||||
vi.mock("@/lib/notify", () => ({ notify: (...a: unknown[]) => mockNotify(...a), sendPushForNotification: vi.fn() }));
|
||||
vi.mock("@/lib/redis", () => ({
|
||||
redis: {
|
||||
get: vi.fn(async (k: string) => redisStore.get(k) ?? null),
|
||||
set: vi.fn(async (k: string, v: string) => {
|
||||
redisStore.set(k, v);
|
||||
return "OK";
|
||||
}),
|
||||
del: vi.fn(async (k: string) => {
|
||||
redisStore.delete(k);
|
||||
return 1;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/spider-exclusion", () => ({ getSpiderExclusionId: vi.fn().mockResolvedValue(null) }));
|
||||
|
||||
import { storiesRouter } from "@/trpc/routers/stories";
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
import {
|
||||
containRect,
|
||||
contrastTextColor,
|
||||
mentionedPetIds,
|
||||
parseStoredOverlays,
|
||||
storyMediaSize,
|
||||
storyOverlaysSchema,
|
||||
} from "@/lib/story-overlays";
|
||||
import { filterMentionablePetIds } from "@/lib/story-mentions";
|
||||
import { isStoryEditorEnabled, setStoryEditorEnabled } from "@/lib/feature-flags";
|
||||
|
||||
function createCaller() {
|
||||
const mockPrisma = createMockPrisma();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { caller: storiesRouter.createCaller({ userId: "owner-1", prisma: mockPrisma as any } as any), mockPrisma };
|
||||
}
|
||||
|
||||
const text = (extra: Record<string, unknown> = {}) => ({ kind: "text", x: 0.5, y: 0.5, scale: 1, rotation: 0, text: "Hallo", color: "#ffffff", style: "plain", font: "bold", ...extra });
|
||||
const mention = (petId: string) => ({ kind: "mention", x: 0.5, y: 0.3, scale: 1, rotation: 0, petId });
|
||||
const gif = (extra: Record<string, unknown> = {}) => ({ kind: "gif", x: 0.5, y: 0.7, scale: 1, rotation: 10, gifId: "abc", url: "https://media2.giphy.com/media/abc/200w.webp", width: 200, height: 150, ...extra });
|
||||
const sticker = (stickerId = "paw") => ({ kind: "sticker", x: 0.2, y: 0.2, scale: 1.5, rotation: -20, stickerId });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
redisStore.clear();
|
||||
});
|
||||
|
||||
describe("storyOverlaysSchema", () => {
|
||||
it("accepts all four overlay kinds", () => {
|
||||
expect(storyOverlaysSchema.safeParse([text(), mention("p2"), gif(), sticker()]).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects GIF URLs outside GIPHY, unknown stickers, empty text and colors off the palette", () => {
|
||||
expect(storyOverlaysSchema.safeParse([gif({ url: "https://evil.example/x.gif" })]).success).toBe(false);
|
||||
expect(storyOverlaysSchema.safeParse([sticker("skull")]).success).toBe(false);
|
||||
expect(storyOverlaysSchema.safeParse([text({ text: " " })]).success).toBe(false);
|
||||
expect(storyOverlaysSchema.safeParse([text({ color: "#123456" })]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects positions outside the media area and absurd scales", () => {
|
||||
expect(storyOverlaysSchema.safeParse([text({ x: 1.2 })]).success).toBe(false);
|
||||
expect(storyOverlaysSchema.safeParse([text({ scale: 50 })]).success).toBe(false);
|
||||
});
|
||||
|
||||
it("limits overlays, GIFs and distinct mentions", () => {
|
||||
expect(storyOverlaysSchema.safeParse(Array.from({ length: 16 }, () => sticker())).success).toBe(false);
|
||||
expect(storyOverlaysSchema.safeParse([gif(), gif(), gif(), gif()]).success).toBe(false);
|
||||
expect(storyOverlaysSchema.safeParse(Array.from({ length: 11 }, (_, i) => mention(`p${i}`))).success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses stored JSON defensively", () => {
|
||||
expect(parseStoredOverlays(null)).toEqual([]);
|
||||
expect(parseStoredOverlays("kaputt")).toEqual([]);
|
||||
expect(parseStoredOverlays([text()])).toHaveLength(1);
|
||||
expect(mentionedPetIds([mention("a"), mention("a"), mention("b")] as never)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlay geometry", () => {
|
||||
it("fits portrait media into a taller screen with letterboxing", () => {
|
||||
expect(containRect(400, 800, 1080, 1350)).toEqual({ x: 0, y: 150, width: 400, height: 500 });
|
||||
});
|
||||
|
||||
it("derives the media size per story type", () => {
|
||||
expect(storyMediaSize({ mediaType: "TEXT" })).toEqual({ width: 9, height: 16 });
|
||||
expect(storyMediaSize({ mediaType: "IMAGE", mediaWidth: 1080, mediaHeight: 1920 })).toEqual({ width: 1080, height: 1920 });
|
||||
expect(storyMediaSize({ mediaType: "VIDEO", aspectRatio: "9:16" })).toEqual({ width: 9, height: 16 });
|
||||
expect(storyMediaSize({ mediaType: "IMAGE" })).toBeNull();
|
||||
});
|
||||
|
||||
it("picks a readable text color on a colored box", () => {
|
||||
expect(contrastTextColor("#facc15")).toBe("#171717");
|
||||
expect(contrastTextColor("#3b82f6")).toBe("#ffffff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterMentionablePetIds — „Wer darf die Follower sehen?“ regelt das Markieren", () => {
|
||||
function prismaWith(targets: { id: string; ownerId: string; followerVisibility: string }[], opts: { blocks?: string[][]; follows?: string[] } = {}) {
|
||||
const p = createMockPrisma();
|
||||
p.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
|
||||
p.pet.findMany.mockResolvedValue(targets);
|
||||
p.block.findMany.mockResolvedValue((opts.blocks ?? []).map(([blockerPetId, blockedPetId]) => ({ blockerPetId, blockedPetId })));
|
||||
p.follow.findMany.mockResolvedValue((opts.follows ?? []).map((followeePetId) => ({ followeePetId })));
|
||||
return p;
|
||||
}
|
||||
|
||||
it("applies EVERYONE / FOLLOWERS_ONLY / NOBODY", async () => {
|
||||
const p = prismaWith(
|
||||
[
|
||||
{ id: "everyone", ownerId: "o2", followerVisibility: "EVERYONE" },
|
||||
{ id: "fans-followed", ownerId: "o3", followerVisibility: "FOLLOWERS_ONLY" },
|
||||
{ id: "fans-not-followed", ownerId: "o4", followerVisibility: "FOLLOWERS_ONLY" },
|
||||
{ id: "nobody", ownerId: "o5", followerVisibility: "NOBODY" },
|
||||
],
|
||||
{ follows: ["fans-followed"] }
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allowed = await filterMentionablePetIds(p as any, "me", ["everyone", "fans-followed", "fans-not-followed", "nobody"]);
|
||||
expect(allowed.sort()).toEqual(["everyone", "fans-followed"]);
|
||||
});
|
||||
|
||||
it("always allows the owner's own pets, never self, never blocked pets", async () => {
|
||||
const p = prismaWith(
|
||||
[
|
||||
{ id: "own-pet", ownerId: "owner-1", followerVisibility: "NOBODY" },
|
||||
{ id: "blocked", ownerId: "o2", followerVisibility: "EVERYONE" },
|
||||
],
|
||||
{ blocks: [["blocked", "me"]] }
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allowed = await filterMentionablePetIds(p as any, "me", ["me", "own-pet", "blocked"]);
|
||||
expect(allowed).toEqual(["own-pet"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.create with overlays", () => {
|
||||
it("stores overlays + media size, drops disallowed mentions and notifies only allowed pets", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
mockPrisma.pet.findMany.mockResolvedValue([
|
||||
{ id: "ok", ownerId: "o2", followerVisibility: "EVERYONE" },
|
||||
{ id: "private", ownerId: "o3", followerVisibility: "NOBODY" },
|
||||
]);
|
||||
mockPrisma.block.findMany.mockResolvedValue([]);
|
||||
mockPrisma.follow.findMany.mockResolvedValue([]);
|
||||
mockPrisma.story.create.mockResolvedValue({ id: "story-1" });
|
||||
|
||||
await caller.create({
|
||||
petId: "me",
|
||||
storageKey: "stories/x.jpg",
|
||||
overlays: [text(), mention("ok"), mention("private")] as never,
|
||||
mediaWidth: 1080,
|
||||
mediaHeight: 1350,
|
||||
});
|
||||
|
||||
const data = mockPrisma.story.create.mock.calls[0][0].data;
|
||||
expect(data.mediaWidth).toBe(1080);
|
||||
expect(data.overlays.map((o: { kind: string; petId?: string }) => o.petId ?? o.kind)).toEqual(["text", "ok"]);
|
||||
expect(mockPrisma.storyMention.createMany).toHaveBeenCalledWith({ data: [{ storyId: "story-1", petId: "ok" }], skipDuplicates: true });
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotify.mock.calls[0][1]).toMatchObject({ recipientPetId: "ok", type: "STORY_MENTION", actorPetId: "me", storyId: "story-1" });
|
||||
});
|
||||
|
||||
it("video stories save mentions but notify only once the video is ready", async () => {
|
||||
process.env.MUX_TOKEN_ID = "id";
|
||||
process.env.MUX_TOKEN_SECRET = "secret";
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
mockPrisma.pet.findMany.mockResolvedValue([{ id: "ok", ownerId: "o2", followerVisibility: "EVERYONE" }]);
|
||||
mockPrisma.block.findMany.mockResolvedValue([]);
|
||||
mockPrisma.follow.findMany.mockResolvedValue([]);
|
||||
mockPrisma.story.create.mockResolvedValue({ id: "story-v" });
|
||||
|
||||
await caller.createVideoUpload({ petId: "me", overlays: [mention("ok")] as never, mediaWidth: 720, mediaHeight: 1280 });
|
||||
|
||||
expect(mockPrisma.storyMention.createMany).toHaveBeenCalledOnce();
|
||||
expect(mockNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.createText", () => {
|
||||
it("creates a TEXT story with background", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
mockPrisma.story.create.mockResolvedValue({ id: "story-t" });
|
||||
|
||||
await caller.createText({ petId: "me", background: "sunset", overlays: [text()] as never });
|
||||
|
||||
const data = mockPrisma.story.create.mock.calls[0][0].data;
|
||||
expect(data).toMatchObject({ mediaType: "TEXT", background: "sunset" });
|
||||
expect(data.storageKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires text and a known background", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
await expect(caller.createText({ petId: "me", background: "sunset", overlays: [sticker()] as never })).rejects.toThrow();
|
||||
await expect(caller.createText({ petId: "me", background: "rainbow", overlays: [text()] as never })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("story notifications for the owner", () => {
|
||||
it("a paw on someone else's story notifies its owner, not on the own story", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
mockPrisma.storyReaction.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ petId: "other", expiresAt: new Date(Date.now() + 60_000) });
|
||||
mockPrisma.$transaction.mockResolvedValue([]);
|
||||
|
||||
await caller.toggleReaction({ storyId: "s1", petId: "me" });
|
||||
expect(mockNotify.mock.calls[0][1]).toMatchObject({ recipientPetId: "other", type: "STORY_REACTION", storyId: "s1" });
|
||||
|
||||
mockNotify.mockClear();
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ petId: "me", expiresAt: new Date(Date.now() + 60_000) });
|
||||
await caller.toggleReaction({ storyId: "s2", petId: "me" });
|
||||
expect(mockNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a comment notifies the story owner", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
mockPrisma.story.findUnique.mockResolvedValue({ petId: "other", expiresAt: new Date(Date.now() + 60_000) });
|
||||
mockPrisma.$transaction.mockResolvedValue([{ id: "c1" }]);
|
||||
|
||||
await caller.addComment({ storyId: "s1", petId: "me", body: "Süß!" });
|
||||
expect(mockNotify.mock.calls[0][1]).toMatchObject({ recipientPetId: "other", type: "STORY_COMMENT", actorPetId: "me" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.listActive with overlays", () => {
|
||||
it("returns parsed overlays and hides mentions of pets that no longer exist", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "someone-else" });
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "s1",
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
overlays: [text(), mention("alive"), mention("deleted")],
|
||||
mentions: [{ pet: { id: "alive", name: "Bubi" } }],
|
||||
},
|
||||
]);
|
||||
|
||||
const [story] = await caller.listActive({ petId: "p" });
|
||||
expect(story.overlays.map((o: { kind: string; petId?: string }) => o.petId ?? o.kind)).toEqual(["text", "alive"]);
|
||||
expect(story.mentionedPets).toEqual([{ id: "alive", name: "Bubi" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.searchMentionable", () => {
|
||||
it("returns only pets the actor may mention", async () => {
|
||||
const { caller, mockPrisma } = createCaller();
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ id: "me", ownerId: "owner-1" });
|
||||
mockPrisma.pet.findMany
|
||||
.mockResolvedValueOnce([{ id: "a", name: "Anton" }, { id: "b", name: "Bella" }]) // Kandidaten
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "a", ownerId: "o2", followerVisibility: "EVERYONE" },
|
||||
{ id: "b", ownerId: "o3", followerVisibility: "NOBODY" },
|
||||
]); // Regeln
|
||||
mockPrisma.block.findMany.mockResolvedValue([]);
|
||||
mockPrisma.follow.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await caller.searchMentionable({ petId: "me", query: "a" });
|
||||
expect(result.map((p: { id: string }) => p.id)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stories.searchGifs", () => {
|
||||
it("maps GIPHY results, filters rating g and caches them", async () => {
|
||||
process.env.GIPHY_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "g1", title: "Hund", images: { fixed_width: { webp: "https://media1.giphy.com/g1.webp", width: "200", height: "112" }, fixed_width_small: { webp: "https://media1.giphy.com/g1s.webp" } } },
|
||||
{ id: "broken", images: {} },
|
||||
],
|
||||
pagination: { total_count: 50, count: 2, offset: 0 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { caller } = createCaller();
|
||||
|
||||
const first = await caller.searchGifs({ query: "Hund" });
|
||||
expect(first.gifs).toEqual([{ id: "g1", title: "Hund", previewUrl: "https://media1.giphy.com/g1s.webp", url: "https://media1.giphy.com/g1.webp", width: 200, height: 112 }]);
|
||||
expect(first.nextOffset).toBe(2);
|
||||
expect(String(fetchMock.mock.calls[0][0])).toContain("rating=g");
|
||||
|
||||
await caller.searchGifs({ query: "hund" }); // gleiche Suche, anders geschrieben → Cache
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Notschalter", () => {
|
||||
it("is on by default and can be switched off and on", async () => {
|
||||
expect(await isStoryEditorEnabled()).toBe(true);
|
||||
await setStoryEditorEnabled(false);
|
||||
expect(await isStoryEditorEnabled()).toBe(false);
|
||||
const { caller } = createCaller();
|
||||
expect(await caller.featureFlags()).toEqual({ storyEditor: false });
|
||||
await setStoryEditorEnabled(true);
|
||||
expect(await isStoryEditorEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* /api/cron/vaccine-reminders — erinnert 30 Tage vor dem Impf-Folgetermin.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
const { sendExpoPushNotification } = vi.hoisted(() => ({
|
||||
sendExpoPushNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/push", () => ({ sendExpoPushNotification }));
|
||||
|
||||
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
|
||||
|
||||
const mockPrisma = createMockPrisma();
|
||||
// Getter: vi.mock wird nach oben gehoben, mockPrisma existiert erst beim Zugriff.
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
get prisma() {
|
||||
return mockPrisma;
|
||||
},
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/cron/vaccine-reminders/route";
|
||||
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
|
||||
|
||||
const request = (auth?: string) =>
|
||||
new Request("https://pawfeed.org/api/cron/vaccine-reminders", { headers: auth ? { authorization: auth } : {} });
|
||||
|
||||
const dueVaccine = {
|
||||
id: "vac-1",
|
||||
petId: "pet-1",
|
||||
name: "Tollwut",
|
||||
nextDueDate: new Date("2026-11-01T00:00:00Z"),
|
||||
pet: { name: "Bubi" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.CRON_SECRET = "test-secret";
|
||||
mockPrisma.notification.create.mockResolvedValue({});
|
||||
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrisma.pet.findUnique.mockResolvedValue({ ownerId: "owner-1" });
|
||||
mockPrisma.owner.findUnique.mockResolvedValue({ expoPushToken: "ExponentPushToken[abc]" });
|
||||
});
|
||||
|
||||
describe("GET /api/cron/vaccine-reminders", () => {
|
||||
it("rejects requests without the cron secret", async () => {
|
||||
expect((await GET(request())).status).toBe(401);
|
||||
expect((await GET(request("Bearer wrong"))).status).toBe(401);
|
||||
expect(mockPrisma.vaccine.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the run summary", async () => {
|
||||
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
|
||||
const res = await GET(request("Bearer test-secret"));
|
||||
expect(await res.json()).toEqual({ ok: true, sent: 1, failed: 0, more: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendVaccineReminders", () => {
|
||||
const now = new Date("2026-10-04T08:00:00Z");
|
||||
|
||||
it("only looks at unreminded vaccines due between today and 30 days from now", async () => {
|
||||
mockPrisma.vaccine.findMany.mockResolvedValue([]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await sendVaccineReminders(mockPrisma as any, now);
|
||||
|
||||
const where = mockPrisma.vaccine.findMany.mock.calls[0][0].where;
|
||||
expect(where.reminderSentAt).toBeNull();
|
||||
expect(where.nextDueDate.gte.toISOString()).toBe("2026-10-04T00:00:00.000Z");
|
||||
expect(where.nextDueDate.lte.toISOString()).toBe("2026-11-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("claims the vaccine, creates a VACCINE_DUE notification and pushes the details", async () => {
|
||||
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await sendVaccineReminders(mockPrisma as any, now);
|
||||
|
||||
expect(result).toEqual({ sent: 1, failed: 0, more: false });
|
||||
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "vac-1", reminderSentAt: null },
|
||||
data: { reminderSentAt: now },
|
||||
});
|
||||
expect(mockPrisma.notification.create).toHaveBeenCalledWith({
|
||||
data: { recipientPetId: "pet-1", type: "VACCINE_DUE", vaccineId: "vac-1" },
|
||||
});
|
||||
expect(sendExpoPushNotification).toHaveBeenCalledWith(
|
||||
"ExponentPushToken[abc]",
|
||||
expect.objectContaining({
|
||||
title: "Impfung bald fällig",
|
||||
body: expect.stringContaining("„Tollwut“ für Bubi ist am 01.11.2026 fällig"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a vaccine another run already claimed", async () => {
|
||||
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
|
||||
mockPrisma.vaccine.updateMany.mockResolvedValue({ count: 0 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await sendVaccineReminders(mockPrisma as any, now);
|
||||
|
||||
expect(result.sent).toBe(0);
|
||||
expect(mockPrisma.notification.create).not.toHaveBeenCalled();
|
||||
expect(sendExpoPushNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still counts the reminder when the push fails", async () => {
|
||||
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine]);
|
||||
sendExpoPushNotification.mockRejectedValueOnce(new Error("expo down"));
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await sendVaccineReminders(mockPrisma as any, now);
|
||||
|
||||
expect(result).toEqual({ sent: 1, failed: 0, more: false });
|
||||
});
|
||||
|
||||
it("keeps going after a failing vaccine and reports it", async () => {
|
||||
mockPrisma.vaccine.findMany.mockResolvedValue([dueVaccine, { ...dueVaccine, id: "vac-2" }]);
|
||||
mockPrisma.notification.create.mockRejectedValueOnce(new Error("db")).mockResolvedValueOnce({});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await sendVaccineReminders(mockPrisma as any, now);
|
||||
|
||||
expect(result).toEqual({ sent: 1, failed: 1, more: false });
|
||||
// Fehlgeschlagene Erinnerung wird freigegeben, damit der nächste Lauf sie nachholt.
|
||||
expect(mockPrisma.vaccine.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "vac-1" },
|
||||
data: { reminderSentAt: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { formatRelativeCompact } from "@/lib/format-relative-time";
|
||||
import { RankRing } from "@/components/referrals/RankRing";
|
||||
|
||||
export default function MessagesPage() {
|
||||
const t = useTranslations("Messages");
|
||||
@@ -63,12 +64,14 @@ export default function MessagesPage() {
|
||||
onClick={() => router.push(`/messages/${conv.id}`)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<Avatar className="w-12 h-12 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={conv.otherPet.name} />}
|
||||
<AvatarFallback className="text-sm bg-muted">
|
||||
{conv.otherPet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<RankRing owner={conv.otherPet.owner}>
|
||||
<Avatar className="w-12 h-12 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={conv.otherPet.name} />}
|
||||
<AvatarFallback className="text-sm bg-muted">
|
||||
{conv.otherPet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</RankRing>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award } from "lucide-react";
|
||||
import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2, Sparkles, Gift, Award, Syringe, PawPrint } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
@@ -13,10 +13,11 @@ import { getMediaUrl } from "@/lib/media-url";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
import { PostDetailDialog } from "@/components/feed/PostDetailDialog";
|
||||
import type { PostCardPost } from "@/components/feed/PostCard";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { formatRelativeCompact } from "@/lib/format-relative-time";
|
||||
import { RankRing } from "@/components/referrals/RankRing";
|
||||
|
||||
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP";
|
||||
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK" | "PET_SUGGESTION" | "REFERRAL_JOINED" | "REFERRAL_RANK_UP" | "VACCINE_DUE" | "STORY_MENTION" | "STORY_COMMENT" | "STORY_REACTION";
|
||||
|
||||
function notificationIcon(type: NotificationType) {
|
||||
switch (type) {
|
||||
@@ -31,6 +32,10 @@ function notificationIcon(type: NotificationType) {
|
||||
case "PET_SUGGESTION": return <Sparkles className="h-4 w-4 text-amber-500" />;
|
||||
case "REFERRAL_JOINED": return <Gift className="h-4 w-4 text-orange-500" />;
|
||||
case "REFERRAL_RANK_UP": return <Award className="h-4 w-4 text-orange-500" />;
|
||||
case "VACCINE_DUE": return <Syringe className="h-4 w-4 text-sky-500" />;
|
||||
case "STORY_MENTION": return <AtSign className="h-4 w-4 text-orange-500" />;
|
||||
case "STORY_COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
|
||||
case "STORY_REACTION": return <PawPrint className="h-4 w-4 text-orange-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +46,7 @@ export default function NotificationsPage() {
|
||||
const { activePetId } = useActivePet();
|
||||
const trt = useTranslations("RelativeTime");
|
||||
const t = useTranslations("NotificationsPage");
|
||||
const locale = useLocale();
|
||||
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -97,8 +103,14 @@ export default function NotificationsPage() {
|
||||
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
|
||||
case "REFERRAL_RANK_UP":
|
||||
return "/einladen";
|
||||
// Story-Mitteilungen → Tierprofil (dort läuft die Story über den Ring)
|
||||
case "STORY_MENTION":
|
||||
case "STORY_COMMENT":
|
||||
case "STORY_REACTION":
|
||||
return notif.story ? `/pets/${notif.story.petId}` : null;
|
||||
case "BIRTHDAY":
|
||||
case "ADOPTION_DAY":
|
||||
case "VACCINE_DUE":
|
||||
return notif.recipientPetId ? `/pets/${notif.recipientPetId}/health` : null;
|
||||
default:
|
||||
return null;
|
||||
@@ -186,16 +198,19 @@ export default function NotificationsPage() {
|
||||
|
||||
{/* Actor avatar + notification type icon */}
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="w-10 h-10">
|
||||
{actorAvatarUrl && (
|
||||
<AvatarImage src={actorAvatarUrl} alt={notif.actorPet?.name ?? ""} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{notif.type === "BIRTHDAY" ? "🎂" :
|
||||
notif.type === "ADOPTION_DAY" ? "🏠" :
|
||||
notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<RankRing owner={notif.actorPet?.owner}>
|
||||
<Avatar className="w-10 h-10">
|
||||
{actorAvatarUrl && (
|
||||
<AvatarImage src={actorAvatarUrl} alt={notif.actorPet?.name ?? ""} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{notif.type === "BIRTHDAY" ? "🎂" :
|
||||
notif.type === "ADOPTION_DAY" ? "🏠" :
|
||||
notif.type === "VACCINE_DUE" ? "💉" :
|
||||
notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</RankRing>
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-card border flex items-center justify-center">
|
||||
{notificationIcon(notif.type as NotificationType)}
|
||||
</span>
|
||||
@@ -233,6 +248,20 @@ export default function NotificationsPage() {
|
||||
{notif.type === "PET_SUGGESTION" && t("typePetSuggestion")}
|
||||
{notif.type === "REFERRAL_JOINED" && t("typeReferralJoined")}
|
||||
{notif.type === "REFERRAL_RANK_UP" && t("typeReferralRankUp")}
|
||||
{notif.type === "STORY_MENTION" && t("typeStoryMention")}
|
||||
{notif.type === "STORY_COMMENT" && t("typeStoryComment")}
|
||||
{notif.type === "STORY_REACTION" && t("typeStoryReaction")}
|
||||
{notif.type === "VACCINE_DUE" && notif.vaccine?.nextDueDate &&
|
||||
t("typeVaccineDue", {
|
||||
name: notif.vaccine.name,
|
||||
// @db.Date kommt als UTC-Mitternacht — in UTC formatieren, sonst kippt der Tag.
|
||||
date: new Date(notif.vaccine.nextDueDate).toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
|
||||
@@ -33,6 +33,7 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
const { petId } = await params;
|
||||
|
||||
const t = await getTranslations("PetProfile");
|
||||
const tFounding = await getTranslations("FoundingMember");
|
||||
const trpc = await createTRPCCaller();
|
||||
|
||||
let pet;
|
||||
@@ -77,12 +78,16 @@ export default async function PetProfilePage({ params }: PageProps) {
|
||||
{formatPetName(pet)}
|
||||
<PetIdentityBadges
|
||||
isVerified={pet.isVerified}
|
||||
isFoundingMember={pet.isFoundingMember}
|
||||
sponsor={pet.owner}
|
||||
referral={pet.owner}
|
||||
size="lg"
|
||||
verifiedLabel={t("verified")}
|
||||
/>
|
||||
</h1>
|
||||
{pet.isFoundingMember && (
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-400">{tFounding("tooltip")}</p>
|
||||
)}
|
||||
<Badge variant="secondary">{pet.species.name}</Badge>
|
||||
|
||||
{pet.invitedByPet && (
|
||||
|
||||
@@ -16,6 +16,8 @@ import { FollowButton } from "@/components/social/FollowButton";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
import { BlurImage } from "@/components/ui/blur-image";
|
||||
import { RankRing } from "@/components/referrals/RankRing";
|
||||
import type { ReferralOwnerFields } from "@/components/referrals/ReferralRankBadge";
|
||||
|
||||
export default function SearchPage() {
|
||||
const t = useTranslations("Search");
|
||||
@@ -93,6 +95,7 @@ export default function SearchPage() {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
species: { name: string };
|
||||
owner?: ReferralOwnerFields | null;
|
||||
breed: { name: string } | null;
|
||||
_count: { followers: number };
|
||||
};
|
||||
@@ -106,12 +109,14 @@ export default function SearchPage() {
|
||||
href={`/pets/${p.id}`}
|
||||
className="flex items-center gap-3 flex-1 min-w-0"
|
||||
>
|
||||
<Avatar className="w-10 h-10 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={p.name} />}
|
||||
<AvatarFallback className="text-sm bg-muted">
|
||||
{p.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<RankRing owner={p.owner}>
|
||||
<Avatar className="w-10 h-10 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={p.name} />}
|
||||
<AvatarFallback className="text-sm bg-muted">
|
||||
{p.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</RankRing>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{p.name}</p>
|
||||
<Badge variant="secondary" className="text-xs mt-0.5">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
|
||||
import { STORY_RETENTION_DAYS, deleteStoryWithMedia } from "@/lib/story-cleanup";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Pro Lauf begrenzt: run-cron.sh bricht nach 60 s ab, und jede Story kostet
|
||||
// bis zu zwei externe Löschaufrufe (Supabase, Mux). Ein Rückstand wird über
|
||||
// die folgenden Tage abgebaut.
|
||||
const BATCH_SIZE = 200;
|
||||
|
||||
function authorized(req: Request): boolean {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) return false;
|
||||
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Storys, die älter als STORY_RETENTION_DAYS sind, endgültig — vorher
|
||||
* blieben abgelaufene Storys samt Bildern und Mux-Videos für immer liegen
|
||||
* („lazy delete“ hat sie nur ausgeblendet). Ausgelöst täglich vom NAS-Crontab
|
||||
* über docker/run-cron.sh.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
if (!authorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const cutoff = new Date(Date.now() - STORY_RETENTION_DAYS * DAY_MS);
|
||||
const stories = await prisma.story.findMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
select: { id: true, storageKey: true, muxAssetId: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
for (const story of stories) {
|
||||
try {
|
||||
await deleteStoryWithMedia(prisma, story);
|
||||
deleted++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error("[cron/cleanup-stories] delete failed", story.id, err);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: failed === 0, deleted, failed, more: stories.length === BATCH_SIZE });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { timingSafeStringEqual } from "@/lib/timing-safe-compare";
|
||||
import { sendVaccineReminders } from "@/lib/vaccine-reminders";
|
||||
|
||||
function authorized(req: Request): boolean {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) return false;
|
||||
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnert 30 Tage vor dem Folgetermin einer Impfung (`Vaccine.nextDueDate`)
|
||||
* per Mitteilung + Push daran, einen Impftermin zu vereinbaren. Ausgelöst
|
||||
* täglich vom NAS-Crontab über docker/run-cron.sh.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
if (!authorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const result = await sendVaccineReminders(prisma);
|
||||
return NextResponse.json({ ok: result.failed === 0, ...result });
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { redis } from "@/lib/redis";
|
||||
import { fanOutPost } from "@/lib/feed-helpers";
|
||||
import { MAX_STORY_VIDEO_DURATION_SECS } from "@/lib/story-video-limits";
|
||||
import { maybeQualifyReferral } from "@/lib/referrals";
|
||||
import { notifyStoryMentions } from "@/lib/story-mentions";
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/mux
|
||||
@@ -124,6 +125,13 @@ export async function POST(req: NextRequest) {
|
||||
aspectRatio: aspectRatio ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Story-Editor: Markierungen wurden beim Upload gespeichert, aber erst
|
||||
// jetzt ist die Story sichtbar → jetzt benachrichtigen.
|
||||
if (!overStoryLimit) {
|
||||
const mentions = await prisma.storyMention.findMany({ where: { storyId: story.id }, select: { petId: true } });
|
||||
notifyStoryMentions(prisma, story.id, story.petId, mentions.map((m) => m.petId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { resolveAppRedirect } from "@/lib/app-redirect";
|
||||
import { countAppRedirect } from "@/lib/app-redirect-stats";
|
||||
|
||||
/**
|
||||
* /app — Download-Weiterleitung für Flyer, Sticker und QR-Codes
|
||||
* (z. B. pawfeed.org/app?utm_source=flyer). Logik: src/lib/app-redirect.ts.
|
||||
* Öffentlich (siehe isPublicRoute in src/proxy.ts), nie gecacht, weil das
|
||||
* Ziel vom Gerät abhängt.
|
||||
*/
|
||||
|
||||
function resolveOrigin(request: NextRequest): string {
|
||||
const proto = request.headers.get("x-forwarded-proto") ?? "https";
|
||||
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "pawfeed.org";
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const target = resolveAppRedirect(
|
||||
request.headers.get("user-agent"),
|
||||
request.nextUrl.searchParams,
|
||||
resolveOrigin(request)
|
||||
);
|
||||
await countAppRedirect(target.source, target.platform);
|
||||
|
||||
const response = NextResponse.redirect(target.location, 302);
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
return response;
|
||||
}
|
||||
@@ -217,6 +217,11 @@ export default function DatenschutzPage() {
|
||||
keine gesetzlichen Aufbewahrungspflichten entgegenstehen. Kontodaten werden nach
|
||||
Löschung des Kontos innerhalb von 30 Tagen gelöscht.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Storys sind 24 Stunden für andere sichtbar. Spätestens 7 Tage nach dem Veröffentlichen
|
||||
löschen wir sie automatisch endgültig, zusammen mit Bild bzw. Video, Aufrufen, Pfoten und
|
||||
Kommentaren. Sie können eine eigene Story jederzeit vorher selbst löschen.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Technische Fehlerberichte (Absturz-/Fehlerdaten inkl. IP-Adresse, siehe Abschnitt 2)
|
||||
werden automatisiert nach 30 Tagen gelöscht. Dieser Zeitraum ist nach unserer Erfahrung
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Sprout } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const PLACEHOLDER = "{name}";
|
||||
const PREVIEW_NAME = "Bubi";
|
||||
|
||||
type Form = {
|
||||
welcomeEnabled: boolean;
|
||||
autoFollowEnabled: boolean;
|
||||
welcomeTextDe: string;
|
||||
welcomeTextEn: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Begrüßung neuer Tiere durch das Kurator-Tier „PawFeed Team“ und Stand der
|
||||
* Gründungsmitglieder. Ändern dürfen nur SUPER_ADMINs (curator-admin.ts).
|
||||
*/
|
||||
export default function AdminCuratorPage() {
|
||||
const trpc = useTRPC();
|
||||
const { data, isLoading } = useQuery(trpc.curatorAdmin.get.queryOptions());
|
||||
|
||||
if (isLoading || !data) return <Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Begrüßung</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Was das Kurator-Konto „PawFeed Team“ bei jedem neu angelegten Tier automatisch macht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="flex items-center gap-4 rounded-xl border p-4">
|
||||
<Sprout className="h-6 w-6 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">Gründungsmitglieder</p>
|
||||
<p className="text-sm text-muted-foreground">Die ersten {data.foundingLimit} Tiere bekommen das Abzeichen automatisch und dauerhaft.</p>
|
||||
</div>
|
||||
<p className="font-mono text-xl font-semibold tabular-nums">
|
||||
{data.foundingCount} / {data.foundingLimit}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<CuratorForm key={String(data.settings.updatedAt)} initial={data.settings} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CuratorForm({ initial }: { initial: Form }) {
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [form, setForm] = useState<Form>({
|
||||
welcomeEnabled: initial.welcomeEnabled,
|
||||
autoFollowEnabled: initial.autoFollowEnabled,
|
||||
welcomeTextDe: initial.welcomeTextDe,
|
||||
welcomeTextEn: initial.welcomeTextEn,
|
||||
});
|
||||
const set = <K extends keyof Form>(key: K, value: Form[K]) => setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
const save = useMutation(
|
||||
trpc.curatorAdmin.update.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast.success("Begrüßung gespeichert");
|
||||
void qc.invalidateQueries(trpc.curatorAdmin.get.queryFilter());
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
);
|
||||
|
||||
const missingPlaceholder = !form.welcomeTextDe.includes(PLACEHOLDER) || !form.welcomeTextEn.includes(PLACEHOLDER);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
title="Team und neues Tier folgen sich gegenseitig"
|
||||
hint="Das neue Tier bekommt „PawFeed Team“ als ersten Follower (mit Benachrichtigung) und folgt dem Team selbst, damit sein Feed nicht leer startet."
|
||||
checked={form.autoFollowEnabled}
|
||||
onChange={(v) => set("autoFollowEnabled", v)}
|
||||
/>
|
||||
<ToggleRow
|
||||
title="Begrüßungsnachricht senden"
|
||||
hint="Direktnachricht vom Team an das neue Tier. Antworten landen unter „Messages“ im Team-Postfach."
|
||||
checked={form.welcomeEnabled}
|
||||
onChange={(v) => set("welcomeEnabled", v)}
|
||||
/>
|
||||
|
||||
<TextBlock
|
||||
id="welcome-de"
|
||||
label="Begrüßungstext Deutsch"
|
||||
value={form.welcomeTextDe}
|
||||
onChange={(v) => set("welcomeTextDe", v)}
|
||||
/>
|
||||
<TextBlock
|
||||
id="welcome-en"
|
||||
label="Begrüßungstext Englisch"
|
||||
value={form.welcomeTextEn}
|
||||
onChange={(v) => set("welcomeTextEn", v)}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<code className="rounded bg-muted px-1">{PLACEHOLDER}</code> wird durch den Namen des neuen Tiers ersetzt und muss in
|
||||
beiden Texten vorkommen. Welche Sprache ein Tier bekommt, richtet sich nach der App- bzw. Seitensprache beim Anlegen.
|
||||
</p>
|
||||
{missingPlaceholder && (
|
||||
<p className="text-sm text-destructive">Beide Texte brauchen den Platzhalter {PLACEHOLDER}.</p>
|
||||
)}
|
||||
|
||||
<Button onClick={() => save.mutate(form)} disabled={save.isPending || missingPlaceholder}>
|
||||
{save.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextBlock({ id, label, value, onChange }: { id: string; label: string; value: string; onChange: (v: string) => void }) {
|
||||
const preview = value.split(PLACEHOLDER).join(PREVIEW_NAME);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<Textarea id={id} value={value} maxLength={1000} rows={4} onChange={(e) => onChange(e.target.value)} />
|
||||
<div className="rounded-lg border border-dashed bg-muted/40 p-3 text-sm">
|
||||
<span className="block text-xs uppercase tracking-wide text-muted-foreground">Vorschau für „{PREVIEW_NAME}“</span>
|
||||
<span className="whitespace-pre-wrap">{preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ title, hint, checked, onChange }: { title: string; hint: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-4 rounded-xl border p-4">
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
<Switch checked={checked} onCheckedChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Radio,
|
||||
Music,
|
||||
Gift,
|
||||
Sprout,
|
||||
PawPrint,
|
||||
} from "lucide-react";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
@@ -62,6 +64,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
|
||||
{ href: `${base}/posts`, label: "Posts", Icon: FileText },
|
||||
{ href: `${base}/reports`, label: "Reports", Icon: Flag },
|
||||
{ href: `${base}/users`, label: "Users", Icon: Users },
|
||||
{ href: `${base}/pets`, label: "Tiere", Icon: PawPrint },
|
||||
{ href: `${base}/messages`, label: "Messages", Icon: MessageCircle },
|
||||
{ href: `${base}/ads`, label: "Ads", Icon: Megaphone },
|
||||
{ href: `${base}/music`, label: "Musik-Katalog", Icon: Music },
|
||||
@@ -73,6 +76,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
|
||||
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
|
||||
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
|
||||
{ href: `${base}/referrals`, label: "Empfehlungen", Icon: Gift },
|
||||
{ href: `${base}/curator`, label: "Begrüßung", Icon: Sprout },
|
||||
{ href: `${base}/blacklist`, label: "Blacklist", Icon: Ban },
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -5,8 +5,9 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { Flag, Clock, TriangleAlert, TrendingUp, ShieldAlert, Info, ChevronRight } from "lucide-react";
|
||||
import { Flag, Clock, TriangleAlert, TrendingUp, ShieldAlert, Info, ChevronRight, Sparkles } from "lucide-react";
|
||||
import { TimeSeriesChart } from "@/components/admin/TimeSeriesChart";
|
||||
import { SpeciesDistributionCard } from "@/components/admin/SpeciesDistributionCard";
|
||||
import { GranularityToggle, type Granularity } from "@/components/admin/GranularityToggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent } from "@/components/ui/card";
|
||||
@@ -28,6 +29,13 @@ export default function AdminDashboard() {
|
||||
})
|
||||
);
|
||||
|
||||
const { data: storyEditor } = useQuery(trpc.admin.getStoryEditorEnabled.queryOptions());
|
||||
const setStoryEditor = useMutation(
|
||||
trpc.admin.setStoryEditorEnabled.mutationOptions({
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: trpc.admin.getStoryEditorEnabled.queryKey() }),
|
||||
})
|
||||
);
|
||||
|
||||
const [postsGranularity, setPostsGranularity] = useState<Granularity>("day");
|
||||
const [ownerGranularity, setOwnerGranularity] = useState<Granularity>("day");
|
||||
const [visitsGranularity, setVisitsGranularity] = useState<Granularity>("day");
|
||||
@@ -89,6 +97,36 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storyEditor && (
|
||||
<div
|
||||
className={`flex items-center justify-between gap-4 rounded-xl ring-1 px-5 py-4 ${
|
||||
storyEditor.enabled ? "ring-foreground/10 bg-card" : "ring-destructive/40 bg-destructive/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles
|
||||
className={`h-5 w-5 shrink-0 ${storyEditor.enabled ? "text-muted-foreground" : "text-destructive"}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Story-Editor (Notschalter)</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{storyEditor.enabled
|
||||
? "An — Text, Markierungen, GIFs und Sticker in Storys sind aktiv."
|
||||
: "Aus — App und Web blenden Editor und Overlays aus (ohne App-Update)."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={storyEditor.enabled ? "outline" : "destructive"}
|
||||
disabled={setStoryEditor.isPending}
|
||||
onClick={() => setStoryEditor.mutate({ enabled: !storyEditor.enabled })}
|
||||
>
|
||||
{storyEditor.enabled ? "Abschalten" : "Wieder einschalten"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">Loading…</div>
|
||||
) : (
|
||||
@@ -162,6 +200,8 @@ export default function AdminDashboard() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<SpeciesDistributionCard />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { BadgeCheck, Loader2, Search, Sprout } from "lucide-react";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PetAvatar } from "@/components/ui/pet-avatar";
|
||||
import { AchievedRankBadges } from "@/components/admin/pets/AchievedRankBadges";
|
||||
import { PetDetailSheet } from "@/components/admin/pets/PetDetailSheet";
|
||||
|
||||
type Filter = "all" | "verified" | "founding" | "ranked" | "bannedOwner";
|
||||
type Sort = "newest" | "followers" | "posts";
|
||||
|
||||
const FILTERS: { value: Filter; label: string }[] = [
|
||||
{ value: "all", label: "Alle" },
|
||||
{ value: "verified", label: "Verifiziert" },
|
||||
{ value: "founding", label: "Gründungsmitglied" },
|
||||
{ value: "ranked", label: "Mit Empfehlungsrang" },
|
||||
{ value: "bannedOwner", label: "Owner gesperrt" },
|
||||
];
|
||||
const SORTS: { value: Sort; label: string }[] = [
|
||||
{ value: "newest", label: "Neueste zuerst" },
|
||||
{ value: "followers", label: "Meiste Follower" },
|
||||
{ value: "posts", label: "Meiste Beiträge" },
|
||||
];
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const selectClass = "h-9 rounded-md border bg-background px-2 text-sm";
|
||||
|
||||
/**
|
||||
* Tierverwaltung — jedes Tier einzeln: Suche nach Name, Spitzname,
|
||||
* Empfehlungscode oder ID; Details und Aktionen im Seitenpanel.
|
||||
* Berechtigungen prüft pet-admin.ts / referral-admin.ts serverseitig.
|
||||
*/
|
||||
export default function AdminPetsPage() {
|
||||
const trpc = useTRPC();
|
||||
const [search, setSearch] = useState("");
|
||||
const [speciesId, setSpeciesId] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [sort, setSort] = useState<Sort>("newest");
|
||||
const [openPetId, setOpenPetId] = useState<string | null>(null);
|
||||
const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS);
|
||||
|
||||
const { data: species = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: "de" }));
|
||||
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
trpc.petAdmin.list.infiniteQueryOptions(
|
||||
{ search: debouncedSearch || undefined, speciesId: speciesId || undefined, filter, sort },
|
||||
{ getNextPageParam: (last) => last.nextCursor, initialCursor: null }
|
||||
)
|
||||
);
|
||||
const pets = data?.pages.flatMap((page) => page.pets) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Tiere</h1>
|
||||
<p className="text-sm text-muted-foreground">Jedes Tier einzeln verwalten: Abzeichen, Ränge des Owners, Inhalte, Löschen.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Name, Spitzname, Empfehlungscode oder ID"
|
||||
aria-label="Tiere suchen"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<select aria-label="Tierart" value={speciesId} onChange={(e) => setSpeciesId(e.target.value)} className={selectClass}>
|
||||
<option value="">Alle Tierarten</option>
|
||||
{species.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select aria-label="Filter" value={filter} onChange={(e) => setFilter(e.target.value as Filter)} className={selectClass}>
|
||||
{FILTERS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select aria-label="Sortierung" value={sort} onChange={(e) => setSort(e.target.value as Sort)} className={selectClass}>
|
||||
{SORTS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader2 className="mx-auto my-12 h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : pets.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">Keine Tiere gefunden.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Tier</th>
|
||||
<th className="px-3 py-2">Tierart</th>
|
||||
<th className="px-3 py-2 text-right">Beiträge</th>
|
||||
<th className="px-3 py-2 text-right">Follower</th>
|
||||
<th className="px-3 py-2">Angelegt</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pets.map((pet) => {
|
||||
const ban = pet.owner.ban;
|
||||
const isOwnerBanned = !!ban && (!ban.expiresAt || new Date(ban.expiresAt) > new Date());
|
||||
return (
|
||||
<tr
|
||||
key={pet.id}
|
||||
className="cursor-pointer border-t hover:bg-muted/40 focus-within:bg-muted/40"
|
||||
onClick={() => setOpenPetId(pet.id)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-left font-medium focus-visible:outline-2 focus-visible:outline-orange-500"
|
||||
onClick={() => setOpenPetId(pet.id)}
|
||||
>
|
||||
<PetAvatar name={pet.name} avatarKey={pet.avatarKey} size="sm" />
|
||||
<span>
|
||||
{pet.name}
|
||||
{pet.nickname && <span className="text-muted-foreground"> ({pet.nickname})</span>}
|
||||
</span>
|
||||
{pet.isVerified && <BadgeCheck className="h-3.5 w-3.5 fill-blue-500 text-background" aria-label="Verifiziert" />}
|
||||
{pet.isFoundingMember && <Sprout className="h-3.5 w-3.5 text-emerald-600" aria-label="Gründungsmitglied" />}
|
||||
<AchievedRankBadges achievements={pet.owner.rankAchievements} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{pet.species.name}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{pet._count.posts}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{pet._count.followers}</td>
|
||||
<td className="px-3 py-2 text-muted-foreground tabular-nums">{new Date(pet.createdAt).toLocaleDateString("de-DE")}</td>
|
||||
<td className="px-3 py-2">{isOwnerBanned && <Badge variant="destructive">Owner gesperrt</Badge>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && (
|
||||
<Button variant="outline" onClick={() => void fetchNextPage()} disabled={isFetchingNextPage}>
|
||||
{isFetchingNextPage ? "Wird geladen…" : "Mehr laden"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<PetDetailSheet petId={openPetId} onClose={() => setOpenPetId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { BlurImage } from "@/components/ui/blur-image";
|
||||
import { Flag, Trash2, X, Loader2, CheckCheck, MessageCircle, Heart, ImageOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
|
||||
@@ -37,6 +39,9 @@ export default function AdminReportsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [action, setAction] = useState<ReportAction | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
// found.md: Melder bekommt eine Team-Nachricht (Begründung bzw. Dank).
|
||||
const [dismissMessage, setDismissMessage] = useState("");
|
||||
const [notifyReporter, setNotifyReporter] = useState(true);
|
||||
const [detailPostId, setDetailPostId] = useState<string | null>(null);
|
||||
|
||||
const { data: reports = [], isLoading } = useQuery(trpc.admin.listReports.queryOptions());
|
||||
@@ -72,14 +77,20 @@ export default function AdminReportsPage() {
|
||||
function closeDialog() {
|
||||
setAction(null);
|
||||
setReason("");
|
||||
setDismissMessage("");
|
||||
setNotifyReporter(true);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!action) return;
|
||||
if (action.kind === "dismiss") {
|
||||
dismiss.mutate({ reportId: action.reportId });
|
||||
dismiss.mutate({
|
||||
reportId: action.reportId,
|
||||
notifyReporter,
|
||||
message: dismissMessage.trim() || undefined,
|
||||
});
|
||||
} else {
|
||||
deletePost.mutate({ postId: action.postId, reason: reason || undefined });
|
||||
deletePost.mutate({ postId: action.postId, reason: reason || undefined, notifyReporters: notifyReporter });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,9 +280,20 @@ export default function AdminReportsPage() {
|
||||
</DialogHeader>
|
||||
|
||||
{action?.kind === "dismiss" ? (
|
||||
<p className="text-sm text-zinc-400">
|
||||
Den Report als unbegründet schließen? Der Inhalt bleibt bestehen.
|
||||
</p>
|
||||
<>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Den Report als unbegründet schließen? Der Inhalt bleibt bestehen.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="Begründung für den Melder (optional), z. B. warum kein Verstoß vorliegt"
|
||||
value={dismissMessage}
|
||||
onChange={(e) => setDismissMessage(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
disabled={!notifyReporter}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-400">
|
||||
@@ -279,7 +301,7 @@ export default function AdminReportsPage() {
|
||||
ebenfalls geschlossen.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Begründung (optional)"
|
||||
placeholder="Interne Begründung (optional, nur im Moderations-Log)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
|
||||
@@ -287,6 +309,20 @@ export default function AdminReportsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-3 rounded-lg border border-zinc-800 px-3 py-2.5 text-sm">
|
||||
<Switch checked={notifyReporter} onCheckedChange={setNotifyReporter} className="mt-0.5" />
|
||||
<span>
|
||||
<span className="text-zinc-100">
|
||||
{action?.kind === "dismiss" ? "Melder benachrichtigen" : "Allen Meldern danken"}
|
||||
</span>
|
||||
<span className="block text-xs text-zinc-500">
|
||||
{action?.kind === "dismiss"
|
||||
? "Team-Nachricht: Meldung geprüft, kein Verstoß — plus deine Begründung."
|
||||
: "Team-Nachricht: Meldung geprüft, Inhalt entfernt, danke fürs Melden."}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" className="text-zinc-400" onClick={closeDialog}>
|
||||
Abbrechen
|
||||
|
||||
@@ -52,7 +52,12 @@ export default async function ReferralLandingPage({ params }: { params: Promise<
|
||||
<div className="mx-auto max-w-md space-y-8 text-center">
|
||||
{inviterPet ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<PetAvatar name={inviterPet.name} avatarKey={inviterPet.avatarKey} size="lg" className="h-24 w-24" />
|
||||
{/* size="default" + eigene Größe: die Variante "lg" setzt per data-Attribut fest size-10 und gewinnt gegen className. */}
|
||||
<PetAvatar
|
||||
name={inviterPet.name}
|
||||
avatarKey={inviterPet.avatarKey}
|
||||
className="size-28 shadow-lg ring-4 ring-orange-500/25 sm:size-36"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance">
|
||||
{t("titleWithPet", { name: inviterPet.name })}
|
||||
</h1>
|
||||
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user