mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #136 from Since-3/refactor/automatic-image-downsizer
added automatic image downsizer for vercel image optimization
This commit is contained in:
commit
9ef2acb5bd
5 changed files with 263 additions and 17 deletions
78
DEPLOYMENT.md
Normal file
78
DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Deployment: Staging (Vercel)
|
||||||
|
|
||||||
|
Kumasy läuft als Staging-Umgebung auf Vercel, getriggert über den `dev`-Branch. Dieses Dokument beschreibt den aktuellen Aufbau, damit er reproduzierbar ist und man bei Fehlern schnell die Ursache findet.
|
||||||
|
|
||||||
|
Kein Docker: die App ist ein reines Next.js 16 (App Router) Full-Stack-Projekt ohne separaten Server/WebSockets — alles läuft als Vercel Serverless/Edge Functions. Ein eigenes Image würde hier nur Komplexität ohne Nutzen hinzufügen.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- **Hosting:** Vercel, Git-Integration mit `Since-3/Kuma`. Push auf `dev` → automatisches Preview-Deployment (= Staging). `main` ist als Production-Branch gesetzt, aber Production ist bislang **nicht** eingerichtet (kein eigenes Supabase-Projekt, keine Production-Env-Vars) — dieses Dokument behandelt nur Staging.
|
||||||
|
- **Staging-URL (fest, an `dev` gepinnt):** `https://kumasy-git-dev-kumasy.vercel.app`
|
||||||
|
- **Region:** Vercel Functions laufen in `fra1` (Frankfurt) — bewusst umgestellt, weil die Default-Region (Cleveland/US East) wegen der Entfernung zur Supabase-DB in `eu-central-1` spürbar langsam war.
|
||||||
|
- **Datenbank/Auth:** eigenes, separates Supabase-Projekt für Staging (Projekt-Ref `pyxjpsalchmnlyqfnzfw`), **nicht** dasselbe wie Produktion. Postgres + Supabase Auth (E-Mail/Passwort + Google OAuth).
|
||||||
|
- **Storage:** Supabase Storage, Bucket `avatars` (public) — wird für Profilbilder und Kursbilder verwendet.
|
||||||
|
- **Zahlungen:** Stripe, Test-Mode. Eigener Webhook-Endpoint für die Staging-URL (Webhook-Secrets sind pro Endpoint eindeutig, können nicht mit Produktion geteilt werden).
|
||||||
|
- **E-Mail:** Resend, gleicher API-Key wie lokal/Produktion (kein Test/Live-Split wie bei Stripe).
|
||||||
|
|
||||||
|
## Environment-Variablen
|
||||||
|
|
||||||
|
Alle in Vercel unter **Project Settings → Environment Variables**, Scope **"Preview"** (niemals "Production" — das hält Staging- und Produktions-Secrets sauber getrennt).
|
||||||
|
|
||||||
|
| Variable | Quelle |
|
||||||
|
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` |
|
||||||
|
| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
|
||||||
|
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
|
||||||
|
| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal |
|
||||||
|
| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
|
||||||
|
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
|
||||||
|
| `COMPANY_NAME` | `"Kumasy"` |
|
||||||
|
|
||||||
|
Lokales Pendant für einmalige DB-Operationen gegen Staging (z.B. neue Migrationen einspielen): `.env.staging` im Repo-Root, gitignored (`.env*` steht in `.gitignore`), wird nie committed.
|
||||||
|
|
||||||
|
## Build Command (Vercel Project Settings → Build & Development Settings)
|
||||||
|
|
||||||
|
```
|
||||||
|
npx prisma generate && npx prisma migrate deploy && next build
|
||||||
|
```
|
||||||
|
|
||||||
|
`package.json` hat kein `postinstall` für Prisma — ohne diesen Override würde der Build mit fehlendem/veraltetem Prisma Client scheitern, und neue Migrationen würden nie automatisch angewendet.
|
||||||
|
|
||||||
|
## Bekannte Stolperfallen (aufgetreten während des Erstaufbaus)
|
||||||
|
|
||||||
|
1. **`prisma.config.ts` + Vercel = Crash beim Config-Laden.** Prisma 6.19's `env()`-Helper wirft synchron beim Laden der Config, wenn die Variable zu dem Zeitpunkt nicht sichtbar ist (bekannter Prisma-Bug, siehe [prisma/prisma#28607](https://github.com/prisma/prisma/issues/28607)). Fix: in `prisma.config.ts` `process.env.DATABASE_URL!` statt `env("DATABASE_URL")` verwenden.
|
||||||
|
2. **Supabase Direct Connection ist IPv6-only.** `db.<project-ref>.supabase.co:5432` (die klassische "Direct Connection") funktioniert nicht von Vercels Build-Containern aus (`P1001: Can't reach database server`), weil dort kein IPv6 verfügbar ist. Für `DIRECT_URL` stattdessen den **Session Pooler** verwenden: `aws-0-eu-central-1.pooler.supabase.com:5432` (gleicher Host wie der Transaction Pooler, nur Port 5432 statt 6543, ohne `pgbouncer=true`).
|
||||||
|
3. **`.env.example` hat einen Fehler:** zeigt `DIRECT_URL` mit Port 6543, obwohl der Kommentar "für Migrationen" sagt. Der Pooler/Direct-Unterschied ist der Port, nicht nur der Host — siehe Punkt 2 für den korrekten Wert.
|
||||||
|
4. **Schema-Drift zwischen `schema.prisma` und den committeten Migrationen.** Mehrere Felder (`Employee.permissions/roles/locations/onboardingToken`, `Business.stripeAccountId/slug`, `CourseBooking`-Zahlungsfelder, `GuestLead`-Tabelle) existierten im Schema, aber in keiner Migrationsdatei — offenbar wurden sie irgendwann per `prisma db push` direkt an einer DB angelegt, ohne die Migration zu committen. Behoben durch `prisma migrate diff --from-url $DIRECT_URL --to-schema-datamodel prisma/schema.prisma --script`, das Ergebnis als neue Migration (`prisma/migrations/20260729161133_sync_schema_drift/`) committed. **Wichtig für Produktion:** dort existieren dieselben Spalten vermutlich schon (per `db push`) — diese Migration darf dort nicht ausgeführt, sondern muss mit `prisma migrate resolve --applied 20260729161133_sync_schema_drift` als bereits angewendet markiert werden, sonst schlägt `migrate deploy` mit "column already exists" fehl.
|
||||||
|
5. **Next.js Image Optimization braucht die Storage-Domain explizit.** `next.config.ts` → `images.remotePatterns` muss den Hostnamen des jeweiligen Supabase-Projekts enthalten. Staging-Domain wurde ergänzt, Produktions-Domain bleibt zusätzlich bestehen (dieselbe Datei bedient später auch Production).
|
||||||
|
6. **Storage-Buckets werden nicht mitkopiert.** Ein neues Supabase-Projekt hat keine Buckets — `avatars` (public) musste manuell im Staging-Projekt angelegt werden, sonst schlagen Uploads mit "Bucket not found" fehl.
|
||||||
|
|
||||||
|
## Reihenfolge, falls das Setup je neu aufgebaut werden muss
|
||||||
|
|
||||||
|
1. Neues Supabase-Projekt anlegen, Connection-Strings/Keys notieren (Pooler für `DATABASE_URL`, **Session Pooler** für `DIRECT_URL`).
|
||||||
|
2. Migrationen einmalig lokal einspielen: `npx prisma migrate deploy` mit `.env.staging`.
|
||||||
|
3. Storage-Bucket `avatars` (public) im neuen Projekt anlegen.
|
||||||
|
4. Vercel-Projekt importieren, Branch-Domain auf `dev` pinnen (Project Settings → Domains).
|
||||||
|
5. Region auf `fra1` stellen (Project Settings → Functions).
|
||||||
|
6. Stripe-Test-Webhook auf die Staging-Domain anlegen, Signing Secret notieren.
|
||||||
|
7. Alle Env-Vars in Vercel eintragen (Scope: Preview), Build Command überschreiben.
|
||||||
|
8. Ersten Deploy auslösen, danach Google OAuth in Supabase (Site URL, Redirect URLs) auf die bestätigte Staging-Domain konfigurieren.
|
||||||
|
9. `next.config.ts` um die neue Storage-Domain ergänzen, committen.
|
||||||
|
|
||||||
|
## Verifikations-Checkliste
|
||||||
|
|
||||||
|
- [x] Homepage lädt, Bilder rendern (dicebear, Supabase Storage, Google-Avatare)
|
||||||
|
- [x] Öffentliche Listing-Seite zeigt echte Daten aus der Staging-DB
|
||||||
|
- [x] E-Mail/Passwort-Registrierung funktioniert
|
||||||
|
- [x] Google-Login funktioniert Ende-zu-Ende
|
||||||
|
- [x] Schreibvorgang (z.B. Kurs anlegen) funktioniert
|
||||||
|
- [x] Bild-Upload rendert korrekt
|
||||||
|
- [ ] Stripe-Checkout mit Testkarte `4242 4242 4242 4242` — **noch offen**, hängt an Stripe Connect Onboarding
|
||||||
|
- [ ] Stripe-Webhook zeigt "delivered", `CourseBooking.paymentStatus` wechselt auf `"paid"` — **noch offen**
|
||||||
|
- [ ] Buchungsbestätigungs-Mail kommt tatsächlich an (Resend) — **noch offen**
|
||||||
|
- [ ] Stripe Connect Onboarding — **noch offen**
|
||||||
|
- [x] Beide Sprachen (de/en) spot-gecheckt
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Camera, X } from "lucide-react";
|
import { Camera, Loader2, X } from "lucide-react";
|
||||||
|
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||||
|
import { Progress } from "@/src/components/ui/progress";
|
||||||
|
|
||||||
interface ImageUploadComponentProps {
|
interface ImageUploadComponentProps {
|
||||||
value: File | null;
|
value: File | null;
|
||||||
|
|
@ -14,11 +16,18 @@ interface ImageUploadComponentProps {
|
||||||
const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadComponentProps) => {
|
const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadComponentProps) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||||
|
|
||||||
const handleFile = (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
if (!file.type.startsWith("image/")) return;
|
if (!file.type.startsWith("image/")) return;
|
||||||
const url = URL.createObjectURL(file);
|
setIsOptimizing(true);
|
||||||
onChange(file, url);
|
try {
|
||||||
|
const resizedFile = await downsizeImage(file);
|
||||||
|
const url = URL.createObjectURL(resizedFile);
|
||||||
|
onChange(resizedFile, url);
|
||||||
|
} finally {
|
||||||
|
setIsOptimizing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -42,19 +51,26 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div
|
<div
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => !isOptimizing && inputRef.current?.click()}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
if (!isOptimizing) setIsDragging(true);
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setIsDragging(false)}
|
onDragLeave={() => setIsDragging(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={isOptimizing ? undefined : handleDrop}
|
||||||
className={`relative w-28 h-28 rounded-full border-2 border-dashed flex items-center justify-center cursor-pointer transition-colors overflow-hidden
|
className={`relative w-28 h-28 rounded-full border-2 border-dashed flex items-center justify-center transition-colors overflow-hidden
|
||||||
|
${isOptimizing ? "cursor-wait" : "cursor-pointer"}
|
||||||
${isDragging ? "border-blue bg-blue/5" : "border-gray-300 hover:border-blue hover:bg-gray-50"}
|
${isDragging ? "border-blue bg-blue/5" : "border-gray-300 hover:border-blue hover:bg-gray-50"}
|
||||||
${preview ? "border-solid border-blue" : ""}
|
${preview ? "border-solid border-blue" : ""}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{preview ? (
|
{isOptimizing ? (
|
||||||
|
<div className="flex flex-col items-center gap-1.5 text-gray-400 px-3 w-full">
|
||||||
|
<Loader2 size={22} className="animate-spin" />
|
||||||
|
<span className="text-[11px] text-center leading-tight">Bild wird optimiert…</span>
|
||||||
|
<Progress value={60} className="w-full max-w-16" />
|
||||||
|
</div>
|
||||||
|
) : preview ? (
|
||||||
<>
|
<>
|
||||||
<Image src={preview} alt="Profilbild Vorschau" fill className="object-cover" />
|
<Image src={preview} alt="Profilbild Vorschau" fill className="object-cover" />
|
||||||
<button
|
<button
|
||||||
|
|
@ -76,7 +92,8 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className="text-sm text-blue underline cursor-pointer"
|
disabled={isOptimizing}
|
||||||
|
className="text-sm text-blue underline cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{preview ? "Bild ändern" : "Bild auswählen"}
|
{preview ? "Bild ändern" : "Bild auswählen"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -86,6 +103,7 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
disabled={isOptimizing}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
25
src/components/ui/progress.tsx
Normal file
25
src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { cn } from "@/src/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { value?: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className={cn("bg-gray-200 relative h-1.5 w-full overflow-hidden rounded-full", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-blue h-full transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
96
src/lib/image/downsize.ts
Normal file
96
src/lib/image/downsize.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
const MAX_DIMENSION = 1920;
|
||||||
|
const JPEG_QUALITY = 0.8;
|
||||||
|
const MAX_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MIN_DIMENSION = 320;
|
||||||
|
const MIN_QUALITY = 0.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downsizes the Image on the Client via Canvas so it will
|
||||||
|
* get through Vercels Image-Optimization-Limits (max. 4 MB).
|
||||||
|
* Animated GIFs and non-image files are returned unchanged, since
|
||||||
|
* rasterizing them via Canvas would destroy the animation.
|
||||||
|
*/
|
||||||
|
export async function downsizeImage(
|
||||||
|
file: File,
|
||||||
|
maxDimension: number = MAX_DIMENSION,
|
||||||
|
quality: number = JPEG_QUALITY,
|
||||||
|
maxBytes: number = MAX_BYTES
|
||||||
|
): Promise<File> {
|
||||||
|
if (
|
||||||
|
!file.type.startsWith("image/") ||
|
||||||
|
file.type === "image/svg+xml" ||
|
||||||
|
file.type === "image/gif"
|
||||||
|
) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitmap = await createImageBitmap(file).catch(() => null);
|
||||||
|
if (!bitmap) return file;
|
||||||
|
|
||||||
|
const { width, height } = bitmap;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputType = file.type === "image/png" ? "image/png" : "image/jpeg";
|
||||||
|
|
||||||
|
let currentScale = Math.min(1, maxDimension / Math.max(width, height));
|
||||||
|
let currentQuality = quality;
|
||||||
|
let bestBlob: Blob | null = null;
|
||||||
|
|
||||||
|
// Iteratively shrink dimensions/quality until the output fits under maxBytes,
|
||||||
|
// or we hit the floor (MIN_DIMENSION / MIN_QUALITY) and give up further shrinking.
|
||||||
|
for (let attempt = 0; attempt < 8; attempt++) {
|
||||||
|
const targetWidth = Math.max(1, Math.round(width * currentScale));
|
||||||
|
const targetHeight = Math.max(1, Math.round(height * currentScale));
|
||||||
|
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
|
ctx.clearRect(0, 0, targetWidth, targetHeight);
|
||||||
|
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, outputType, outputType === "image/jpeg" ? currentQuality : undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!blob) break;
|
||||||
|
bestBlob = blob;
|
||||||
|
|
||||||
|
if (blob.size <= maxBytes) break;
|
||||||
|
|
||||||
|
const atMinDimension = Math.max(targetWidth, targetHeight) <= MIN_DIMENSION;
|
||||||
|
const atMinQuality = outputType !== "image/jpeg" || currentQuality <= MIN_QUALITY;
|
||||||
|
|
||||||
|
if (atMinDimension && atMinQuality) break;
|
||||||
|
|
||||||
|
if (outputType === "image/jpeg" && currentQuality > MIN_QUALITY) {
|
||||||
|
currentQuality = Math.max(MIN_QUALITY, currentQuality - 0.15);
|
||||||
|
} else if (!atMinDimension) {
|
||||||
|
currentScale *= 0.75;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
|
if (!bestBlob || bestBlob.size >= file.size) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestBlob.size > maxBytes) {
|
||||||
|
throw new Error(
|
||||||
|
"Das Bild ist zu groß und konnte nicht ausreichend komprimiert werden. Bitte ein kleineres Bild wählen."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = outputType === file.type ? file.name : file.name.replace(/\.[^.]+$/, "") + ".jpg";
|
||||||
|
|
||||||
|
return new File([bestBlob], newName, { type: outputType, lastModified: Date.now() });
|
||||||
|
}
|
||||||
|
|
@ -13,9 +13,11 @@ import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
|
||||||
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Course } from "../../types/course.types";
|
import { Course } from "../../types/course.types";
|
||||||
import { Trash2, ImagePlus, X } from "lucide-react";
|
import { Trash2, ImagePlus, X, Loader2 } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||||
|
import { Progress } from "@/src/components/ui/progress";
|
||||||
|
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||||
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
||||||
import {
|
import {
|
||||||
StandingOrderScopeDialog,
|
StandingOrderScopeDialog,
|
||||||
|
|
@ -149,6 +151,7 @@ const CourseCreateView = ({
|
||||||
isEdit ? (initialData!.coverImage ?? null) : null
|
isEdit ? (initialData!.coverImage ?? null) : null
|
||||||
);
|
);
|
||||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||||
|
const [imageUploadPhase, setImageUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
|
||||||
|
|
||||||
const isStandingOrderRelated =
|
const isStandingOrderRelated =
|
||||||
isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder);
|
isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder);
|
||||||
|
|
@ -219,9 +222,12 @@ const CourseCreateView = ({
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setIsUploadingImage(true);
|
setIsUploadingImage(true);
|
||||||
|
setImageUploadPhase("optimizing");
|
||||||
try {
|
try {
|
||||||
|
const resizedFile = await downsizeImage(file);
|
||||||
|
setImageUploadPhase("uploading");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", resizedFile);
|
||||||
const res = await fetch("/api/upload/course-image", { method: "POST", body: formData });
|
const res = await fetch("/api/upload/course-image", { method: "POST", body: formData });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(json.error);
|
if (!res.ok) throw new Error(json.error);
|
||||||
|
|
@ -229,6 +235,7 @@ const CourseCreateView = ({
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen");
|
toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen");
|
||||||
} finally {
|
} finally {
|
||||||
|
setImageUploadPhase(null);
|
||||||
setIsUploadingImage(false);
|
setIsUploadingImage(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -352,6 +359,10 @@ const CourseCreateView = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (status: "draft" | "published") => {
|
const handleSubmit = async (status: "draft" | "published") => {
|
||||||
|
if (isUploadingImage) {
|
||||||
|
toast.info("Bitte warten Sie, bis das Kursbild hochgeladen wurde.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status === "published" && !validateForm()) {
|
if (status === "published" && !validateForm()) {
|
||||||
toast.error("Bitte füllen Sie alle erforderlichen Felder aus");
|
toast.error("Bitte füllen Sie alle erforderlichen Felder aus");
|
||||||
return;
|
return;
|
||||||
|
|
@ -577,9 +588,20 @@ const CourseCreateView = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-gray-400 transition-colors">
|
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-gray-400 transition-colors px-6">
|
||||||
{isUploadingImage ? (
|
{isUploadingImage ? (
|
||||||
<span className="text-sm text-gray-500">Wird hochgeladen…</span>
|
<div className="flex flex-col items-center gap-2 w-full">
|
||||||
|
<Loader2 size={20} className="text-gray-400 animate-spin" />
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{imageUploadPhase === "optimizing"
|
||||||
|
? "Bild wird optimiert…"
|
||||||
|
: "Wird hochgeladen…"}
|
||||||
|
</span>
|
||||||
|
<Progress
|
||||||
|
value={imageUploadPhase === "optimizing" ? 40 : 85}
|
||||||
|
className="w-full max-w-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ImagePlus size={24} className="text-gray-400 mb-1" />
|
<ImagePlus size={24} className="text-gray-400 mb-1" />
|
||||||
|
|
@ -717,21 +739,28 @@ const CourseCreateView = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => handleSubmit("published")} disabled={isSubmitting}>
|
<Button
|
||||||
|
onClick={() => handleSubmit("published")}
|
||||||
|
disabled={isSubmitting || isUploadingImage}
|
||||||
|
>
|
||||||
{isSubmitting
|
{isSubmitting
|
||||||
? "Wird gespeichert..."
|
? "Wird gespeichert..."
|
||||||
: mode === "edit"
|
: mode === "edit"
|
||||||
? "Änderungen speichern"
|
? "Änderungen speichern"
|
||||||
: "Veröffentlichen"}
|
: "Veröffentlichen"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => router.push("/courses")} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push("/courses")}
|
||||||
|
disabled={isSubmitting || isUploadingImage}
|
||||||
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="mt-6 mb-4 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
className="mt-6 mb-4 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
onClick={() => handleSubmit("draft")}
|
onClick={() => handleSubmit("draft")}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isUploadingImage}
|
||||||
>
|
>
|
||||||
{mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
|
{mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue