feat: Openinary medya galerisi — görsel yükleme/seçme (mucomutfak klasörü)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-07-21 10:06:59 +03:00
co-authored by Claude Fable 5
parent 8974cb3e1e
commit b3a3a4b450
7 changed files with 414 additions and 2 deletions
+18 -1
View File
@@ -153,9 +153,12 @@
<script setup lang="ts">
import { computed, h } from "vue";
import { useMediaPicker } from "@/composables/useMediaPicker";
defineOptions({ name: "AdminField" });
const mediaPicker = useMediaPicker();
const props = defineProps<{
parent: any;
field: string | number;
@@ -318,7 +321,8 @@ const isReadonly = computed(
function stringKind(s: string): "date" | "image" | "long" | "bool" | "text" {
if (/^(true|false)$/i.test(s)) return "bool";
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return "date";
if (/^\/[^\s]+\.(webp|jpe?g|png|svg|gif|avif)$/i.test(s)) return "image";
if (/^(\/|https?:\/\/)[^\s]+\.(webp|jpe?g|png|svg|gif|avif|heic|heif)$/i.test(s))
return "image";
if (s.length > 90 || s.includes("\n")) return "long";
return "text";
}
@@ -403,6 +407,19 @@ const renderPrimitive = () => {
class: inputCls + " font-mono text-xs",
onInput: (e: Event) => set((e.target as HTMLInputElement).value),
}),
isReadonly.value
? null
: h(
"button",
{
type: "button",
title: "Galeriden seç veya yükle",
class:
"shrink-0 text-xs font-semibold border border-gray-300 hover:border-[#3FA0C7] hover:text-[#3FA0C7] rounded-lg px-3 py-2 cursor-pointer",
onClick: () => mediaPicker.open((url) => set(url)),
},
"🖼 Galeri",
),
]);
}
+222
View File
@@ -0,0 +1,222 @@
<template>
<div
v-if="picker.state.visible"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="picker.close()"
>
<div
class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col"
>
<!-- Header -->
<div class="flex items-center gap-2 px-5 py-3 border-b border-gray-200">
<h3 class="font-bold text-[#3C393D]">Görsel Galerisi</h3>
<nav class="flex items-center gap-1 text-sm text-gray-500 min-w-0">
<template v-for="(crumb, i) in crumbs" :key="crumb.path">
<span v-if="i > 0" class="text-gray-300">/</span>
<button
class="hover:text-[#3FA0C7] cursor-pointer truncate"
:class="{ 'font-semibold text-[#3C393D]': i === crumbs.length - 1 }"
@click="load(crumb.path)"
>
{{ crumb.name }}
</button>
</template>
</nav>
<span class="flex-1"></span>
<label
class="bg-[#3FA0C7] hover:bg-[#3590b5] text-white text-sm font-semibold rounded-lg px-4 py-1.5 cursor-pointer"
:class="{ 'opacity-50 pointer-events-none': uploading }"
>
{{ uploading ? "Yükleniyor..." : "⬆ Görsel Yükle" }}
<input
type="file"
accept="image/*"
multiple
class="hidden"
@change="onUpload"
/>
</label>
<button
class="text-gray-400 hover:text-gray-600 text-xl px-2 cursor-pointer"
@click="picker.close()"
>
</button>
</div>
<p v-if="error" class="px-5 py-2 text-sm text-red-600 bg-red-50">
{{ error }}
</p>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-5">
<div v-if="loading" class="text-center text-gray-400 py-10 text-sm">
Yükleniyor...
</div>
<template v-else>
<div
class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3"
>
<!-- Folders -->
<button
v-for="f in folders"
:key="f.path"
class="aspect-square rounded-xl border border-gray-200 hover:border-[#3FA0C7] flex flex-col items-center justify-center gap-1 cursor-pointer bg-[#fafbfc]"
@click="load(f.path)"
>
<span class="text-3xl">📁</span>
<span class="text-xs text-gray-600 px-1 truncate w-full text-center">{{
f.name
}}</span>
</button>
<!-- Images -->
<div
v-for="file in files"
:key="file.path"
class="group relative aspect-square rounded-xl border border-gray-200 hover:border-[#3FA0C7] overflow-hidden cursor-pointer"
:title="file.name"
@click="picker.select(file.url)"
>
<img
:src="file.thumb"
loading="lazy"
class="w-full h-full object-cover"
/>
<span
class="absolute inset-x-0 bottom-0 bg-black/60 text-white text-[10px] px-1.5 py-1 truncate"
>{{ file.name }}</span
>
<button
class="absolute top-1 right-1 hidden group-hover:flex bg-black/60 hover:bg-red-600 text-white text-xs rounded-md w-6 h-6 items-center justify-center cursor-pointer"
title="Sil"
@click.stop="removeFile(file)"
>
</button>
</div>
</div>
<p
v-if="!folders.length && !files.length"
class="text-center text-gray-400 py-10 text-sm"
>
Bu klasör boş sağ üstten görsel yükleyebilirsiniz.
</p>
</template>
</div>
<p class="px-5 py-2 text-[11px] text-gray-400 border-t border-gray-100">
Bir görsele tıklayınca alana eklenir. Görseller media.ayris.tech /
{{ ROOT }} klasöründe saklanır.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useMediaPicker } from "@/composables/useMediaPicker";
type MediaFolder = { name: string; path: string };
type MediaFile = {
name: string;
path: string;
url: string;
thumb: string;
size: number;
};
const ROOT = "mucomutfak";
const picker = useMediaPicker();
const loading = ref(false);
const uploading = ref(false);
const error = ref("");
const currentPath = ref(ROOT);
const folders = ref<MediaFolder[]>([]);
const files = ref<MediaFile[]>([]);
const crumbs = computed(() => {
const parts = currentPath.value.split("/");
return parts.map((name, i) => ({
name,
path: parts.slice(0, i + 1).join("/"),
}));
});
function authHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${localStorage.getItem("muco-admin-token") ?? ""}`,
};
}
async function load(path: string) {
loading.value = true;
error.value = "";
try {
const res = await fetch(
`/api/admin/media?path=${encodeURIComponent(path)}`,
{ headers: authHeaders() },
);
const body = await res.json();
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
currentPath.value = body.path;
folders.value = body.folders;
files.value = body.files;
} catch (e: any) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
uploading.value = true;
error.value = "";
try {
const fd = new FormData();
for (const f of input.files) fd.append("files", f);
fd.append("folder", currentPath.value);
const res = await fetch("/api/admin/media/upload", {
method: "POST",
headers: authHeaders(),
body: fd,
});
const body = await res.json();
if (!res.ok || body.success === false) {
throw new Error(body.error || body.errors?.[0]?.error || "Yükleme hatası");
}
await load(currentPath.value);
} catch (e: any) {
error.value = e.message;
} finally {
uploading.value = false;
input.value = "";
}
}
async function removeFile(file: MediaFile) {
if (!confirm(`"${file.name}" kalıcı olarak silinsin mi?`)) return;
error.value = "";
try {
const res = await fetch(
`/api/admin/media?path=${encodeURIComponent(file.path)}`,
{ method: "DELETE", headers: authHeaders() },
);
const body = await res.json();
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
await load(currentPath.value);
} catch (e: any) {
error.value = e.message;
}
}
// Modal her açıldığında güncel listeyi çek
watch(
() => picker.state.visible,
(v) => {
if (v) load(currentPath.value);
},
);
</script>
+29
View File
@@ -0,0 +1,29 @@
import { reactive } from "vue";
type SelectCallback = (url: string) => void;
// Admin medya galerisi için modül-seviyesi paylaşılan durum:
// Field.vue picker'ı açar, MediaPicker.vue seçimi geri bildirir.
const state = reactive({
visible: false,
callback: null as SelectCallback | null,
});
export function useMediaPicker() {
return {
state,
open(cb: SelectCallback) {
state.callback = cb;
state.visible = true;
},
select(url: string) {
state.callback?.(url);
state.callback = null;
state.visible = false;
},
close() {
state.callback = null;
state.visible = false;
},
};
}
+2 -1
View File
@@ -32,7 +32,8 @@
</div>
<!-- Panel -->
<div v-else class="flex min-h-dvh">
<AdminMediaPicker v-if="token" />
<div v-if="token" class="flex min-h-dvh">
<!-- Sidebar -->
<aside
class="w-64 shrink-0 bg-white border-r border-gray-200 flex flex-col"