feat: use portainer hook instead of pushing image to gitea package repository
This commit is contained in:
69
app/components/FilesTable.vue
Normal file
69
app/components/FilesTable.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumn } from "@nuxt/ui";
|
||||
|
||||
const UButton = resolveComponent("UButton");
|
||||
|
||||
defineProps<{
|
||||
data: FileEntry[];
|
||||
status: "pending" | "idle" | "success" | "error";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [file: FileEntry];
|
||||
delete: [file: FileEntry];
|
||||
}>();
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatDate = (iso: string): string =>
|
||||
new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
|
||||
const columns: TableColumn<FileEntry>[] = [
|
||||
{ accessorKey: "name", header: "Name" },
|
||||
{
|
||||
accessorKey: "size",
|
||||
header: "Size",
|
||||
cell: ({ row }) => formatSize(row.original.size),
|
||||
},
|
||||
{
|
||||
accessorKey: "modifiedAt",
|
||||
header: "Modified",
|
||||
cell: ({ row }) => formatDate(row.original.modifiedAt),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) =>
|
||||
h("div", { class: "flex justify-end gap-1" }, [
|
||||
h(UButton, {
|
||||
icon: "i-lucide-pencil",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => emit("rename", row.original),
|
||||
}),
|
||||
h(UButton, {
|
||||
icon: "i-lucide-trash-2",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
color: "error",
|
||||
onClick: () => emit("delete", row.original),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:loading="status === 'pending' || status === 'idle'"
|
||||
>
|
||||
<template #empty>
|
||||
{{ status === "pending" || status === "idle" ? "Loading..." : "No files" }}
|
||||
</template>
|
||||
</UTable>
|
||||
</template>
|
||||
59
app/components/RenameFileModal.vue
Normal file
59
app/components/RenameFileModal.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from "zod";
|
||||
import type { FormSubmitEvent } from "@nuxt/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string;
|
||||
}>();
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string({ error: "Required" })
|
||||
.min(1)
|
||||
.regex(/^[^/\\]+$/, "Invalid filename"),
|
||||
});
|
||||
|
||||
type Schema = z.output<typeof schema>;
|
||||
|
||||
const emit = defineEmits<{ close: [value: boolean] }>();
|
||||
|
||||
const toast = useToast();
|
||||
const submitting = ref(false);
|
||||
|
||||
const state = reactive<Partial<Schema>>({
|
||||
name: props.filename,
|
||||
});
|
||||
|
||||
const onSubmit = async (event: FormSubmitEvent<Schema>) => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
await $fetch(`/api/files/${encodeURIComponent(props.filename)}`, {
|
||||
method: "PATCH",
|
||||
body: event.data,
|
||||
});
|
||||
toast.add({ title: "File renamed", color: "success" });
|
||||
emit("close", true);
|
||||
} catch (error) {
|
||||
toast.add({ title: getApiError(error), color: "error" });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal title="Rename file">
|
||||
<template #body>
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormField label="Name" name="name">
|
||||
<UInput v-model="state.name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<UButton variant="ghost" @click="emit('close', false)">Cancel</UButton>
|
||||
<UButton type="submit" variant="subtle" :loading="submitting" icon="i-lucide-save">Save</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { LazyLinkModal, LazyConfirmModal } from "#components";
|
||||
import { LazyLinkModal, LazyConfirmModal, LazyRenameFileModal } from "#components";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const toast = useToast();
|
||||
const overlay = useOverlay();
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const { fetch: fetchSession } = useUserSession();
|
||||
|
||||
@@ -14,10 +15,13 @@ const signOut = async () => {
|
||||
await navigateTo("/auth/sign-in");
|
||||
};
|
||||
|
||||
const { data: links, status, refresh } = useLazyFetch("/api/links", { key: "links", server: false, });
|
||||
const { data: links, status: linksStatus, refresh: refreshLinks } = useLazyFetch("/api/links", { key: "links", server: false });
|
||||
const { data: files, status: filesStatus, refresh: refreshFiles } = useLazyFetch("/api/files", { key: "files", server: false });
|
||||
|
||||
const route = useRoute();
|
||||
const section = computed(() => route.query.section === "files" ? "files" : "links");
|
||||
const category = computed(() => {
|
||||
if (section.value !== "links") return "all";
|
||||
if (route.query.filter === "active") return "active";
|
||||
if (route.query.filter === "disabled") return "disabled";
|
||||
return "all";
|
||||
@@ -28,7 +32,9 @@ const categoryTitle = computed(() => {
|
||||
if (category.value === "disabled") return "Disabled links";
|
||||
return "All links";
|
||||
});
|
||||
useHead({ title: categoryTitle });
|
||||
|
||||
const pageTitle = computed(() => section.value === "files" ? "Files" : categoryTitle.value);
|
||||
useHead({ title: pageTitle });
|
||||
|
||||
const filteredLinks = computed(() => {
|
||||
if (!links.value) return [];
|
||||
@@ -37,11 +43,12 @@ const filteredLinks = computed(() => {
|
||||
return links.value;
|
||||
});
|
||||
|
||||
// Link actions
|
||||
const openModal = async (link: Link | null) => {
|
||||
const modal = overlay.create(LazyLinkModal, { destroyOnClose: true });
|
||||
const instance = modal.open({ link });
|
||||
if (await instance.result) await refresh();
|
||||
}
|
||||
if (await instance.result) await refreshLinks();
|
||||
};
|
||||
|
||||
const deleteLink = async (link: Link) => {
|
||||
const modal = overlay.create(LazyConfirmModal, { destroyOnClose: true });
|
||||
@@ -51,12 +58,55 @@ const deleteLink = async (link: Link) => {
|
||||
try {
|
||||
await $fetch(`/api/links/${link.id}`, { method: "DELETE" });
|
||||
toast.add({ title: "Link deleted", color: "success" });
|
||||
await refresh();
|
||||
await refreshLinks();
|
||||
} catch (error) {
|
||||
toast.add({ title: getApiError(error), color: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
// File actions
|
||||
const uploading = ref(false);
|
||||
|
||||
const handleUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
for (const file of Array.from(input.files)) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
await $fetch("/api/files", { method: "POST", body: form });
|
||||
}
|
||||
toast.add({ title: `${input.files.length === 1 ? "File" : "Files"} uploaded`, color: "success" });
|
||||
await refreshFiles();
|
||||
} catch (error) {
|
||||
toast.add({ title: getApiError(error), color: "error" });
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
input.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renameFile = async (file: FileEntry) => {
|
||||
const modal = overlay.create(LazyRenameFileModal, { destroyOnClose: true });
|
||||
const instance = modal.open({ filename: file.name });
|
||||
if (await instance.result) await refreshFiles();
|
||||
};
|
||||
|
||||
const deleteFile = async (file: FileEntry) => {
|
||||
const modal = overlay.create(LazyConfirmModal, { destroyOnClose: true });
|
||||
const instance = modal.open({ title: "Delete file", description: `Are you sure you want to delete "${file.name}"?` });
|
||||
if (!await instance.result) return;
|
||||
|
||||
try {
|
||||
await $fetch(`/api/files/${encodeURIComponent(file.name)}`, { method: "DELETE" });
|
||||
toast.add({ title: "File deleted", color: "success" });
|
||||
await refreshFiles();
|
||||
} catch (error) {
|
||||
toast.add({ title: getApiError(error), color: "error" });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,42 +116,60 @@ const deleteLink = async (link: Link) => {
|
||||
<span class="font-semibold text-highlighted text-lg">pihka.al</span>
|
||||
</template>
|
||||
|
||||
<UNavigationMenu
|
||||
orientation="vertical"
|
||||
highlight
|
||||
:items="[
|
||||
{
|
||||
label: 'All',
|
||||
icon: 'i-lucide-link',
|
||||
badge: links?.length ?? 0,
|
||||
to: '/dashboard',
|
||||
active: category === 'all',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
icon: 'i-lucide-link-2',
|
||||
badge: links?.filter((l) => !l.disabled).length ?? 0,
|
||||
to: '/dashboard?filter=active',
|
||||
active: category === 'active',
|
||||
},
|
||||
{
|
||||
label: 'Disabled',
|
||||
icon: 'i-lucide-link-2-off',
|
||||
badge: links?.filter((l) => l.disabled).length ?? 0,
|
||||
to: '/dashboard?filter=disabled',
|
||||
active: category === 'disabled',
|
||||
},
|
||||
]"
|
||||
class="px-2"
|
||||
/>
|
||||
<div class="px-2 space-y-2">
|
||||
<UNavigationMenu
|
||||
orientation="vertical"
|
||||
highlight
|
||||
:items="[
|
||||
{
|
||||
label: 'All',
|
||||
icon: 'i-lucide-link',
|
||||
badge: links?.length ?? 0,
|
||||
to: '/dashboard',
|
||||
active: section === 'links' && category === 'all',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
icon: 'i-lucide-link-2',
|
||||
badge: links?.filter((l) => !l.disabled).length ?? 0,
|
||||
to: '/dashboard?filter=active',
|
||||
active: section === 'links' && category === 'active',
|
||||
},
|
||||
{
|
||||
label: 'Disabled',
|
||||
icon: 'i-lucide-link-2-off',
|
||||
badge: links?.filter((l) => l.disabled).length ?? 0,
|
||||
to: '/dashboard?filter=disabled',
|
||||
active: section === 'links' && category === 'disabled',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<UNavigationMenu
|
||||
orientation="vertical"
|
||||
highlight
|
||||
:items="[
|
||||
{
|
||||
label: 'Files',
|
||||
icon: 'i-lucide-folder',
|
||||
badge: files?.length ?? 0,
|
||||
to: '/dashboard?section=files',
|
||||
active: section === 'files',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton variant="link" icon="i-lucide-log-out" block @click="signOut">Sign out</UButton>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
<UDashboardPanel>
|
||||
<UDashboardPanel v-if="section === 'links'">
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="category === 'all' ? 'All links' : category === 'active' ? 'Active links' : 'Disabled links'">
|
||||
<UDashboardNavbar :title="categoryTitle">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-plus" variant="subtle" @click="openModal(null)">New link</UButton>
|
||||
</template>
|
||||
@@ -111,11 +179,31 @@ const deleteLink = async (link: Link) => {
|
||||
<template #body>
|
||||
<LinksTable
|
||||
:data="filteredLinks"
|
||||
:status="status"
|
||||
:status="linksStatus"
|
||||
@edit="openModal"
|
||||
@delete="deleteLink"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
|
||||
<UDashboardPanel v-else>
|
||||
<template #header>
|
||||
<UDashboardNavbar title="Files">
|
||||
<template #right>
|
||||
<input ref="fileInput" type="file" class="hidden" multiple @change="handleUpload" />
|
||||
<UButton icon="i-lucide-upload" variant="subtle" :loading="uploading" @click="(fileInput as HTMLInputElement)?.click()">Upload</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<FilesTable
|
||||
:data="files ?? []"
|
||||
:status="filesStatus"
|
||||
@rename="renameFile"
|
||||
@delete="deleteFile"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import type { InternalApi } from "nitropack/types";
|
||||
|
||||
export type Link = InternalApi["/api/links"]["get"][number];
|
||||
export type FileEntry = { name: string; size: number; modifiedAt: string };
|
||||
|
||||
const apiErrorSchema = z.object({
|
||||
data: z.object({
|
||||
|
||||
Reference in New Issue
Block a user