feat: MediaPicker — sürükle-bırak + progress bar + kütüphane modal
Mevcut sorun: - Her görsel için medya sayfasına git, yükle, URL kopyala, forma yapıştır → 4 adım - Sürükle-bırak yok, progress yok, hangi dosyanın yüklendiği belirsiz Çözüm: MediaPicker component (tek/çoklu mode) API route'ları: - POST /api/admin/media/upload — session auth + Appwrite Storage upload - GET /api/admin/media/list — kütüphane modal için dosya listesi Component özellikleri: - Sürükle-bırak drop zone (hover state ile) - Multiple file upload (çoklu mode) - XHR ile gerçek progress bar (%) — Server Action ile alınamazdı - Görsel preview (single: aspect-video, multiple: aspect-square grid) - Hover'da × ile kaldırma - Multiple mode'da sırasını değiştirme - 'Kütüphaneden seç' modal — daha önce yüklenmiş görselleri grid'de göster, tıklayınca seç - Error handling (dosya boyutu, ağ hatası vb.) - Başarılı yüklemeyi 2 saniye gösterip kaybetme Form alanları → MediaPicker (URL field'ları kaldırıldı): - Blog: cover_image, seo_image - Hizmet: hero_image - Proje: image_url (kapak), gallery (çoklu) - Referans: image_url - Sektör: hero_image - Ekip: photo_url - SEO sayfa: og_image - SEO global: default_og_image - Site Settings: client_logos (çoklu) Backward compat: form data formatı aynı kalıyor — hidden input ile URL satır satır. admin-actions değişmedi. URL elle yapıştırmak hala mümkün (kütüphaneden URL kopyala).
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Select,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveBlogPost } from "@/lib/admin-actions";
|
||||
import type { BlogPostRow } from "@/lib/types";
|
||||
import { Save } from "lucide-react";
|
||||
@@ -81,19 +82,16 @@ export function BlogForm({ post }: { post?: BlogPostRow }) {
|
||||
rows={14}
|
||||
placeholder={"# Başlık\n\nMarkdown desteklenir…"}
|
||||
/>
|
||||
<Field
|
||||
label="Kapak görseli URL"
|
||||
<MediaPicker
|
||||
label="Kapak görseli"
|
||||
name="cover_image"
|
||||
type="url"
|
||||
defaultValue={post?.cover_image}
|
||||
placeholder="https://…"
|
||||
help="Medya kütüphanesinden bir görselin view URL'ini yapıştırın."
|
||||
help="Yazı listesinde ve detay sayfasının üst kısmında gösterilir."
|
||||
/>
|
||||
<Field
|
||||
label="Kapak file_id"
|
||||
<input
|
||||
type="hidden"
|
||||
name="cover_file_id"
|
||||
defaultValue={post?.cover_file_id}
|
||||
help="(opsiyonel) Appwrite storage file ID'si"
|
||||
defaultValue={post?.cover_file_id ?? ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -107,11 +105,11 @@ export function BlogForm({ post }: { post?: BlogPostRow }) {
|
||||
defaultValue={post?.seo_title}
|
||||
help="Boş bırakırsanız yazı başlığı kullanılır."
|
||||
/>
|
||||
<Field
|
||||
<MediaPicker
|
||||
label="SEO OG görseli"
|
||||
name="seo_image"
|
||||
type="url"
|
||||
defaultValue={post?.seo_image}
|
||||
help="Sosyal medyada paylaşılınca görünecek görsel (1200×630 ideal)."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveTeamMember } from "@/lib/admin-actions";
|
||||
import type { TeamMemberRow } from "@/lib/types";
|
||||
|
||||
@@ -29,13 +30,6 @@ export function TeamMemberForm({ row }: { row?: TeamMemberRow }) {
|
||||
defaultValue={row?.role}
|
||||
placeholder="Kurucu / Geliştirici / Tasarımcı"
|
||||
/>
|
||||
<Field
|
||||
label="Fotoğraf URL"
|
||||
name="photo_url"
|
||||
type="url"
|
||||
defaultValue={row?.photo_url}
|
||||
help="Medya kütüphanesinden URL kopyalayın."
|
||||
/>
|
||||
<Field
|
||||
label="LinkedIn URL"
|
||||
name="linkedin_url"
|
||||
@@ -49,6 +43,16 @@ export function TeamMemberForm({ row }: { row?: TeamMemberRow }) {
|
||||
defaultValue={row?.order ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<MediaPicker
|
||||
label="Fotoğraf"
|
||||
name="photo_url"
|
||||
defaultValue={row?.photo_url}
|
||||
maxSizeMB={5}
|
||||
help="Kare format ideal. Hakkımızda sayfasında gösterilir."
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<Textarea
|
||||
label="Biyografi"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveService } from "@/lib/admin-actions";
|
||||
import type { FaqItem, ServiceRow } from "@/lib/types";
|
||||
|
||||
@@ -71,12 +72,11 @@ export function ServiceForm({ service }: { service?: ServiceRow }) {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Field
|
||||
label="Hero görsel URL"
|
||||
<MediaPicker
|
||||
label="Hero görsel"
|
||||
name="hero_image"
|
||||
type="url"
|
||||
defaultValue={service?.hero_image}
|
||||
help="Detay sayfası başında gösterilir (opsiyonel)."
|
||||
help="Detay sayfasının üst kısmında gösterilir (opsiyonel)."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveProject } from "@/lib/admin-actions";
|
||||
import { listServices } from "@/lib/data";
|
||||
import type { ProjectRow } from "@/lib/types";
|
||||
@@ -63,12 +64,6 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
type="number"
|
||||
defaultValue={project?.year ?? new Date().getFullYear()}
|
||||
/>
|
||||
<Field
|
||||
label="Kapak görseli URL"
|
||||
name="image_url"
|
||||
type="url"
|
||||
defaultValue={project?.image_url}
|
||||
/>
|
||||
<Field
|
||||
label="Canlı URL"
|
||||
name="live_url"
|
||||
@@ -85,6 +80,13 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<MediaPicker
|
||||
label="Kapak görseli"
|
||||
name="image_url"
|
||||
defaultValue={project?.image_url}
|
||||
help="Liste ve detay sayfasının üstündeki ana görsel."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Kısa açıklama (kart için)"
|
||||
name="description"
|
||||
@@ -104,15 +106,12 @@ export async function ProjectForm({ project }: { project?: ProjectRow }) {
|
||||
help="Proje detay sayfasında uzun anlatım olarak gösterilir."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
<MediaPicker
|
||||
label="Galeri görselleri"
|
||||
name="gallery"
|
||||
defaultValue={project?.gallery?.join("\n")}
|
||||
rows={5}
|
||||
placeholder={
|
||||
"https://example.com/image1.jpg\nhttps://example.com/image2.jpg"
|
||||
}
|
||||
help="Her satıra bir URL. Medya kütüphanesinden URL'leri kopyalayın."
|
||||
multiple
|
||||
defaultValue={project?.gallery ?? []}
|
||||
help="Detay sayfasında lightbox galeri olarak gösterilir. Birden fazla görsel yükleyebilirsiniz."
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveTestimonial } from "@/lib/admin-actions";
|
||||
import type { TestimonialRow } from "@/lib/types";
|
||||
|
||||
@@ -24,7 +25,13 @@ export function TestimonialForm({ row }: { row?: TestimonialRow }) {
|
||||
<FormShell>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Field label="İsim" name="name" required defaultValue={row?.name} />
|
||||
<Field label="Görsel URL" name="image_url" type="url" defaultValue={row?.image_url} />
|
||||
<MediaPicker
|
||||
label="Profil görseli"
|
||||
name="image_url"
|
||||
defaultValue={row?.image_url}
|
||||
maxSizeMB={5}
|
||||
help="Müşteri profil fotoğrafı (kare format ideal)."
|
||||
/>
|
||||
<Field label="Pozisyon" name="role" defaultValue={row?.role} />
|
||||
<Field label="Firma" name="company" defaultValue={row?.company} />
|
||||
<Field
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveIndustry } from "@/lib/admin-actions";
|
||||
import type { FaqItem, IndustryRow } from "@/lib/types";
|
||||
|
||||
@@ -51,11 +52,11 @@ export function IndustryForm({ row }: { row?: IndustryRow }) {
|
||||
defaultValue={row?.slug}
|
||||
placeholder="avukat-web-tasarimi"
|
||||
/>
|
||||
<Field
|
||||
label="Hero görsel URL"
|
||||
<MediaPicker
|
||||
label="Hero görsel"
|
||||
name="hero_image"
|
||||
type="url"
|
||||
defaultValue={row?.hero_image}
|
||||
help="Sektör sayfasının üst kısmında gösterilir."
|
||||
/>
|
||||
<Field
|
||||
label="Sıra"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { saveSeoPage } from "@/lib/admin-actions";
|
||||
import type { SeoPageRow } from "@/lib/types";
|
||||
|
||||
@@ -39,11 +40,11 @@ export function SeoPageForm({ row }: { row?: SeoPageRow }) {
|
||||
rows={3}
|
||||
defaultValue={row?.description}
|
||||
/>
|
||||
<Field
|
||||
<MediaPicker
|
||||
label="OG görseli"
|
||||
name="og_image"
|
||||
type="url"
|
||||
defaultValue={row?.og_image}
|
||||
help="Sosyal medya paylaşım görseli (1200×630 ideal)."
|
||||
/>
|
||||
<Field
|
||||
label="Canonical URL"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PrimaryButton,
|
||||
Textarea,
|
||||
} from "@/components/admin/form";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import { DeleteButton } from "@/components/admin/delete-button";
|
||||
import { getSeoSettings, listSeoPages } from "@/lib/data";
|
||||
import { deleteSeoPage, saveSeoSettings } from "@/lib/admin-actions";
|
||||
@@ -42,12 +43,14 @@ export default async function SeoAdminPage() {
|
||||
defaultValue={settings?.site_name}
|
||||
placeholder="Kovak Yazılım"
|
||||
/>
|
||||
<Field
|
||||
label="Varsayılan OG görseli"
|
||||
name="default_og_image"
|
||||
type="url"
|
||||
defaultValue={settings?.default_og_image}
|
||||
/>
|
||||
<div>
|
||||
<MediaPicker
|
||||
label="Varsayılan OG görseli"
|
||||
name="default_og_image"
|
||||
defaultValue={settings?.default_og_image}
|
||||
help="Sosyal paylaşımlarda (1200×630 ideal)."
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="Twitter handle"
|
||||
name="twitter_handle"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/components/admin/form";
|
||||
import { getSiteSettings } from "@/lib/data";
|
||||
import { saveSiteSettings } from "@/lib/admin-actions";
|
||||
import { MediaPicker } from "@/components/admin/media-picker";
|
||||
import type {
|
||||
FaqItem,
|
||||
ProcessStep,
|
||||
@@ -407,13 +408,13 @@ export default async function SiteSettingsPage() {
|
||||
help='Her satır: "İkonAdı | Değer | Etiket" (örn. Star | 4.9 | Google yıldızı)'
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
<MediaPicker
|
||||
label="Müşteri logoları"
|
||||
name="client_logos"
|
||||
rows={4}
|
||||
defaultValue={s?.client_logos?.join("\n")}
|
||||
placeholder={"https://example.com/logo1.png\nhttps://example.com/logo2.png"}
|
||||
help="Her satıra bir URL. Logoların grayscale + opaque versiyonu gösterilir."
|
||||
multiple
|
||||
defaultValue={s?.client_logos ?? []}
|
||||
maxSizeMB={3}
|
||||
help="Anasayfada grayscale logo şeridi olarak gösterilir. Tercihen şeffaf PNG."
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
|
||||
Reference in New Issue
Block a user