Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa75fe612b | ||
|
|
ea3cf1dcd4 | ||
|
|
1e8e6bbd7d | ||
|
|
31b3f7b81b | ||
|
|
30c57b661c |
@@ -625,3 +625,5 @@ npx vitest # watch mode
|
||||
| A 401 (not 403) from an admin tRPC procedure usually means a stale Clerk session, not a permissions bug | `protectedProcedure` throws `UNAUTHORIZED` (401) when Clerk's session is missing/expired; `assertAdmin()` throws `FORBIDDEN` (403) when the session is valid but the role is too low. A browser tab left open and backgrounded for hours can let Clerk's silent token refresh lapse — reload the page before suspecting a code regression. |
|
||||
| `eas update` must be run with `--platform android` (`mobile/`), not `--platform all` | This app has no web target — `react-native-web` isn't installed — so the default `--platform all` fails bundling with `Unable to resolve module react-native-web/dist/index`. Previously published updates were all Android-only too (check `eas channel:view <channel>` to confirm before assuming this changed). |
|
||||
| `mobile/node_modules` can end up silently corrupted (missing nested files) after an interrupted/partial install, without `npm install` re-detecting or fixing it | Symptom: Metro bundling fails with `Unable to resolve module ./some/nested/path.js` inside an already-installed package (seen with `@expo/cli`'s `utils/telemetry`, then `@sentry/browser` and `@sentry-internal/browser-utils` in the same session) — `npm install` reports "up to date" and does nothing. Fix: `rm -rf node_modules && npm install` (a full reinstall), not a targeted one; deleting just the one broken package's folder can just surface the *next* corrupted package one bundle-failure at a time. |
|
||||
| Mobile Google SSO can break with `redirect url ... does not match an authorized redirect URI` even though `GoogleSignInButton.tsx`/`app.json`'s scheme are unchanged | Clerk keeps its authorized-redirect-URL allowlist server-side per instance (`GET`/`POST https://api.clerk.com/v1/redirect_urls`, auth via `CLERK_SECRET_KEY`), entirely outside this repo — it can be missing or reset independent of any code change. Check that list before debugging app code; add the missing entry (`{"url":"pawfeed://sso-callback"}` for this app's `"scheme": "pawfeed"`) via `POST /v1/redirect_urls` — takes effect immediately, no app deploy/rebuild needed. |
|
||||
| Admin music-track upload can fail even with a genuinely valid MP3/M4A/AAC file | Two independent causes, both hit in the same incident: (1) the Supabase bucket must be named **exactly** `music-tracks` (plural) — it was once created as `music-track` (singular), so every upload failed with a bucket-not-found error surfaced only as a generic "Upload fehlgeschlagen" toast; verify via `GET {SUPABASE_URL}/storage/v1/bucket` with the service-role key. (2) The upload form used to validate against the browser-reported `File.type`, which is sniffed from the OS's own MIME registry and is inconsistent for `.m4a`/`.aac` (e.g. Windows Chrome reports `.m4a` as `audio/x-m4a`, not `audio/mp4`) — it now derives the content-type from the file extension instead (`src/lib/music-content-types.ts`), matching what `MUSIC_ALLOWED_CONTENT_TYPES` (server-side, `supabase-storage.ts`) actually expects. |
|
||||
|
||||
+6
-1
@@ -117,6 +117,7 @@ Neu 7:
|
||||
→ 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.
|
||||
→ Umgesetzt (Plan: `~/.claude/plans/mobile-settings-tab-extraction.md`, Gitea-Issue #36, Branch `feature/settings-header-icon`). Statt eines 5. Bottom-Tabs (ursprünglich vorgeschlagene Variante, siehe Mockup `mobile-settings-tab-mockup.png` im Plan-Ordner) wurde ein **Zahnrad-Icon im Header** gewählt, neben der Mailbox — auf Wunsch des Users, da Settings realistisch nur 1–2× im Halbjahr geöffnet wird und ein permanenter 5. Tab dafür unnötig Platz kostet (Mockup: `mobile-settings-header-icon-mockup.png`). Neuer Screen `app/(app)/settings.tsx` (Stack-Push wie "Suche", kein Modal, keine Tab-Bar sichtbar währenddessen), erreichbar über `SettingsHeaderButton` in `(tabs)/_layout.tsx` — erscheint auf allen 4 Tabs neben `MessagesHeaderButton`. Die 5 Footer-Sektionen aus `pets.tsx` (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout) wurden nach `src/components/settings/*.tsx` extrahiert (DRY, je eigene Datei). `pets.tsx` ist jetzt reine Tierliste. Nebenbei behoben: `FooterErrorBoundary` schützt jetzt den **gesamten** Settings-Screen statt nur den Logout-Button (Lücke aus der Code-Review beim OTA-Indikator-Feature). Typecheck + Lint sauber, i18n-Parität geprüft.
|
||||
**Gemerged nach `main` in Commit `30c57b6`** (Merge von `feature/settings-header-icon`, Issue #36 geschlossen), end-to-end auf einem Android-Emulator verifiziert (Zahnrad öffnet Einstellungen korrekt, Zurück-Navigation funktioniert, keine Tab-Bar sichtbar währenddessen, "Meine Tiere" zeigt nur noch die Tierliste) und per OTA live veröffentlicht (Update-Group `f31e3b54`).
|
||||
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
|
||||
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
|
||||
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
|
||||
@@ -124,4 +125,8 @@ Neu 7:
|
||||
→ Ursache gefunden: `app/(app)/post-new.tsx` gehörte noch zu den nicht auf Dark Mode migrierten Screens — es importierte die statischen, Light-only `colors`/`typography` aus `theme/tokens.ts` direkt, statt sie per `useTheme()` zu beziehen (gleiche Bug-Klasse wie die bereits behobenen Fälle aus dem Darkmode-Audit, siehe README/CLAUDE.md-Kommentar in `theme/tokens.ts`: "dark-mode support has to be added file-by-file"). Der Container-Hintergrund blieb dadurch immer hell (`colors.porchWhite`), während die geteilte `TextField`-Komponente **korrekt** theme-abhängige Textfarbe zieht (`colors.ink`, im Dark Mode hell) — macht bei aktivem Dark Mode aus hellem Text auf hellem Hintergrund den gemeldeten "weiß auf weiß"-Effekt in der Caption-Eingabe.
|
||||
Fix: `post-new.tsx` auf `useTheme()` + `makeStyles(colors)`-Muster umgestellt (identisch zu `milestone-new.tsx`). Gleiches Muster/gleicher Fund auch in `story-new.tsx` entdeckt (dort ohne Texteingabe, macht sich als falscher heller Hintergrund statt unlesbarem Text bemerkbar) — gleich mitbehoben.
|
||||
Typecheck + Lint sauber (0 neue Warnungen, 6 vorbestehende `exhaustive-deps`-Warnungen unverändert).
|
||||
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
|
||||
**Nachtrag:** `app/(app)/search.tsx` (gleicher Fehler im Suchfeld) auf Nachfrage ebenfalls behoben — dort bekommen die separaten Unterkomponenten (`TabButton`, `PetRow`, `Loading`, `EmptyState`) `styles`/`colors` jetzt als Props von der Hauptkomponente durchgereicht, statt selbst die statischen Tokens zu importieren. Typecheck + Lint sauber (1 vorbestehende, unveränderte `alt-text`-Warnung bei `expo-image`).
|
||||
- [x] Google-SSO-Login ging nicht mehr, obwohl das schon mal gelöst war ("The current redirect url passed in the sign in or sign up request does not match an authorized redirect URI for this instance... pawfeed://sso-callback").
|
||||
→ **Kein Code-Bug** — `GoogleSignInButton.tsx`/`useWarmUpBrowser.ts` (Commit `99575dd`) waren unverändert korrekt. Ursache lag außerhalb des Repos: Clerk führt pro Instanz eine eigene, serverseitige Allowlist autorisierter Redirect-URLs (`GET/POST https://api.clerk.com/v1/redirect_urls`, Auth via `CLERK_SECRET_KEY`) — komplett getrennt vom App-Code, kann also unabhängig vom letzten "war doch schon gelöst"-Stand geändert/geleert worden sein. Registriert war nur ein falscher/veralteter Eintrag `clerk://org.pawfeed.app2.callback` (vermutlich Rest einer früheren Bundle-ID-Iteration mit einer "2" drin) — `pawfeed://sso-callback` (aus `app.json`s `"scheme": "pawfeed"`) fehlte komplett.
|
||||
Fix: fehlenden Eintrag per Clerk Backend API ergänzt (`POST /v1/redirect_urls`, `{"url":"pawfeed://sso-callback"}`) — live sofort wirksam, kein App-Deploy/Rebuild nötig. Am Emulator bis zum Google-Consent-Screen verifiziert (öffnet korrekt, leitet zu pawfeed.org weiter), kompletten Rücksprung dann vom User auf echtem Gerät bestätigt.
|
||||
**Merke für künftige Sessions:** wenn SSO trotz unverändertem Code plötzlich bricht, zuerst die Clerk-Redirect-URL-Allowlist prüfen, bevor im App-Code gesucht wird — die liegt nicht in git und kann von außerhalb dieser Session geändert worden sein.
|
||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { getMusicUrl } from "@/lib/music-url";
|
||||
import { MAX_MUSIC_FILE_SIZE, MUSIC_EXTENSION_CONTENT_TYPES, type MusicContentType } from "@/lib/music-content-types";
|
||||
import { Plus, Pencil, Trash2, Loader2, Check, X, Music, Play, Pause } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -23,9 +24,34 @@ type TrackForm = {
|
||||
|
||||
const emptyForm: TrackForm = { title: "", artist: "", licenseNote: "", storageKey: "", durationSecs: 0 };
|
||||
|
||||
const ALLOWED_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
|
||||
type AllowedType = (typeof ALLOWED_TYPES)[number];
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
const MAX_SIZE = MAX_MUSIC_FILE_SIZE;
|
||||
|
||||
/**
|
||||
* Extension -> canonical content-type, used instead of the browser-reported
|
||||
* `file.type` for both validation and the actual upload's Content-Type
|
||||
* header. `file.type` is sniffed by the browser/OS from its own MIME
|
||||
* registry and is inconsistent for exactly these formats — Windows Chrome
|
||||
* commonly reports `.m4a` as `audio/x-m4a` (not `audio/mp4`), and an
|
||||
* unrecognized extension can come back as an empty string. Either would
|
||||
* fail the old `ALLOWED_TYPES.includes(file.type)` check even for a
|
||||
* perfectly valid MP3/M4A/AAC file — the upload looked like a rejected file
|
||||
* type when it was really just an unrecognized *reported* MIME type.
|
||||
*
|
||||
* This does mean a file can be uploaded by simply renaming it with an
|
||||
* allowed extension (e.g. `notes.txt` -> `notes.mp3`) rather than the
|
||||
* browser's MIME-sniffed type having to agree first — a deliberate
|
||||
* trade-off, not an oversight (flagged in code review): `file.type` was
|
||||
* never a real content-based check either (browsers derive it from the same
|
||||
* OS extension registry, not from inspecting bytes), this panel sits behind
|
||||
* `ADMIN_SECRET` + a role check, not the public upload surface, and
|
||||
* `readAudioDuration()` below still fails for genuinely non-audio bytes.
|
||||
*/
|
||||
function resolveContentType(file: File): MusicContentType | null {
|
||||
const dotIndex = file.name.lastIndexOf(".");
|
||||
if (dotIndex <= 0) return null; // no extension (or a bare ".ext"-only name)
|
||||
const ext = file.name.slice(dotIndex + 1).toLowerCase();
|
||||
return MUSIC_EXTENSION_CONTENT_TYPES.get(ext) ?? null;
|
||||
}
|
||||
|
||||
/** Reads an audio file's duration client-side via a detached <audio> element. */
|
||||
function readAudioDuration(file: File): Promise<number> {
|
||||
@@ -116,8 +142,9 @@ export default function AdminMusicPage() {
|
||||
toast.error("Audiodatei muss unter 5 MB sein.");
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(file.type as AllowedType)) {
|
||||
toast.error("Nur MP3, M4A oder AAC erlaubt.");
|
||||
const contentType = resolveContentType(file);
|
||||
if (!contentType) {
|
||||
toast.error("Nur .mp3, .m4a oder .aac erlaubt.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -125,7 +152,7 @@ export default function AdminMusicPage() {
|
||||
try {
|
||||
const durationSecs = await readAudioDuration(file);
|
||||
const { presignedUrl, key } = await getUploadUrl.mutateAsync({
|
||||
contentType: file.type as AllowedType,
|
||||
contentType,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
@@ -135,7 +162,7 @@ export default function AdminMusicPage() {
|
||||
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`));
|
||||
xhr.onerror = () => reject(new Error("Network error"));
|
||||
xhr.open("PUT", presignedUrl);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
xhr.setRequestHeader("Content-Type", contentType);
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
@@ -245,7 +272,7 @@ export default function AdminMusicPage() {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/mpeg,audio/mp4,audio/aac"
|
||||
accept=".mp3,.m4a,.aac,audio/mpeg,audio/mp4,audio/aac"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,33 @@ const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
|
||||
const ALLOWED_VIDEO_TYPES = ["video/mp4", "video/quicktime", "video/webm"] as const;
|
||||
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // same as normal video posts (VideoUploadForm.tsx)
|
||||
|
||||
const ACCEPT_ATTR = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES].join(",");
|
||||
// Wildcard categories, not the explicit MIME list (`image/jpeg,...,video/mp4,...`)
|
||||
// — several mobile browsers'/OS's native "Photos & Videos" pickers only
|
||||
// reliably offer *both* media kinds when given the broad `image/*`/`video/*`
|
||||
// categories; a long explicit list mixing image and video subtypes was
|
||||
// silently narrowing the picker down to photos only on at least one such
|
||||
// picker (user report: "beim Story erstellen nur Zugriff auf Fotos, Videos
|
||||
// werden gar nicht angezeigt"). This only relaxes what the OS *offers* in
|
||||
// the picker dialog — the actual accept/reject decision still happens in
|
||||
// handleFile() below against the specific
|
||||
// ALLOWED_IMAGE_TYPES/ALLOWED_VIDEO_TYPES lists, unchanged. The two are
|
||||
// deliberately decoupled now (this is a fixed category string, not derived
|
||||
// from those lists) — a wildcard category can't meaningfully express "just
|
||||
// these three video subtypes" to the OS picker, so narrowing
|
||||
// ALLOWED_VIDEO_TYPES later won't narrow what the picker offers; it'll
|
||||
// still be enforced right here after selection, just with an extra
|
||||
// pick-then-reject round trip for a now-disallowed type — an accepted
|
||||
// trade-off for actually being able to pick a video at all. Two related,
|
||||
// deliberately-not-fixed-here risks (flagged in code review): (1) the OS
|
||||
// picker now also surfaces formats this form never accepted (GIF, MKV,
|
||||
// 3GPP, ...), each ending in the same pick-then-reject round trip; (2) iOS
|
||||
// Safari's HEIC->JPEG auto-transcode for gallery photos is reportedly tied
|
||||
// to the accept list being specific — `image/*` may let a raw HEIC file
|
||||
// through where `image/jpeg,image/png,image/webp` didn't, and
|
||||
// ALLOWED_IMAGE_TYPES doesn't list `image/heic`/`image/heif`. Neither is
|
||||
// confirmed against a real device in this session; revisit if iPhone users
|
||||
// report photo uploads failing here specifically.
|
||||
const ACCEPT_ATTR = "image/*,video/*";
|
||||
|
||||
interface StoryFormProps {
|
||||
onClose: () => void;
|
||||
@@ -181,8 +207,15 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
const isVideo = (ALLOWED_VIDEO_TYPES as readonly string[]).includes(file.type);
|
||||
const isImage = (ALLOWED_IMAGE_TYPES as readonly string[]).includes(file.type);
|
||||
// Strip codec parameters before comparing — some Android camera-roll
|
||||
// pickers report e.g. `video/mp4;codecs=hvc1` for HEVC-recorded clips,
|
||||
// which never matched the bare `ALLOWED_VIDEO_TYPES` strings. This was
|
||||
// unreachable while the accept attribute only offered photos (see
|
||||
// ACCEPT_ATTR above); now that videos are actually selectable, a real
|
||||
// mp4 could otherwise still get rejected as "invalid type" here.
|
||||
const bareMimeType = file.type.split(";")[0].trim().toLowerCase();
|
||||
const isVideo = (ALLOWED_VIDEO_TYPES as readonly string[]).includes(bareMimeType);
|
||||
const isImage = (ALLOWED_IMAGE_TYPES as readonly string[]).includes(bareMimeType);
|
||||
|
||||
if (!isVideo && !isImage) {
|
||||
setErrorMessage(t("invalidType"));
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Client-safe constants for Story music-track uploads. Extracted out of
|
||||
* `supabase-storage.ts` (which imports `"server-only"` and therefore can't
|
||||
* be imported from the admin music page, a `"use client"` component) so the
|
||||
* upload form and the server-side storage/tRPC code share one source of
|
||||
* truth instead of two independently maintained copies drifting apart —
|
||||
* found during code review of the upload-validation fix (see
|
||||
* README.md's Known Issues).
|
||||
*/
|
||||
export const MUSIC_ALLOWED_CONTENT_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
|
||||
export type MusicContentType = (typeof MUSIC_ALLOWED_CONTENT_TYPES)[number];
|
||||
|
||||
export const MAX_MUSIC_FILE_SIZE = 5 * 1024 * 1024; // 5 MB — short clips only
|
||||
|
||||
/**
|
||||
* Extension -> canonical content-type. A `Map`, not a plain object literal —
|
||||
* a plain-object lookup (`{ mp3: ... }[ext]`) resolves inherited
|
||||
* `Object.prototype` members for an extension like "constructor", silently
|
||||
* returning a function instead of `undefined` and bypassing the "unknown
|
||||
* extension" rejection entirely.
|
||||
*/
|
||||
export const MUSIC_EXTENSION_CONTENT_TYPES: ReadonlyMap<string, MusicContentType> = new Map([
|
||||
["mp3", "audio/mpeg"],
|
||||
["m4a", "audio/mp4"],
|
||||
["aac", "audio/aac"],
|
||||
]);
|
||||
@@ -1,5 +1,8 @@
|
||||
import "server-only";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { MUSIC_ALLOWED_CONTENT_TYPES, MAX_MUSIC_FILE_SIZE } from "./music-content-types";
|
||||
|
||||
export { MUSIC_ALLOWED_CONTENT_TYPES, MAX_MUSIC_FILE_SIZE };
|
||||
|
||||
// Lazy singleton — not initialized at module eval so Next.js build does not
|
||||
// throw when env vars are absent during the page-data collection phase.
|
||||
@@ -22,10 +25,14 @@ export const ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] a
|
||||
|
||||
// Music-track catalog (admin-curated, see admin.ts's music-track procedures)
|
||||
// — a separate bucket since it holds audio, not images. Must be created once
|
||||
// in the Supabase dashboard; this code assumes it already exists.
|
||||
// in the Supabase dashboard (name must match exactly — a "music-track" vs.
|
||||
// "music-tracks" typo here once made every upload fail with a bucket-not-found
|
||||
// error that surfaced as a generic upload failure, see README.md's Known
|
||||
// Issues); this code assumes it already exists.
|
||||
export const MUSIC_STORAGE_BUCKET = "music-tracks";
|
||||
export const MAX_MUSIC_FILE_SIZE = 5 * 1024 * 1024; // 5 MB — short clips only
|
||||
export const MUSIC_ALLOWED_CONTENT_TYPES = ["audio/mpeg", "audio/mp4", "audio/aac"] as const;
|
||||
// MAX_MUSIC_FILE_SIZE / MUSIC_ALLOWED_CONTENT_TYPES live in ./music-content-types
|
||||
// (a plain, non-"server-only" module) and are re-exported above so existing
|
||||
// imports of this file keep working — see that file's doc comment for why.
|
||||
|
||||
/**
|
||||
* Creates a signed upload URL for direct client-to-Supabase uploads.
|
||||
|
||||
Reference in New Issue
Block a user