frontend/src/routes/admin/categories/+page.svelte
<script lang="ts">
import type { Category, NewCategory } from '$lib/api';
import { api } from '$lib/config/config';
import { categoriesApi } from '$lib/requests/requests';
import ConfirmationPopup from '$lib/components/confirmationPopup.svelte';
import { onMount } from 'svelte';
let categories: Category[] = [];
let selectedCategories: Category[] = [];
let newCategory: NewCategory = {
name: '',
picture: '',
position: 0
};
let page = 0;
let categoriesPerPage = 10;
let deletingCategory: boolean = false;
let confirmationMessage: string | undefined = undefined;
let deleteCategoryCallback: VoidFunction = () => {};
onMount(() => {
categoriesApi()
.getCategories(true, { withCredentials: true })
.then((res) => {
categories = res.data ?? [];
});
});
function createNewCategory() {
if (!newCategory) return;
categoriesApi()
.postCategory(newCategory, { withCredentials: true })
.then((res) => {
categories = [...categories, res.data];
});
}
function renameCategory(id: string, newName: string) {
if (!newCategory) return;
categoriesApi()
.patchCategory(id, { name: newName }, { withCredentials: true })
.then((res) => {
categories = categories.map((ct) => {
if (ct.id === id) {
ct.name = newName;
}
return ct;
});
});
}
function toggleHidden(id: string, state: boolean) {
if (!newCategory) return;
categoriesApi()
.patchCategory(id, { hidden: state }, { withCredentials: true })
.then((res) => {
categories = categories.map((ct) => {
if (ct.id === id) {
ct.hidden = state;
}
return ct;
});
});
}
function reuploadCategoryPicture(id: string, file: File) {
if (!newCategory) return;
file2Base64(file).then((base64) => {
base64 = base64.replace('data:', '').replace(/^.+,/, '');
categoriesApi()
.patchCategory(id, { picture: base64 }, { withCredentials: true })
.then((res) => {
categories = categories.map((ct) => {
if (ct.id === id) {
ct.picture_uri = res.data.picture_uri + '?' + Math.random();
}
return ct;
});
});
});
}
function deleteCategory(id: string) {
categoriesApi()
.markDeleteCategory(id, { withCredentials: true })
.then(() => {
categories = categories.filter((ct) => ct.id !== id);
});
}
const file2Base64 = (file: File): Promise<string> => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result?.toString() || '');
reader.onerror = (error) => reject(error);
});
};
</script>
<!-- Popup -->
<div
id="hs-modal-new-image"
class="hs-overlay hidden w-full h-full fixed top-0 left-0 z-[60] overflow-x-hidden overflow-y-auto"
>
<div
class="hs-overlay-open:mt-7 hs-overlay-open:opacity-100 hs-overlay-open:duration-500 mt-0 opacity-0 ease-out transition-all sm:max-w-lg sm:w-full m-3 sm:mx-auto"
>
<div
class="bg-white border border-gray-200 rounded-xl shadow-sm dark:bg-gray-800 dark:border-gray-700"
>
<div class="p-4 sm:p-7">
<div class="text-center">
<h2 class="block text-2xl font-bold text-gray-800 dark:text-gray-200">
Ajouter une catégorie
</h2>
</div>
<div class="mt-5">
<!-- Form -->
<div class="grid gap-y-4">
<!-- Form Group -->
<div>
<!-- name -->
<label for="name" class="block text-sm mb-2 dark:text-white">Nom</label>
<div class="relative">
<input
type="text"
id="name"
name="name"
placeholder="Nom de la catégorie"
class="py-3 px-4 block w-full border-gray-200 border-2 rounded-md text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400"
required
aria-describedby="text-error"
bind:value={newCategory.name}
/>
</div>
<label for="image" class="block text-sm mb-2 dark:text-white">Image</label>
<div class="relative">
<input
type="file"
id="image"
name="image"
accept=".jpg, .jpeg, .png"
class="py-3 px-4 block w-full border-gray-200 border-2 rounded-md text-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400"
required
aria-describedby="text-error"
on:change={(e) => {
// @ts-ignore
let file = e.target?.files[0];
file2Base64(file).then((res) => {
res = res.replace('data:', '').replace(/^.+,/, '');
newCategory.picture = res;
});
}}
/>
</div>
<button
type="submit"
class="mt-4 py-3 px-4 inline-flex justify-center items-center gap-2 rounded-md border border-transparent font-semibold bg-blue-500 text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all text-sm dark:focus:ring-offset-gray-800"
on:click={() => createNewCategory()}
data-hs-overlay="#hs-modal-new-image">Créer</button
>
</div>
</div>
<!-- End Form -->
</div>
</div>
</div>
</div>
</div>
{#if deletingCategory}
<ConfirmationPopup
message={confirmationMessage}
confirm_text="Supprimer"
cancel_callback={() => {
deletingCategory = false;
}}
confirm_callback={deleteCategoryCallback}
/>
{/if}
<!-- Table Section -->
<div class="max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 mx-auto">
<!-- Card -->
<div class="flex flex-col">
<div class="-m-1.5 overflow-x-auto">
<div class="p-1.5 min-w-full inline-block align-middle">
<div
class="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden dark:bg-slate-900 dark:border-gray-700"
>
<!-- Header -->
<div
class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-b border-gray-200 dark:border-gray-700"
>
<div>
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Catégories</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">Ajouter des catégories</p>
</div>
<div>
<div class="inline-flex gap-x-2">
<button
class="py-2 px-3 inline-flex justify-center items-center gap-2 rounded-md border border-transparent font-semibold bg-blue-500 text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all text-sm dark:focus:ring-offset-gray-800"
data-hs-overlay="#hs-modal-new-image"
>
<svg
class="w-3 h-3"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M2.63452 7.50001L13.6345 7.5M8.13452 13V2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
Ajouter une catégorie
</button>
</div>
</div>
</div>
<!-- End Header -->
<!-- Table -->
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-slate-800">
<tr>
<th scope="col" class="px-6 py-3 text-left">
<div class="flex items-center gap-x-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-gray-200"
>
Nom
</span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left">
<div class="flex items-center gap-x-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-gray-200"
>
Image
</span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-left">
<div class="flex items-center gap-x-2">
<span
class="text-xs font-semibold uppercase tracking-wide text-gray-800 dark:text-gray-200"
>
Cachée
</span>
</div>
</th>
<th scope="col" class="px-6 py-3 text-right" />
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each categories.slice(page * categoriesPerPage, (page + 1) * categoriesPerPage) as category}
<tr>
<td class="h-px w-72">
<div class="px-6 py-3">
<!-- <p class="block text-sm text-gray-500 break-words">{category.name}</p> -->
<!-- editable p -->
<input
type="text"
class="block text-sm dark:text-white/[.8] break-words p-2 bg-transparent"
value={category.name}
on:input={(e) => {
// @ts-ignore
let name = e.target?.value;
renameCategory(category.id, name);
}}
/>
</div>
</td>
<td class="h-px w-72">
<!-- Display a miniature of the image -->
<div class="px-6 py-3 w-24 relative">
<!-- <img
src={api() + category.picture_uri}
alt="indisponible"
class="w-full h-full rounded-md object-cover"
/> -->
<!-- input in front of the image to click & reupload -->
<input
type="file"
class="absolute w-[50%] h-[70%] opacity-0 cursor-pointer"
on:change={(e) => {
// @ts-ignore
let file = e.target?.files[0];
reuploadCategoryPicture(category.id, file);
}}
/>
{#if category.picture_uri != ''}
<img
src={api() + category.picture_uri}
alt="indisponible"
class="w-full h-full rounded-md object-cover"
/>
{/if}
</div>
</td>
<td class="h-px w-px whitespace-nowrap">
<div class="px-6 py-1.5">
<input
type="checkbox"
checked={category.hidden}
class="inline-flex items-center gap-x-1.5 text-sm text-blue-600 decoration-2 hover:underline font-medium"
on:change={() => toggleHidden(category.id, !category.hidden)}
/>
</div>
</td>
<td class="h-px w-px whitespace-nowrap">
<div class="px-6 py-1.5">
<button
class="inline-flex items-center gap-x-1.5 text-sm text-blue-600 decoration-2 hover:underline font-medium"
on:click={() => {
deleteCategoryCallback = () => {
deletingCategory = false;
deleteCategory(category.id)
}
confirmationMessage = "Supprimer '" + category.name + "' ?";
deletingCategory = true;
}}
>
Supprimer
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- End Table -->
<!-- Footer -->
<div
class="px-6 py-4 grid gap-3 md:flex md:justify-between md:items-center border-t border-gray-200 dark:border-gray-700"
>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">
<span class="font-semibold text-gray-800 dark:text-gray-200"
>{categories.length}</span
> résultats
</p>
</div>
<div>
<div class="inline-flex gap-x-2">
<button
type="button"
class="py-2 px-3 inline-flex justify-center items-center gap-2 rounded-md border font-medium bg-white text-gray-700 shadow-sm align-middle hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white focus:ring-blue-600 transition-all text-sm dark:bg-slate-900 dark:hover:bg-slate-800 dark:border-gray-700 dark:text-gray-400 dark:hover:text-white dark:focus:ring-offset-gray-800"
on:click={() => {
if (page > 0) page--;
}}
>
<svg
class="w-3 h-3"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"
/>
</svg>
Précédent
</button>
<p class="text-sm self-center text-gray-600 dark:text-gray-400">
Page {page + 1} / {Math.ceil(categories.length / categoriesPerPage)}
</p>
<button
type="button"
class="py-2 px-3 inline-flex justify-center items-center gap-2 rounded-md border font-medium bg-white text-gray-700 shadow-sm align-middle hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white focus:ring-blue-600 transition-all text-sm dark:bg-slate-900 dark:hover:bg-slate-800 dark:border-gray-700 dark:text-gray-400 dark:hover:text-white dark:focus:ring-offset-gray-800"
on:click={() => {
if (page < Math.ceil(categories.length / categoriesPerPage) - 1) page++;
}}
>
Suivant
<svg
class="w-3 h-3"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path
fill-rule="evenodd"
d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- End Footer -->
</div>
</div>
</div>
</div>
<!-- End Card -->
</div>
<!-- End Table Section -->