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:
co-authored by
Claude Fable 5
parent
8974cb3e1e
commit
b3a3a4b450
Vendored
+1
@@ -12,6 +12,7 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AdminField: typeof import('./src/components/Admin/Field.vue')['default']
|
AdminField: typeof import('./src/components/Admin/Field.vue')['default']
|
||||||
|
AdminMediaPicker: typeof import('./src/components/Admin/MediaPicker.vue')['default']
|
||||||
Button: typeof import('./src/components/Button.vue')['default']
|
Button: typeof import('./src/components/Button.vue')['default']
|
||||||
Footer: typeof import('./src/components/Footer.vue')['default']
|
Footer: typeof import('./src/components/Footer.vue')['default']
|
||||||
Header: typeof import('./src/components/Header.vue')['default']
|
Header: typeof import('./src/components/Header.vue')['default']
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const { pool, ensureSchema, seedFromFiles, exportToFiles, isValidSlug } =
|
|||||||
const { startPublish, getPublishState, DIST_DIR } = await import(
|
const { startPublish, getPublishState, DIST_DIR } = await import(
|
||||||
"./publish.mjs"
|
"./publish.mjs"
|
||||||
);
|
);
|
||||||
|
const { listMedia, proxyUpload, deleteMedia, mediaEnabled, MEDIA_ROOT } =
|
||||||
|
await import("./media.mjs");
|
||||||
|
|
||||||
const PORT = Number(process.env.ADMIN_PORT || 3001);
|
const PORT = Number(process.env.ADMIN_PORT || 3001);
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||||
@@ -133,6 +135,41 @@ app.post("/api/admin/import", requireAuth, async (_req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Medya (Openinary proxy, mucomutfak klasörü) ----
|
||||||
|
|
||||||
|
app.get("/api/admin/media", requireAuth, async (req, res) => {
|
||||||
|
if (!mediaEnabled())
|
||||||
|
return res.status(503).json({ error: "Medya servisi yapılandırılmamış" });
|
||||||
|
try {
|
||||||
|
res.json(await listMedia(String(req.query.path || MEDIA_ROOT)));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(e.status || 500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Multipart body Openinary'ye stream edilir; express.json bu content-type'a dokunmaz
|
||||||
|
app.post("/api/admin/media/upload", requireAuth, async (req, res) => {
|
||||||
|
if (!mediaEnabled())
|
||||||
|
return res.status(503).json({ error: "Medya servisi yapılandırılmamış" });
|
||||||
|
try {
|
||||||
|
const { status, body } = await proxyUpload(req);
|
||||||
|
res.status(status).json(body);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/admin/media", requireAuth, async (req, res) => {
|
||||||
|
if (!mediaEnabled())
|
||||||
|
return res.status(503).json({ error: "Medya servisi yapılandırılmamış" });
|
||||||
|
try {
|
||||||
|
await deleteMedia(String(req.query.path || ""));
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(e.status || 500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Publish: DB -> JSON -> vite-ssg build -> dist swap (runs in background)
|
// Publish: DB -> JSON -> vite-ssg build -> dist swap (runs in background)
|
||||||
app.post("/api/admin/publish", requireAuth, (_req, res) => {
|
app.post("/api/admin/publish", requireAuth, (_req, res) => {
|
||||||
const started = startPublish();
|
const started = startPublish();
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// server/media.mjs — Openinary (media.ayris.tech) proxy, "mucomutfak" klasörüne kilitli
|
||||||
|
const BASE = () => process.env.OPENINARY_API_URL?.replace(/\/$/, "");
|
||||||
|
const KEY = () => process.env.OPENINARY_API_KEY;
|
||||||
|
export const MEDIA_ROOT = "mucomutfak";
|
||||||
|
|
||||||
|
export function mediaEnabled() {
|
||||||
|
return Boolean(BASE() && KEY());
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInsideRoot(p) {
|
||||||
|
const clean = String(p || "").replace(/^\/+|\/+$/g, "");
|
||||||
|
if (
|
||||||
|
clean !== MEDIA_ROOT &&
|
||||||
|
!clean.startsWith(`${MEDIA_ROOT}/`)
|
||||||
|
) {
|
||||||
|
throw Object.assign(new Error(`Yol '${MEDIA_ROOT}' klasörü içinde olmalı`), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clean.includes("..")) {
|
||||||
|
throw Object.assign(new Error("Geçersiz yol"), { status: 400 });
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryUrl(path, transform = "") {
|
||||||
|
const t = transform ? `${transform}/` : "";
|
||||||
|
return `${BASE()}/t/${t}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klasör içeriğini listele
|
||||||
|
export async function listMedia(path = MEDIA_ROOT) {
|
||||||
|
const clean = assertInsideRoot(path);
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE()}/api/storage?path=${encodeURIComponent(clean)}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${KEY()}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw Object.assign(new Error(`Openinary listeleme hatası (${res.status})`), {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const IMG_RE = /\.(webp|jpe?g|png|gif|svg|avif|heic|heif)$/i;
|
||||||
|
return {
|
||||||
|
path: clean,
|
||||||
|
folders: (data.folders || []).map((f) => ({ name: f.name, path: f.path })),
|
||||||
|
files: (data.files || [])
|
||||||
|
.filter((f) => IMG_RE.test(f.name))
|
||||||
|
.map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
path: f.path,
|
||||||
|
size: f.size,
|
||||||
|
mtime: f.mtime,
|
||||||
|
url: deliveryUrl(f.path),
|
||||||
|
thumb: deliveryUrl(f.path, "w_240,h_240,c_fill"),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multipart isteği olduğu gibi Openinary'ye aktar (folder alanı client FormData'sında)
|
||||||
|
export async function proxyUpload(req) {
|
||||||
|
const res = await fetch(`${BASE()}/api/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${KEY()}`,
|
||||||
|
"content-type": req.headers["content-type"],
|
||||||
|
...(req.headers["content-length"]
|
||||||
|
? { "content-length": req.headers["content-length"] }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
body: req,
|
||||||
|
duplex: "half",
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (body?.files) {
|
||||||
|
for (const f of body.files) {
|
||||||
|
if (f.path) {
|
||||||
|
f.url = deliveryUrl(f.path);
|
||||||
|
f.thumb = deliveryUrl(f.path, "w_240,h_240,c_fill");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: res.status, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMedia(path) {
|
||||||
|
const clean = assertInsideRoot(path);
|
||||||
|
if (clean === MEDIA_ROOT) {
|
||||||
|
throw Object.assign(new Error("Kök klasör silinemez"), { status: 400 });
|
||||||
|
}
|
||||||
|
const res = await fetch(
|
||||||
|
`${BASE()}/api/storage/${clean
|
||||||
|
.split("/")
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join("/")}`,
|
||||||
|
{ method: "DELETE", headers: { Authorization: `Bearer ${KEY()}` } },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw Object.assign(new Error(`Silme hatası (${res.status})`), {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -153,9 +153,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, h } from "vue";
|
import { computed, h } from "vue";
|
||||||
|
import { useMediaPicker } from "@/composables/useMediaPicker";
|
||||||
|
|
||||||
defineOptions({ name: "AdminField" });
|
defineOptions({ name: "AdminField" });
|
||||||
|
|
||||||
|
const mediaPicker = useMediaPicker();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
parent: any;
|
parent: any;
|
||||||
field: string | number;
|
field: string | number;
|
||||||
@@ -318,7 +321,8 @@ const isReadonly = computed(
|
|||||||
function stringKind(s: string): "date" | "image" | "long" | "bool" | "text" {
|
function stringKind(s: string): "date" | "image" | "long" | "bool" | "text" {
|
||||||
if (/^(true|false)$/i.test(s)) return "bool";
|
if (/^(true|false)$/i.test(s)) return "bool";
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return "date";
|
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";
|
if (s.length > 90 || s.includes("\n")) return "long";
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
@@ -403,6 +407,19 @@ const renderPrimitive = () => {
|
|||||||
class: inputCls + " font-mono text-xs",
|
class: inputCls + " font-mono text-xs",
|
||||||
onInput: (e: Event) => set((e.target as HTMLInputElement).value),
|
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",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<div v-else class="flex min-h-dvh">
|
<AdminMediaPicker v-if="token" />
|
||||||
|
<div v-if="token" class="flex min-h-dvh">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside
|
<aside
|
||||||
class="w-64 shrink-0 bg-white border-r border-gray-200 flex flex-col"
|
class="w-64 shrink-0 bg-white border-r border-gray-200 flex flex-col"
|
||||||
|
|||||||
Reference in New Issue
Block a user