Transferred and translated the frontend code from L10 to L12
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
2025-05-05 19:01:27 +02:00
parent 98205954c3
commit e15d3ae146
96 changed files with 6398 additions and 701 deletions
@@ -0,0 +1,250 @@
<script setup>
import { Head, Link, usePage, useForm } from '@inertiajs/vue3';
import { v4 as uuidv4 } from 'uuid';
import { ref, onMounted } from 'vue';
import { initFlowbite } from 'flowbite';
import blocks from '@/Utils/blocks';
import PageBuilderLayout from '@/layouts/PageBuilderLayout.vue';
import BlockGroup from '@/Components/Blocks/BlockGroup.vue';
import BlockWrapper from '@/Components/Blocks/BlockWrapper.vue';
import AppLogo from '@/Components/PageBuilderLogo.vue';
import InputError from '@/Components/InputError.vue';
const groups = blocks.groups;
const draft = ref([]);
const page = usePage();
onMounted(() => {
initFlowbite();
});
const { builtPages, images } = defineProps({
builtPages: {
type: Object
},
images: {
type: Array,
default: () => ([]),
},
});
const cloneBlock = (block) => {
block.uuid = uuidv4();
return block;
};
const deleteBlock = (index) => {
draft.value.splice(index, 1);
};
function handleDrop(event) {
const data = event.dataTransfer.getData('application/json');
if (data) {
try {
const block = JSON.parse(data);
block.uuid = uuidv4();
draft.value.push(block);
console.log('📦 Dropped block:', block);
} catch (e) {
console.error("Failed to parse dropped block", e);
}
}
};
// Drag and drop
const draggingIndex = ref(null)
function handleDragStart(event, index) {
draggingIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index);
};
function handleDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
function handleDropReorder(event, dropIndex) {
event.preventDefault();
const fromIndex = draggingIndex.value;
if (fromIndex === null || fromIndex === dropIndex) return;
const block = draft.value.splice(fromIndex, 1)[0];
draft.value.splice(dropIndex, 0, block);
draggingIndex.value = null;
};
// Save form
const pageForm = useForm({
title: '',
content: '',
publish: false,
mainpage: false,
linked: false,
linkorder: 0,
visibility: 'public'
});
const savePage = () => {
const dropdown = document.getElementById('dropdownInformation');
if (dropdown) dropdown.classList.add('hidden');
pageForm.content = JSON.stringify(draft.value);
pageForm.post(route('page-builder.landing-page.store'), {
preserveScroll: true,
onSuccess: () => {
// onSuccess, the user is redirected to the index page for the time being, so toast handling has to be implemented there
},
onError: () => {
console.warn("Valideringsfeil ved lagring.")
}
});
};
</script>
<template>
<Head title="Ny Side" />
<PageBuilderLayout>
<template #header>
<AppLogo />
<h3 class="text-lg font-semibold">Ny side</h3>
<div>
<button id="dropdownInformationButton" data-dropdown-toggle="dropdownInformation" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" type="button">
Handlinger
<svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<!-- Dropdown menu -->
<div id="dropdownInformation" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<div class="px-4 py-3 text-sm text-gray-900 dark:text-white">
<div>{{ page.props.auth.user.name }}</div>
<div class="font-medium truncate">{{ page.props.auth.user.email }}</div>
</div>
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownInformationButton">
<li>
<a
href="#"
@click.prevent="savePage"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
Lagre side
</a>
</li>
</ul>
<div class="py-2">
<Link :href="route('page-builder.index')" class="block px-4 py-2 btn btn-sm btn-ghost hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
Lukk
</Link>
</div>
</div>
</div>
</template>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar with form & block groups -->
<div class="bg-slate-100 overflow-y-auto w-1/6 p-4 space-y-4">
<div class="space-y-4 p-4 rounded border dark:border-gray-600 bg-gray-50 dark:bg-gray-800">
<div class="mb-4">
<label
for="page_title-text"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Sidetittel
</label>
<input
type="text"
id="page_title-text"
aria-describedby="page_title-text-explanation"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
placeholder="Skriv inn en tittel"
v-model="pageForm.title"
>
<InputError :message="pageForm.errors.title" class="mt-2" />
<p id="page_title-text-explanation" class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Tittelen på siden vil være synlig i fanearket i nettleseren hos besøkende, samt brukes til å generere URL (slug)
</p>
</div>
<div class="mb-4">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="pageForm.linked" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Menylenke</span>
</label>
</div>
<div class="mb-4">
<label for="steps-range" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Menylenke rekkefølge</label>
<input id="steps-range" :disabled="!pageForm.linked" v-model="pageForm.linkorder" type="range" min="0" max="20" step="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700">
<span class="text-xs">Prioritet: {{ pageForm.linkorder }} (høyere verdi kommer først)</span>
</div>
<div class="mb-4">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="pageForm.publish" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Publisert</span>
</label>
</div>
<div class="mb-4">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="pageForm.mainpage" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Sett som hovedside</span>
</label>
</div>
<div>
<div class="flex items-center mb-4">
<input :checked="pageForm.visibility === 'public'" id="page_state-radio-1" v-model="pageForm.visibility" type="radio" value="public" name="page_state-radio" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="page_state-radio-1" class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Synlig for alle</label>
</div>
<div class="flex items-center">
<input :checked="pageForm.visibility === 'private'" id="page_state-radio-2" v-model="pageForm.visibility" type="radio" value="private" name="page_state-radio" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="page_state-radio-2" class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Bare synlig for medlemmer</label>
</div>
</div>
</div>
<BlockGroup
v-for="group in groups"
:key="group.uuid"
:title="group.title"
:blocks="group.blocks"
/>
</div>
<!-- Builder canvas -->
<div
class="bg-white dark:bg-gray-800 w-5/6 p-6 overflow-y-auto"
@dragover.prevent
@drop="handleDrop"
>
<div v-if="draft.length === 0" class="text-gray-500 text-sm italic">
Dra inn blokker fra venstre for å begynne.
</div>
<div
v-for="(block, index) in draft"
:key="block.uuid"
class="mb-4"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver"
@drop="handleDropReorder($event, index)"
>
<BlockWrapper :images="images" :block="block" @delete="() => deleteBlock(index)" />
</div>
</div>
</div>
</PageBuilderLayout>
</template>
<style scoped>
/* scoped styles for the create screen */
</style>
@@ -0,0 +1,623 @@
<script setup>
import { Head, Link, usePage, useForm, router } from '@inertiajs/vue3';
import { v4 as uuidv4 } from 'uuid';
import { ref, onMounted, watch, nextTick } from 'vue';
import { initFlowbite } from 'flowbite';
import { showToast } from '@/Composables/useToast.js';
import blocks from '@/Utils/blocks';
import PageBuilderLayout from '@/layouts/PageBuilderLayout.vue';
import BlockGroup from '@/Components/Blocks/BlockGroup.vue';
import BlockWrapper from '@/Components/Blocks/BlockWrapper.vue';
import AppLogo from '@/Components/PageBuilderLogo.vue';
import InputError from '@/Components/InputError.vue';
import Modal from '@/Components/Modal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue';
import TextInput from '@/Components/TextInput.vue';
let initialContent = revision.content;
if (typeof initialContent === 'string') {
try {
initialContent = JSON.parse(initialContent);
} catch (e) {
console.warn('Failed to parse revision.content:', e);
initialContent = [];
}
}
const groups = blocks.groups;
const draft = ref(initialContent);
const isDirty = ref(false);
const isDrawerOpen = ref(false);
const page = usePage();
const autoSaveFunction = ref(true);
const autosaveInterval = 30000;
onMounted(() => {
initFlowbite();
// Init auto save
setInterval(() => {
if (isDirty.value && !isDrawerOpen.value) {
autoSaveRevision();
}
}, autosaveInterval);
});
const {
revision,
images,
revisions
} = defineProps({
revision: { type: Object, required: true },
images: { type: Array, default: () => ([]) },
revisions: { type: Object, required: true }
});
// Auto-save function
const autoSaveRevision = () => {
if (autoSaveFunction.value) {
isDirty.value = false;
const payload = {
revision_uuid: revision.uuid,
title: pageForm.title,
content: JSON.stringify(draft.value),
publish: pageForm.publish,
mainpage: pageForm.mainpage,
linked: pageForm.linked,
linkorder: pageForm.linkorder,
visibility: pageForm.visibility,
autosave: true
};
router.patch(route('page-builder.landing-page.patch'), payload, {
preserveScroll: true,
preserveState: false,
onSuccess: () => {
showToast('info', 'Endringer lagret automatisk.');
},
onError: () => {
showToast('danger', 'Automatisk lagring misslykkes ...');
}
});
}
};
const cloneBlock = (block) => {
block.uuid = uuidv4();
return block;
};
const deleteBlock = (index) => {
draft.value.splice(index, 1);
};
function handleDrop(event) {
const data = event.dataTransfer.getData('application/json');
if (data) {
try {
const block = JSON.parse(data);
block.uuid = uuidv4();
draft.value.push(block);
console.log('📦 Dropped block:', block);
} catch (e) {
console.error("Failed to parse dropped block", e);
}
}
};
// Drag and drop
const draggingIndex = ref(null)
function handleDragStart(event, index) {
draggingIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index);
};
function handleDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
function handleDropReorder(event, dropIndex) {
event.preventDefault();
const fromIndex = draggingIndex.value;
if (fromIndex === null || fromIndex === dropIndex) return;
const block = draft.value.splice(fromIndex, 1)[0];
draft.value.splice(dropIndex, 0, block);
draggingIndex.value = null;
};
// Save form
const pageForm = useForm({
revision_uuid: revision.uuid,
title: revision.title || '',
content: '',
publish: revision.page.is_published,
mainpage: revision.page.main,
linked: revision.page.linked,
linkorder: revision.page.linkorder,
visibility: revision.page.visibility || 'public'
});
const savePage = () => {
isDirty.value = false;
const dropdown = document.getElementById('dropdownInformation');
if (dropdown) dropdown.classList.add('hidden');
pageForm.content = JSON.stringify(draft.value);
pageForm.patch(route('page-builder.landing-page.patch'), {
preserveScroll: true,
onSuccess: () => {
// onSuccess, the user is redirected to the index page for the time being, so toast handling has to be implemented there
// Edit: Toast handling is now handled in the PageBuilder layout through it's own component
},
onError: () => {
console.warn("Valideringsfeil ved lagring.")
}
});
};
/**
* formatDate
*
* Formats a date string to a Norwegian date format.
*
* @param {
* } dateString
*/
const formatDate = (dateString) => {
const date = new Date(dateString);
const day = ('0' + date.getDate()).slice(-2);
const month = ('0' + (date.getMonth() + 1)).slice(-2);
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
/**
* dateString
*
* Formats a date string to a Norwegian time format.
* @param {*} dateString
*/
const formatTime = (dateString) => {
const date = new Date(dateString);
return date.toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
};
// Watch the document for any changes to prevent autosaving if no changes are made
watch([draft, pageForm], () => {
if (autoSaveFunction.value) {
isDirty.value = true;
}
}, { deep: true });
// Save as routines and modal handling
const saveAsModal = ref(false);
const saveAsTitle = ref(null);
const saveAsForm = useForm({
page_id: revision.page.id,
title: revision.title || '',
content: '',
publish: revision.page.is_published,
mainpage: revision.page.main,
linked: revision.page.linked,
linkorder: revision.page.linkorder,
visibility: revision.page.visibility || 'public'
});
const opensaveAsModal = () => {
const dropdown = document.getElementById('dropdownInformation');
if (dropdown) dropdown.classList.add('hidden');
saveAsModal.value = true;
isDrawerOpen.value = true; // Don't autosave while modal is open
nextTick(() => { // Make sure page is rendered, code inside the tick selects the text in the input field.
if (saveAsTitle.value) {
const inputElement = saveAsTitle.value.$el;
inputElement.focus();
inputElement.select();
}
});
};
const closesaveAsModal = () => {
saveAsForm.reset();
saveAsModal.value = false;
isDrawerOpen.value = false; // Re-enable auto save
};
const revisionSaveAs = () => {
const dropdown = document.getElementById('dropdownInformation');
if (dropdown) dropdown.classList.add('hidden');
saveAsForm.content = JSON.stringify(draft.value);
saveAsForm.post(route('page-builder.landing-page.save-as'), {
preserveScroll: false,
onSuccess: () => closesaveAsModal(),
});
};
// Open revision routines and modal handling
const openModal = ref(false);
const rowClicked = ref(false);
const openForm = useForm({
revision_uuid: ''
});
const showOpenModal = () => {
const dropdown = document.getElementById('dropdownInformation');
if (dropdown) dropdown.classList.add('hidden');
openModal.value = true;
isDrawerOpen.value = true; // Don't autosave while modal is open
};
const closeOpenModal = () => {
openModal.value = false;
isDrawerOpen.value = false; // Re-enable auto save
openForm.reset();
rowClicked.value = false;
};
const selectRevision = (uuid, index) => {
openForm.revision_uuid = uuid;
if (uuid != revision.uuid) { // Prevent opening of current revision
rowClicked.value = index;
}
};
const openRevision = () => {
router.get(route('page-builder.builder.edit', openForm.revision_uuid));
};
// Set active routines
const setActiveForm = useForm({
revision_uuid: revision.uuid,
title: revision.title || '',
content: '',
});
const setActive = () => {
const dropdown = document.getElementById('dropdownInformation');
if (dropdown) dropdown.classList.add('hidden');
setActiveForm.content = JSON.stringify(draft.value);
setActiveForm.put(route('page-builder.landing-page.set-active'));
};
</script>
<template>
<Modal :show="openModal" @close="closeOpenModal()" max-width="4xl">
<div class="p-6 bg-white dark:bg-gray-800 rounded-md">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-400">
Tilgjengelige revisjoner
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-200">
Velg en versjon av denne siden du ønsker å åpne
</p>
<div class="mt-2 relative overflow-x-auto">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
Tittel
</th>
<th scope="col" class="px-6 py-3">
Forfatter
</th>
<th scope="col" class="px-6 py-3">
Versjon
</th>
<th scope="col" class="px-6 py-3">
Oppdatert
</th>
<th scope="col" class="px-6 py-3">
Opprettet
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(pageRevision, index) in revisions"
:key="index"
class="dark:border-gray-700 border-gray-200"
:class="{
'bg-gray-100 dark:bg-gray-600': rowClicked == index + 1,
'bg-white dark:bg-gray-800': !rowClicked,
'cursor-pointer': pageRevision.uuid != revision.uuid
}"
@click="selectRevision(pageRevision.uuid, index + 1)"
>
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<div class="flex gap-2 justify-normal items-center">
<span>
{{ pageRevision.title }}
</span>
<span v-show="pageRevision.active" class="w-4 h-4 text-yellow-600 dark: dark:text-yellow-400">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</span>
</div>
</th>
<td class="px-6 py-4">
{{ pageRevision.author.name }}
</td>
<td class="px-6 py-4">
{{ pageRevision.version }}
</td>
<td class="px-6 py-4">
{{ formatDate(pageRevision.updated_at) }}
</td>
<td class="px-6 py-4">
{{ formatDate(pageRevision.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closeOpenModal()"> Avbryt </SecondaryButton>
<PrimaryButton
class="ml-3"
:class="{ 'opacity-25': openForm.processing }"
:disabled="openForm.processing"
v-show="rowClicked"
@click="openRevision"
>
Åpne
</PrimaryButton>
</div>
</div>
</Modal>
<Modal :show="saveAsModal" @close="closesaveAsModal()">
<div class="p-6 bg-white dark:bg-gray-800 rounded-md">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-400">
Lagre som ny revisjon
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-200">
Dette vil opprette en ny versjon og kopi av siden
</p>
<div class="mt-6">
<InputLabel for="titleSaveAs" value="Ny tittel" class="text-black dark:text-gray-400" />
<TextInput
id="titleSaveAs"
ref="saveAsTitle"
type="text"
class="mt-1 block w-full dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Oppgi ny tittel"
v-model="saveAsForm.title"
/>
<InputError :message="saveAsForm.errors.title" class="mt-2" />
</div>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closesaveAsModal()"> Avbryt </SecondaryButton>
<PrimaryButton
class="ml-3"
:class="{ 'opacity-25': saveAsForm.processing }"
:disabled="saveAsForm.processing"
@click="revisionSaveAs"
>
Lagre som
</PrimaryButton>
</div>
</div>
</Modal>
<Head title="Rediger Side" />
<PageBuilderLayout>
<template #header>
<AppLogo />
<h3 class="text-lg font-semibold">Rediger "{{ revision.title }}"</h3>
<div>
<button id="dropdownInformationButton" data-dropdown-toggle="dropdownInformation" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" type="button">
Handlinger
<svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<!-- Dropdown menu -->
<div id="dropdownInformation" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<div class="px-4 py-3 text-sm text-gray-900 dark:text-white">
<div>{{ page.props.auth.user.name }}</div>
<div class="font-medium truncate">{{ page.props.auth.user.email }}</div>
</div>
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownInformationButton">
<li>
<a
href="#"
@click="showOpenModal"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
Åpne
</a>
</li>
<li>
<a
href="#"
@click.prevent="savePage"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
Lagre
</a>
</li>
<li>
<a
href="#"
@click="opensaveAsModal"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
Lagre som
</a>
</li>
<li>
<Link
:href="route('page-builder.landing-page.preview', revision.uuid)"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>
Forhåndsvis
</Link>
</li>
</ul>
<div v-if="!revision.active" class="py-2">
<a href="#" @click.prevent="setActive" class="block px-4 py-2 btn btn-sm btn-ghost hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
Sett aktiv
</a>
</div>
<div class="py-2">
<Link :href="route('page-builder.index')" class="block px-4 py-2 btn btn-sm btn-ghost hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
Lukk
</Link>
</div>
</div>
</div>
</template>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<div class="bg-slate-100 overflow-y-auto w-1/6 p-4 space-y-4">
<div class="space-y-4 pt-4 pb-2 pl-4 pr-4 rounded border dark:border-gray-600 bg-gray-50 dark:bg-gray-800">
<div>
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="autoSaveFunction" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">
Autolagring <span v-if="autoSaveFunction">På</span><span v-else>Av</span>
</span>
</label>
</div>
</div>
<div v-if="revision.active" class="space-y-4 p-4 rounded border dark:border-gray-600 bg-gray-50 dark:bg-gray-800">
<div class="mb-4">
<label for="page_title-text" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">
Sidetittel
</label>
<input
type="text"
id="page_title-text"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Skriv inn en tittel"
v-model="pageForm.title"
>
<InputError :message="pageForm.errors.title" class="mt-2" />
<p id="page_title-text-explanation" class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Tittelen på siden vil være synlig i fanearket i nettleseren hos besøkende, samt brukes til å generere URL (slug)
</p>
</div>
<div class="mb-4">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="pageForm.linked" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Menylenke</span>
</label>
</div>
<div class="mb-4">
<label for="steps-range" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Menylenke rekkefølge</label>
<input id="steps-range" :disabled="!pageForm.linked" v-model="pageForm.linkorder" type="range" min="0" max="20" step="1" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700">
<span class="text-xs">Prioritet: {{ pageForm.linkorder }} (høyere verdi kommer først)</span>
</div>
<div class="mb-4">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="pageForm.publish" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Publisert</span>
</label>
</div>
<div class="mb-4">
<label class="inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="pageForm.mainpage" class="sr-only peer">
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"></div>
<span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300">Sett som hovedside</span>
</label>
</div>
<div>
<div class="flex items-center mb-4">
<input id="page_state-radio-1" v-model="pageForm.visibility" type="radio" value="public" name="page_state-radio"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="page_state-radio-1" class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Synlig for alle</label>
</div>
<div class="flex items-center">
<input id="page_state-radio-2" v-model="pageForm.visibility" type="radio" value="private" name="page_state-radio"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="page_state-radio-2" class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">Bare synlig for medlemmer</label>
</div>
</div>
</div>
<!-- Block groups -->
<BlockGroup
v-for="group in groups"
:key="group.uuid"
:title="group.title"
:blocks="group.blocks"
/>
</div>
<!-- Canvas -->
<div class="bg-white dark:bg-gray-800 w-5/6 p-6 overflow-y-auto" @dragover.prevent @drop="handleDrop">
<div v-if="draft.length === 0" class="text-gray-500 text-sm italic">
Dra inn blokker fra venstre for å begynne.
</div>
<div
v-for="(block, index) in draft"
:key="block.uuid"
class="mb-4"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver"
@drop="handleDropReorder($event, index)"
>
<BlockWrapper
:images="images"
:block="block"
@delete="() => deleteBlock(index)"
@drawer-state="isDrawerOpen = $event"
/>
</div>
</div>
</div>
</PageBuilderLayout>
</template>
<style scoped>
/* scoped styles for the edit screen */
</style>
@@ -0,0 +1,170 @@
<script setup>
import AuthenticatedLayout from '@/layouts/AuthenticatedLayout.vue';
import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
import DangerButton from '@/Components/DangerButton.vue';
import { Head, usePage, useForm, Link, router } from '@inertiajs/vue3';
import { computed, onMounted, ref } from 'vue';
import { initFlowbite } from 'flowbite';
onMounted(() => {
initFlowbite();
});
const page = usePage();
const userRoles = computed(() => page.props.userRoles);
const userPermissions = computed(() => page.props.userPermissions);
const {
builtPages,
} = defineProps({
builtPages: {
type: Object,
required: true
}
});
// Check user permissions from Laravel
const hasPermission = (permissionName) => {
return userPermissions.value.some(userPermissions => userPermissions.name === permissionName);
};
// Get thumbnail picture from the content property
const getThumbs = (lPage) => {
try {
const blocks = typeof lPage.content === 'string'
? JSON.parse(lPage.content)
: lPage.content;
return blocks
.filter(block => block.thumbUrl)
.map(block => block.thumbUrl);
} catch (e) {
console.warn('Invalid JSON in page content:', e);
return [];
}
};
// Confirm deletion modal
const deleteModal = ref(false);
const deleteForm = useForm({
id: null
});
const openDeleteModal = (lPage) => {
deleteForm.id = lPage.id;
deleteModal.value = true;
};
const closeDeleteModal = () => {
deleteForm.reset();
deleteModal.value = false;
};
const deletePage = () => {
deleteForm.delete(route('page-builder.landing-page.delete'), {
preserveScroll: true,
onSuccess: () => closeDeleteModal(),
});
};
</script>
<template>
<Modal :show="deleteModal" @close="closeDeleteModal()">
<div class="p-6 bg-white dark:bg-gray-800 rounded-md">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-400">
Bekreft sletting av side
</h2>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-200">
Denne operasjonen er permanent og kan ikke angres. All data
knyttet til siden vil bli slettet. Hvis siden er satt som
hovedsiden, vil alle besøkende bli redirigert til brukersiden
(hvis brukeren er innlogget), eller innloggingsiden hvis
den besøkende ikke er autentisert, inntill du setter en ny
forside.
</p>
<div class="mt-6 flex justify-end">
<SecondaryButton @click="closeDeleteModal()"> Avbryt </SecondaryButton>
<DangerButton
class="ml-3"
:class="{ 'opacity-25': deleteForm.processing }"
:disabled="deleteForm.processing"
@click="deletePage"
>
Slett side
</DangerButton>
</div>
</div>
</Modal>
<Head title="Sidebygger" />
<AuthenticatedLayout>
<template #header>
<div class="flex justify-between items-center">
<div>
<h2 class="font-semibold text-xl text-gray-800 leading-tight dark:text-gray-400">Administrering av sider</h2>
<h4 class="text-sm mt-2 text-gray-800 leading-tight dark:text-gray-400">Her kan du opprette en ny, eller administrere eksisterende sider</h4>
</div>
<Link :href="route('page-builder.landing-page.create')" v-if="hasPermission('create-article')" class="relative inline-flex items-center justify-center p-0.5 mb-2 mr-2 overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-cyan-500 to-blue-500 group-hover:from-cyan-500 group-hover:to-blue-500 hover:text-white dark:text-white focus:ring-4 focus:outline-none focus:ring-cyan-200 dark:focus:ring-cyan-800">
<span class="flex items-center relative px-5 py-2.5 transition-all ease-in duration-75 bg-white dark:bg-gray-700 rounded-md group-hover:bg-opacity-0">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 -960 960 960" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-2">
<path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h440l200 200v440q0 33-23.5 56.5T760-120H200Zm0-80h560v-400H600v-160H200v560Zm80-80h400v-80H280v80Zm0-320h200v-80H280v80Zm0 160h400v-80H280v80Zm-80-320v160-160 560-560Z" />
</svg>
Ny side
</span>
</Link>
</div>
</template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-4">
<div
v-for="page in builtPages"
:key="page.id"
class="flex flex-col items-center bg-white border border-gray-200 rounded-lg shadow-sm md:flex-row md:max-w-xl dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex flex-col md:w-48 w-full rounded-t-lg md:rounded-none md:rounded-s-lg overflow-hidden">
<img
v-for="(thumb, index) in getThumbs(page)"
:key="index"
:src="thumb"
alt="Forhåndsvisning"
class="object-cover w-full h-24 md:h-24 border-b dark:border-gray-700"
/>
</div>
<div class="flex flex-col justify-between p-4 leading-normal h-full w-full">
<div>
<h3 class="mb-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ page.title }}</h3>
<p class="mb-3 font-normal text-gray-700 dark:text-gray-400">Av: {{ page.author.name }} — Revisjoner: {{ page.revisions_count }}</p>
</div>
<div class="flex justify-end gap-2 mt-4">
<Link
v-if="page.activeRevision"
:href="route('page-builder.builder.edit', page.activeRevision.uuid)"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Rediger
</Link>
<button
@click="openDeleteModal(page)"
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900"
>
Slett
</button>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
@@ -0,0 +1,296 @@
<script setup>
import { Head, Link, router, usePage} from '@inertiajs/vue3';
import { onMounted, ref } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { ScrollToPlugin } from 'gsap/ScrollToPlugin';
import { on } from '@/Components/EventBus.vue';
import MainMenu from '@/Components/FrontPage/MenyComponent.vue';
import ExternalMainMenu from '@/Components/FrontPage/ExternalMenuComponent.vue';
import SmallHeader from '@/Components/FrontPage/SmallHeader.vue';
import HeroLogoTextRendered from '@/Components/Blocks/Heroes/HeroLogoTextRendered.vue';
import FAQListRendered from '@/Components/Blocks/FAQ/FAQListRendered.vue';
import FAQCollapseRendered from '@/Components/Blocks/FAQ/FAQCollapseRendered.vue';
import FAQCondensedRendered from '@/Components/Blocks/FAQ/FAQCondensedRendered.vue';
import TwoColumnTextRendered from '@/Components/Blocks/Text/TwoColumnTextRendered.vue';
import FrontPageMenuHeaderRendered from '@/Components/Blocks/Statics/FrontPageMenuHeaderRendered.vue';
import FrontPageSponsorsRendered from '@/Components/Blocks/Statics/FrontPageSponsorsRendered.vue';
import FrontPageMembershipInfoRendered from '@/Components/Blocks/Statics/FrontPageMembershipInfoRendered.vue';
import FrontPageNewsListRendered from '@/Components/Blocks/Statics/FrontPageNewsListRendered.vue';
import FrontPageEventsListRendered from '@/Components/Blocks/Statics/FrontPageEventsListRendered.vue';
import PageFooterRendered from '@/Components/Blocks/Statics/PageFooterRendered.vue';
const {
revision,
images,
blockData,
menuLinks
} = defineProps({
revision: { type: Object, required: true },
images: { type: Array, default: () => ([]) },
blockData: { type: Object, default: () => ({}) },
menuLinks: { type: Object, default: () => ({}) }
});
onMounted(() => {
// Check if the url has the parameter key scrollTo
if (urlParam('scrollTo')) {
// Enable scrolling on the body and html elements
document.body.style.overflow = 'auto';
document.documentElement.style.overflow = 'auto';
// Get the value of the parameter
const scrollTo = urlParam('scrollTo');
// Scroll to the section with the id that matches the value of the parameter
gsap.to(window, {
duration: 1,
scrollTo: {
y: `#${scrollTo}`,
autoKill: false,
},
});
}
});
gsap.registerPlugin(ScrollToPlugin, ScrollTrigger);
const useEventBus = ref(false);
const showMainMenu = ref(false);
const pageRef = ref();
const page = usePage();
const smallHeader = ref(null);
const showErrorModal = ref(false);
const errorStatus = ref('');
const closeErrorModal = () => {
showErrorModal.value = false;
};
// Lytt etter errorModal event fra Laravel
window.addEventListener('errorModal', (event) => {
showErrorModal.value = true;
errorStatus.value = event.detail.status;
});
/** Menu button animations */
const menuButton = ref(null);
const handleMenuButtonOver = () => {
gsap.set(menuButton.value, {
borderRadius: '0.375rem',
});
gsap.to(menuButton.value, {
duration: 0.2,
scale: 1.1,
});
};
const handleMenuButtonOut = () => {
gsap.set(menuButton.value, {
borderRadius: '9999px',
});
gsap.to(menuButton.value, {
duration: 0.2,
scale: 1,
});
};
const clickMenuButton = () => {
showMainMenu.value = !showMainMenu.value;
if (showMainMenu.value) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
document.documentElement.style.overflow = 'auto';
}
};
/** Get a node value from json */
const extractMetaValue = (node, key) => {
let meta = JSON.parse(node.fs_meta);
return meta[key];
};
/**
* formatDate
*
* Formats a date string to a Norwegian date format.
*
* @param {
* } dateString
*/
const formatDate = (dateString) => {
const date = new Date(dateString);
const day = ('0' + date.getDate()).slice(-2);
const month = ('0' + (date.getMonth() + 1)).slice(-2);
const year = date.getFullYear();
return `${day}/${month}/${year}`;
};
/**
* dateString
*
* Formats a date string to a Norwegian time format.
* @param {*} dateString
*/
const formatTime = (dateString) => {
const date = new Date(dateString);
return date.toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' });
};
/** Get url parameters */
const urlParam = (paramKey) => {
const url = new URL(window.location.href);
return url.searchParams.get(paramKey);
};
// Denne funksjonen kalles fra lenker på siden for å
// Gi siden en myk fade ut effekt før den nye siden lastes
const loadLink = (url) => {
gsap.to(pageRef.value, {
duration: 1,
opacity: 0,
ease: 'power2.inOut',
onComplete: () => {
router.visit(url);
},
});
};
const components = {
HeroLogoTextRendered,
FAQListRendered,
FAQCollapseRendered,
FAQCondensedRendered,
TwoColumnTextRendered,
FrontPageMenuHeaderRendered,
FrontPageSponsorsRendered,
FrontPageMembershipInfoRendered,
FrontPageNewsListRendered,
FrontPageEventsListRendered,
PageFooterRendered
};
/** Eventbus listeners */
on('linkClicked', (payload) => {
if (payload === 'om' && useEventBus.value) {
clickMenuButton();
gsap.to(window, {
duration: 1,
scrollTo: {
y: '#content',
autoKill: false,
},
});
} else if (payload === 'arrangementer' && useEventBus.value) {
clickMenuButton();
gsap.to(window, {
duration: 1,
scrollTo: {
y: '#events',
autoKill: false,
},
});
} else if (payload === 'nyheter' && useEventBus.value) {
clickMenuButton();
gsap.to(window, {
duration: 1,
scrollTo: {
y: '#intro',
autoKill: false,
},
});
} else if (payload === 'sponsor' && useEventBus.value) {
clickMenuButton();
gsap.to(window, {
duration: 1,
scrollTo: {
y: '#sponsors-intro',
autoKill: false,
},
});
} else if (payload === 'medlem' && useEventBus.value) {
clickMenuButton();
gsap.to(window, {
duration: 1,
scrollTo: {
y: '#membership',
autoKill: false,
},
});
} else if (payload === 'medlemside' && useEventBus.value) {
clickMenuButton();
loadLink(route('dashboard'));
} else if (payload === 'loggpaa' && useEventBus.value) {
clickMenuButton();
loadLink(route('login'));
} else if (payload === 'registrer' && useEventBus.value) {
clickMenuButton();
loadLink(route('register'));
}
});
on('eventBusActive', (payload) => {
useEventBus.value = payload;
});
</script>
<template>
<Head>
<title>{{ revision.title }}</title>
</Head>
<div ref="pageRef" class="min-h-screen w-full bg-black">
<template v-if="revision.page.main"> <!-- Show Main menu if the current page is the front page (main page) -->
<MainMenu v-if="showMainMenu" :active="showMainMenu" :pageSettings="page.props.pageSettings" :menuLinks="menuLinks" />
</template>
<template v-else> <!-- Show the external main menu, if the current page is NOT the front page (main page) -->
<ExternalMainMenu v-if="showMainMenu" :active="showMainMenu" :pageSettings="page.props.pageSettings" class="z-40" />
</template>
<SmallHeader />
<!-- Hovedmeny knapp -->
<div class="fixed top-0 right-0 p-4 z-50">
<button ref="menuButton" @click="clickMenuButton" @mouseover="handleMenuButtonOver" @mouseout="handleMenuButtonOut" class="p-2 text-white rounded-full shadow-md shadow-black" :class="{'bg-sky-600': showMainMenu, 'bg-green-700': !showMainMenu }">
<svg v-if="showMainMenu" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
<!-- Komponentrendring -->
<component
v-for="block in revision.content"
:key="block.uuid"
:images="images"
:is="components[block.renderComponentName]"
:block="block"
:blockData="blockData"
:frontpage="revision.page.main"
/>
</div>
</template>