This commit is contained in:
2026-05-13 17:11:09 +02:00
commit ea63897455
2785 changed files with 359868 additions and 0 deletions

11
resources/css/app.css Normal file
View File

@@ -0,0 +1,11 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

1
resources/js/app.js Normal file
View File

@@ -0,0 +1 @@
import './bootstrap';

4
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@@ -0,0 +1,41 @@
import { createApp } from 'vue';
import ProjectPageRenderer from './projects-renderer/ProjectPageRenderer.vue';
import ProjectStructureEditor from './projects-renderer/ProjectStructureEditor.vue';
import { buildInitialProject } from './projects-renderer/schema/projectSchema';
const readPayload = (element) => {
const targetId = element.dataset.payloadTarget;
const payloadNode = targetId ? document.getElementById(targetId) : null;
if (!payloadNode) {
return {};
}
try {
return JSON.parse(payloadNode.textContent || '{}');
} catch (error) {
console.error('Unable to parse project renderer payload.', error);
return {};
}
};
document.querySelectorAll('[data-project-structure-editor]').forEach((element) => {
const payload = readPayload(element);
createApp(ProjectStructureEditor, {
initialProject: payload,
fieldId: element.dataset.fieldId || 'project-structure-field',
uploadUrl: element.dataset.uploadUrl || null,
languages: payload.languages || [],
defaultLanguage: payload.defaultLanguage || '',
categories: payload.categories || [],
activeValue: payload.active ?? 'Y',
}).mount(element);
});
document.querySelectorAll('[data-project-page-renderer]').forEach((element) => {
const payload = readPayload(element);
createApp(ProjectPageRenderer, {
project: buildInitialProject(payload),
activeLang: payload.defaultLanguage || '',
}).mount(element);
});

View File

@@ -0,0 +1,282 @@
<script setup>
import { computed } from 'vue';
import PublicSlot from './components/blocks/PublicSlot.vue';
import ProjectBlockRenderer from './components/ProjectBlockRenderer.vue';
import ProjectHeadline from './components/ProjectHeadline.vue';
import ProjectHero from './components/ProjectHero.vue';
import ProjectMetadata from './components/ProjectMetadata.vue';
import { normalizeProjectSchema } from './schema/projectSchema';
const props = defineProps({
project: {
type: Object,
default: () => ({}),
},
editable: {
type: Boolean,
default: false,
},
selectedBlockId: {
type: String,
default: null,
},
activeLang: {
type: String,
default: '',
},
mobilePreview: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select-block']);
const normalizedProject = computed(() => normalizeProjectSchema(props.project, props.activeLang));
const header = computed(() => normalizedProject.value.header);
const metadata = computed(() => normalizedProject.value.metadata);
// Map heroMedia → a slot-data shape PublicSlot understands
const heroSlot = computed(() => {
const m = normalizedProject.value.heroMedia;
if (!m) return null;
const isVideoType = ['youtube', 'frameio', 'bunny', 'video'].includes(m.type);
if (isVideoType) {
return { type: 'video', content: {}, image: { url: '' }, media: m };
}
return { type: 'image', content: {}, image: { url: m.url || '', alt: '' }, media: m };
});
const publicContentBlocks = computed(() => {
const hasMetaDescription = !!metadata.value.description;
return normalizedProject.value.contentBlocks.filter((block) => {
if (!hasMetaDescription) return true;
return !(block.type === 'FullWidth' && block.slot?.type === 'text');
});
});
</script>
<template>
<!-- ===================== PUBLIC VIEW ===================== -->
<template v-if="!editable">
<section class="projects-area">
<div class="container">
<!-- Headline + hero + metadata -->
<div class="row">
<div class="col-lg-12">
<div class="single-project">
<div class="project-info">
<div class="row">
<div class="col-lg-6 col-md-8">
<h2>{{ header.headline || 'Untitled project' }}</h2>
</div>
<div v-if="header.subline" class="col-lg-6 col-md-4">
<div class="subtitle"><p>{{ header.subline }}</p></div>
</div>
</div>
</div>
<div class="thumbnail-wrap">
<PublicSlot
v-if="heroSlot"
:slot-data="heroSlot"
:active-lang="activeLang"
/>
</div>
<div class="row mt-5">
<div class="col-lg-6">
<div class="project-info mb-0">
<div v-if="metadata.clientName" class="client-info">
<h4>Client</h4>
<span>{{ metadata.clientName }}</span>
</div>
<div v-if="metadata.awarded?.length" class="project-details">
<h4>Awarded</h4>
<p v-for="award in metadata.awarded" :key="award">{{ award }}</p>
</div>
</div>
</div>
<div class="col-lg-6">
<div v-if="metadata.description" class="description-text" v-html="metadata.description"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Content blocks all in one shared row so columns flow together -->
<div v-if="publicContentBlocks.length" class="row">
<template v-for="block in publicContentBlocks" :key="block.id">
<!-- FullWidth text centered narrow column -->
<div v-if="block.type === 'FullWidth' && block.slot?.type === 'text'" class="col-lg-12">
<div class="row project-content justify-content-center">
<div class="col-lg-6 text-center">
<PublicSlot
:slot-data="block.slot"
:active-lang="activeLang"
/>
</div>
</div>
</div>
<!-- FullWidth non-text full 12-col -->
<div v-else-if="block.type === 'FullWidth'" class="col-lg-12">
<div class="single-project">
<div class="thumbnail-wrap">
<PublicSlot
:slot-data="block.slot"
:active-lang="activeLang"
/>
</div>
</div>
</div>
<!-- TwoColumns: text slots centered col-lg-10; image/video slots col-lg-12 thumbnail-wrap -->
<template v-else-if="block.type === 'TwoColumns'">
<!-- left text -->
<div v-if="block.left?.type === 'text'" class="col-lg-12">
<div class="row project-content justify-content-center">
<div class="col-lg-10 text-center">
<PublicSlot :slot-data="block.left" :active-lang="activeLang" />
</div>
</div>
</div>
<!-- left non-text -->
<div v-else-if="block.left" class="col-lg-6 col-md-6">
<div class="single-project">
<div class="thumbnail-wrap">
<PublicSlot :slot-data="block.left" :active-lang="activeLang" />
</div>
</div>
</div>
<!-- right text -->
<div v-if="block.right?.type === 'text'" class="col-lg-12">
<div class="row project-content justify-content-center">
<div class="col-lg-10 text-center">
<PublicSlot :slot-data="block.right" :active-lang="activeLang" />
</div>
</div>
</div>
<!-- right non-text -->
<div v-else-if="block.right" class="col-lg-6 col-md-6">
<div class="single-project">
<div class="thumbnail-wrap">
<PublicSlot :slot-data="block.right" :active-lang="activeLang" />
</div>
</div>
</div>
</template>
</template>
</div>
</div>
</section>
</template>
<!-- ===================== EDITOR PREVIEW ===================== -->
<template v-else>
<section class="project-page-renderer">
<div class="project-page-renderer__shell">
<!-- Hero block: clickable wrapper with same border treatment as dynamic blocks -->
<div
class="project-hero-block"
:class="{ 'project-hero-block--selected': selectedBlockId === '__hero__' }"
@click.stop="emit('select-block', '__hero__')"
>
<div class="project-hero-block__bar">
<span class="project-hero-block__badge">Hero / Header</span>
</div>
<ProjectHeadline
:key="activeLang"
:headline="header.headline"
:subline="header.subline"
:mobile-preview="mobilePreview"
/>
<ProjectHero :media="normalizedProject.heroMedia" />
<ProjectMetadata
:key="activeLang"
:metadata="metadata"
:mobile-preview="mobilePreview"
/>
</div>
<div class="project-page-renderer__blocks">
<ProjectBlockRenderer
v-for="block in normalizedProject.contentBlocks"
:key="block.id"
:block="block"
:editable="editable"
:selected="block.id === selectedBlockId"
:active-lang="activeLang"
@select="emit('select-block', $event)"
/>
</div>
</div>
</section>
</template>
</template>
<style scoped>
/* Editor preview only */
.project-page-renderer {
--project-ink: #0f172a;
--project-muted: #667085;
--project-surface: rgba(255, 255, 255, 0.72);
background:
radial-gradient(circle at top right, rgba(14, 116, 144, 0.12), transparent 26%),
linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(241, 245, 249, 0.95));
padding: clamp(1rem, 4%, 3rem);
}
.project-page-renderer__shell {
display: grid;
gap: 2rem;
margin: 0 auto;
max-width: 100%;
width: 100%;
}
.project-page-renderer__blocks {
display: grid;
gap: 2rem;
}
/* Hero clickable wrapper */
.project-hero-block {
cursor: pointer;
display: grid;
gap: 0.85rem;
position: relative;
}
.project-hero-block__bar {
align-items: center;
display: flex;
gap: 0.5rem;
}
.project-hero-block__badge {
background: rgba(14, 116, 144, 0.1);
border-radius: 999px;
color: #0f766e;
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.7rem;
}
.project-hero-block--selected {
outline: 2px solid rgba(14, 116, 144, 0.35);
outline-offset: 0.75rem;
}
@media (max-width: 768px) {
.project-page-renderer {
padding: 1rem;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,281 @@
<script setup>
import { inject, ref } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
uploadUrl: {
type: String,
default: null,
},
label: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const isDragOver = ref(false);
const isUploading = ref(false);
const error = ref(null);
const fileInputRef = ref(null);
const localPreview = ref(null);
// Plugs into the parent editor's pending-upload counter so the form submit
// can be blocked while this upload is in flight.
const pendingUploads = inject('editorPendingUploads', null);
const uploadFile = async (file) => {
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
error.value = 'Only image files are accepted.';
return;
}
// Emit blob URL immediately so the right-side preview updates at once
if (localPreview.value) {
URL.revokeObjectURL(localPreview.value);
}
localPreview.value = URL.createObjectURL(file);
error.value = null;
emit('update:modelValue', localPreview.value);
if (!props.uploadUrl) {
// No upload endpoint — blob URL stays as the value
return;
}
isUploading.value = true;
if (pendingUploads) pendingUploads.value++;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const formData = new FormData();
formData.append('image', file);
const response = await fetch(props.uploadUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
// Replace blob URL with the permanent server URL
URL.revokeObjectURL(localPreview.value);
localPreview.value = null;
emit('update:modelValue', data.url);
} catch {
error.value = 'Upload failed. Please try again.';
URL.revokeObjectURL(localPreview.value);
localPreview.value = null;
emit('update:modelValue', '');
} finally {
isUploading.value = false;
if (pendingUploads) pendingUploads.value--;
}
};
const onDragOver = (event) => {
event.preventDefault();
isDragOver.value = true;
};
const onDragLeave = () => {
isDragOver.value = false;
};
const onDrop = (event) => {
event.preventDefault();
isDragOver.value = false;
const file = event.dataTransfer?.files?.[0];
if (file) {
uploadFile(file);
}
};
const openPicker = () => {
fileInputRef.value.value = '';
fileInputRef.value.click();
};
const onFileSelected = (event) => {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
};
const clearImage = (event) => {
event.stopPropagation();
if (localPreview.value) {
URL.revokeObjectURL(localPreview.value);
localPreview.value = null;
}
emit('update:modelValue', '');
};
</script>
<template>
<div
class="image-drop-zone"
:class="{
'image-drop-zone--over': isDragOver,
'image-drop-zone--uploading': isUploading,
'image-drop-zone--filled': !!(localPreview || modelValue),
}"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@click="openPicker"
>
<template v-if="localPreview || modelValue">
<img :src="localPreview || modelValue" class="image-drop-zone__preview" :class="{ 'image-drop-zone__preview--uploading': isUploading }" alt="">
<div class="image-drop-zone__overlay">
<span class="image-drop-zone__overlay-text">{{ isUploading ? 'Uploading…' : 'Drop or click to replace' }}</span>
<button v-if="!isUploading" type="button" class="image-drop-zone__clear" @click="clearImage" title="Remove image"></button>
</div>
</template>
<template v-else>
<div class="image-drop-zone__empty">
<span class="image-drop-zone__icon">{{ isUploading ? '⏳' : '🖼' }}</span>
<span class="image-drop-zone__hint">{{ isUploading ? 'Uploading…' : (uploadUrl ? 'Drop image or click to upload' : 'Drop image here') }}</span>
<span v-if="label" class="image-drop-zone__label">{{ label }}</span>
</div>
</template>
</div>
<p v-if="error" class="image-drop-zone__error">{{ error }}</p>
<input ref="fileInputRef" type="file" accept="image/*" class="image-drop-zone__input" @change="onFileSelected">
</template>
<style scoped>
.image-drop-zone {
border: 2px dashed rgba(15, 23, 42, 0.15);
border-radius: 0.75rem;
cursor: pointer;
overflow: hidden;
position: relative;
transition: border-color 0.15s, background 0.15s;
user-select: none;
}
.image-drop-zone--over {
background: #f0f9ff;
border-color: rgba(14, 116, 144, 0.6);
}
.image-drop-zone--uploading {
pointer-events: none;
}
.image-drop-zone--filled {
border-style: solid;
border-color: rgba(15, 23, 42, 0.12);
}
/* Empty state */
.image-drop-zone__empty {
align-items: center;
display: flex;
flex-direction: column;
gap: 0.35rem;
justify-content: center;
min-height: 6rem;
padding: 1rem;
}
.image-drop-zone__icon {
font-size: 1.6rem;
line-height: 1;
}
.image-drop-zone__hint {
color: #64748b;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
}
.image-drop-zone__label {
color: #94a3b8;
font-size: 0.72rem;
text-align: center;
}
/* Filled state */
.image-drop-zone__preview {
display: block;
height: 9rem;
object-fit: cover;
width: 100%;
}
.image-drop-zone__preview--uploading {
opacity: 0.5;
}
.image-drop-zone__overlay {
align-items: center;
background: rgba(15, 23, 42, 0.5);
bottom: 0;
display: flex;
justify-content: space-between;
left: 0;
opacity: 0;
padding: 0.4rem 0.6rem;
position: absolute;
right: 0;
transition: opacity 0.15s;
}
.image-drop-zone:hover .image-drop-zone__overlay {
opacity: 1;
}
.image-drop-zone__overlay-text {
color: #fff;
font-size: 0.75rem;
font-weight: 600;
}
.image-drop-zone__clear {
background: rgba(255, 255, 255, 0.15);
border: 0;
border-radius: 50%;
color: #fff;
cursor: pointer;
font-size: 0.75rem;
height: 1.4rem;
line-height: 1;
padding: 0;
width: 1.4rem;
}
.image-drop-zone__clear:hover {
background: rgba(239, 68, 68, 0.75);
}
.image-drop-zone__input {
display: none;
}
.image-drop-zone__error {
color: #b91c1c;
font-size: 0.8rem;
margin: 0.25rem 0 0;
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup>
import { computed, inject } from 'vue';
import FullWidthBlock from './blocks/FullWidthBlock.vue';
import FullWidthImageBlock from './blocks/FullWidthImageBlock.vue';
import FullWidthTextBlock from './blocks/FullWidthTextBlock.vue';
import TwoColumnsBlock from './blocks/TwoColumnsBlock.vue';
import TwoColumnImagesBlock from './blocks/TwoColumnImagesBlock.vue';
import VideoBlock from './blocks/VideoBlock.vue';
const props = defineProps({
block: {
type: Object,
required: true,
},
editable: {
type: Boolean,
default: false,
},
selected: {
type: Boolean,
default: false,
},
activeLang: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const componentMap = {
// New types
FullWidth: FullWidthBlock,
TwoColumns: TwoColumnsBlock,
// Legacy (kept for safety)
FullWidthText: FullWidthTextBlock,
TwoColumnImages: TwoColumnImagesBlock,
FullWidthImage: FullWidthImageBlock,
Video: VideoBlock,
};
const resolvedComponent = computed(() => componentMap[props.block.type] || FullWidthBlock);
const blockDrag = inject('editorBlockDrag', null);
const toggleVisibility = inject('editorToggleBlockVisibility', null);
const swapColumns = inject('editorSwapBlockColumns', null);
const isTwoColumn = computed(() => props.block.type === 'TwoColumns' || props.block.type === 'TwoColumnImages');
const isDragging = computed(() => blockDrag?.dragSrcId.value === props.block.id);
const isDragOver = computed(() => blockDrag?.dragOverId.value === props.block.id);
</script>
<template>
<div
class="project-block"
:data-block-id="block.id"
:class="{
'project-block--selected': selected,
'project-block--dragging': isDragging,
'project-block--over': isDragOver,
'project-block--editable': editable,
'project-block--hidden': editable && block.hidden,
}"
@click="emit('select', block.id)"
@dragover="editable && blockDrag ? blockDrag.onDragOver($event, block.id) : null"
@dragleave="editable && blockDrag ? blockDrag.onDragLeave() : null"
@drop="editable && blockDrag ? blockDrag.onDrop($event, block.id) : null"
@dragend="editable && blockDrag ? blockDrag.onDragEnd() : null"
>
<div v-if="editable" class="project-block__bar">
<div class="project-block__bar-left">
<span class="project-block__badge" :class="{ 'project-block__badge--hidden': block.hidden }">{{ block.name ? `${block.name}${block.type}` : block.type }}</span>
<span v-if="block.hidden" class="project-block__hidden-label">Hidden not visible on frontend</span>
</div>
<div class="project-block__bar-actions">
<button
v-if="swapColumns && isTwoColumn"
type="button"
class="project-block__vis-btn"
title="Swap columns"
@click.stop="swapColumns(block.id)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 16V4m0 0L3 8m4-4l4 4"/><path d="M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>
<button
v-if="toggleVisibility"
type="button"
class="project-block__vis-btn"
:class="{ 'project-block__vis-btn--hidden': block.hidden }"
:title="block.hidden ? 'Hidden — click to show' : 'Visible — click to hide'"
@click.stop="toggleVisibility(block.id)"
>
<!-- Eye open -->
<svg v-if="!block.hidden" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<!-- Eye off -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
<span
v-if="blockDrag"
class="project-block__handle"
title="Drag to reorder"
draggable="true"
@dragstart.stop="blockDrag.onDragStart($event, block.id)"
></span>
</div>
</div>
<component :is="resolvedComponent" :block="block" :active-lang="activeLang" />
</div>
</template>
<style scoped>
.project-block {
display: grid;
gap: 0.85rem;
position: relative;
scroll-margin-top: 8rem;
}
.project-block--editable {
cursor: pointer;
}
.project-block--editable:not(.project-block--selected):hover {
outline: 2px solid rgba(15, 23, 42, 0.12);
outline-offset: 0.75rem;
}
.project-block--dragging {
opacity: 0.4;
}
.project-block--hidden {
border-left: 3px solid #ef4444;
padding-left: 0.75rem;
}
.project-block--over {
outline: 2px dashed rgba(14, 116, 144, 0.6);
outline-offset: 0.5rem;
}
.project-block--selected {
outline: 2px solid rgba(14, 116, 144, 0.35);
outline-offset: 0.75rem;
}
/* Top bar: badge + drag handle */
.project-block__bar {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
.project-block__bar-left {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
min-width: 0;
}
.project-block__hidden-label {
color: #ef4444;
font-size: 0.7rem;
font-weight: 500;
}
.project-block__badge {
background: rgba(14, 116, 144, 0.1);
border-radius: 999px;
color: #0f766e;
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.7rem;
}
.project-block__handle {
color: #94a3b8;
cursor: grab;
font-size: 1.1rem;
line-height: 1;
padding: 0.2rem 0.4rem;
user-select: none;
}
.project-block__handle:hover {
color: #0f766e;
}
.project-block__bar-actions {
align-items: center;
display: flex;
gap: 0.25rem;
}
.project-block__badge--hidden {
background: #fee2e2;
color: #b91c1c;
}
.project-block__vis-btn {
align-items: center;
background: none;
border: none;
border-radius: 0.3rem;
color: #94a3b8;
cursor: pointer;
display: flex;
line-height: 1;
padding: 0.2rem;
transition: color 0.15s;
}
.project-block__vis-btn:hover {
color: #0f766e;
}
.project-block__vis-btn--hidden {
color: #ef4444;
}
.project-block__vis-btn--hidden:hover {
color: #b91c1c;
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup>
import { computed, inject } from 'vue';
defineProps({
headline: {
type: String,
default: '',
},
subline: {
type: String,
default: '',
},
mobilePreview: {
type: Boolean,
default: false,
},
});
const updateHeader = inject('editorUpdateHeader', null);
const isEditable = computed(() => !!updateHeader);
function onHeadlineBlur(e) {
updateHeader?.('headline', e.target.innerText.trim());
}
function onSublineBlur(e) {
updateHeader?.('subline', e.target.innerText.trim());
}
</script>
<template>
<section class="project-headline" :class="{ 'project-headline--mobile': mobilePreview }">
<span class="project-headline__eyebrow">Project headline</span>
<h1
v-if="isEditable"
contenteditable="true"
class="project-headline__editable"
data-placeholder="Untitled project"
:class="{ 'project-headline__editable--empty': !headline }"
@blur="onHeadlineBlur"
v-text="headline || ''"
></h1>
<h1 v-else>{{ headline || 'Untitled project' }}</h1>
<p
v-if="isEditable"
contenteditable="true"
class="project-headline__subline project-headline__editable project-headline__subline--edit"
data-placeholder="Add a subline"
@blur="onSublineBlur"
v-text="subline || ''"
></p>
<p v-else-if="subline" class="project-headline__subline">{{ subline }}</p>
</section>
</template>
<style scoped>
.project-headline {
container-type: inline-size;
display: grid;
gap: 0.85rem;
}
.project-headline__eyebrow {
color: var(--project-muted, #6b7280);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
}
h1 {
color: var(--project-ink, #111827);
font-size: clamp(2.3rem, 5vw, 4.75rem);
font-weight: 700;
letter-spacing: -0.04em;
line-height: 0.94;
margin: 0;
max-width: 20ch;
}
.project-headline__subline {
color: var(--project-muted, #6b7280);
font-size: clamp(1rem, 1.5vw, 1.35rem);
font-weight: 400;
line-height: 1.5;
margin: 0;
max-width: 42ch;
}
/* ---- Inline edit ---- */
.project-headline__editable {
outline: none;
border-bottom: 1px solid transparent;
transition: border-color 0.15s;
}
.project-headline__editable:hover {
border-bottom-color: rgba(15, 23, 42, 0.18);
}
.project-headline__editable:focus {
border-bottom-color: rgba(14, 116, 144, 0.5);
}
.project-headline__editable:empty::before {
color: var(--project-muted, #6b7280);
content: attr(data-placeholder);
pointer-events: none;
}
.project-headline__subline--edit {
min-height: 1.5em;
}
.project-headline--mobile h1 {
font-size: clamp(1.75rem, 7vw, 2.5rem);
max-width: 100%;
}
.project-headline--mobile .project-headline__subline {
font-size: 1rem;
max-width: 100%;
}
@container (max-width: 480px) {
h1 {
font-size: clamp(1.75rem, 9cqi, 2.5rem);
max-width: 100%;
}
.project-headline__subline {
font-size: 1rem;
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,157 @@
<script setup>
import { computed, inject } from 'vue';
import { useImageUpload } from '../composables/useImageUpload';
import { useIntersectionActivation } from '../composables/useIntersectionActivation';
import { getBunnyEmbedUrl, getFrameIoEmbedUrl, getYouTubeEmbedUrl } from '../schema/projectSchema';
const props = defineProps({
media: {
type: Object,
default: () => ({ type: 'image', url: '' }),
},
});
const { isActive, target } = useIntersectionActivation();
const youtubeUrl = computed(() => getYouTubeEmbedUrl(props.media));
const frameIoUrl = computed(() => getFrameIoEmbedUrl(props.media?.url));
const bunnyUrl = computed(() => getBunnyEmbedUrl(props.media));
const uploadUrl = inject('editorUploadUrl', null);
const updateHeroImage = inject('editorUpdateHeroImage', null);
const { isDragOver, isUploading, fileInputRef, onDragOver, onDragLeave, onDrop, openPicker, onFileSelected } = useImageUpload(
() => uploadUrl?.value ?? null,
(url) => updateHeroImage?.(url),
);
const isDroppable = computed(() => props.media?.type === 'image' && !!uploadUrl?.value);
</script>
<template>
<section
ref="target"
class="project-hero"
:class="{ 'project-hero--over': isDragOver, 'project-hero--uploading': isUploading, 'project-hero--editable': isDroppable }"
@dragover="isDroppable ? onDragOver($event) : null"
@dragleave="onDragLeave"
@drop="isDroppable ? onDrop($event) : null"
@click="isDroppable ? openPicker() : null"
>
<iframe
v-if="media.type === 'youtube' && isActive && youtubeUrl"
:src="youtubeUrl"
title="Project hero video"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
<iframe
v-else-if="media.type === 'frameio' && isActive && frameIoUrl"
:src="frameIoUrl"
title="Project hero video"
loading="lazy"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
<iframe
v-else-if="media.type === 'bunny' && isActive && bunnyUrl"
:src="bunnyUrl"
title="Project hero video"
loading="lazy"
style="border:0;position:absolute;top:0;height:100%;width:100%;"
allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;"
allowfullscreen
/>
<video
v-else-if="media.type === 'video' && isActive && media.url"
:autoplay="media.autoplay === true"
:muted="media.muted === true"
:loop="media.loop === true"
controls
playsinline
preload="metadata"
>
<source :src="media.url">
</video>
<img v-else-if="media.url" :src="media.url" alt="Project hero" loading="lazy" :class="{ 'project-hero__img--uploading': isUploading }">
<div v-else class="project-hero__placeholder">
{{ isUploading ? 'Uploading…' : (isDroppable ? 'Drop image here or click to upload.' : 'Add a YouTube URL, Frame.io review link, Bunny embed URL, native video URL, or image URL.') }}
</div>
<div v-if="isDragOver" class="project-hero__overlay">Drop to upload</div>
<div v-if="isUploading && media.url" class="project-hero__overlay project-hero__overlay--uploading">Uploading</div>
</section>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="onFileSelected">
</template>
<style scoped>
.project-hero {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(148, 163, 184, 0.18));
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 1.75rem;
overflow: hidden;
position: relative;
transition: outline-color 0.15s;
}
.project-hero--editable {
cursor: pointer;
}
.project-hero--over {
outline: 2px dashed rgba(14, 116, 144, 0.7);
outline-offset: -2px;
}
iframe,
video,
img {
border: 0;
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.project-hero__img--uploading {
opacity: 0.5;
}
.project-hero__placeholder {
align-items: center;
color: var(--project-muted, #6b7280);
display: flex;
height: 100%;
justify-content: center;
padding: 2rem;
text-align: center;
}
.project-hero__overlay {
align-items: center;
background: rgba(14, 116, 144, 0.55);
bottom: 0;
color: #fff;
display: flex;
font-size: 1rem;
font-weight: 700;
justify-content: center;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.project-hero__overlay--uploading {
background: rgba(15, 23, 42, 0.4);
}
</style>

View File

@@ -0,0 +1,210 @@
<script setup>
import { computed, inject } from 'vue';
defineProps({
metadata: {
type: Object,
default: () => ({ clientName: '', awarded: [], description: '' }),
},
mobilePreview: {
type: Boolean,
default: false,
},
});
const updateMetadata = inject('editorUpdateMetadata', null);
const isEditable = computed(() => !!updateMetadata);
function onClientNameBlur(e) {
updateMetadata?.('clientName', e.target.innerText.trim());
}
function onAwardedBlur(e) {
const lines = e.target.innerText.split('\n').map((s) => s.trim()).filter(Boolean);
updateMetadata?.('awarded', lines);
}
function onDescriptionBlur(e) {
updateMetadata?.('description', e.target.innerHTML.trim());
}
</script>
<template>
<section class="project-metadata" :class="{ 'project-metadata--mobile': mobilePreview }">
<!-- CLIENT -->
<article :class="{ 'project-metadata__article--editable': isEditable }">
<span>Client</span>
<strong
v-if="isEditable"
contenteditable="true"
class="project-metadata__editable"
:data-placeholder="'Add client name'"
@blur="onClientNameBlur"
v-text="metadata.clientName || ''"
></strong>
<strong v-else>{{ metadata.clientName || 'Add client name' }}</strong>
</article>
<!-- AWARDED -->
<article :class="{ 'project-metadata__article--editable': isEditable }">
<span>Awarded</span>
<div
v-if="isEditable"
contenteditable="true"
class="project-metadata__editable project-metadata__awarded-edit"
:data-placeholder="'Add award lines'"
@blur="onAwardedBlur"
v-text="(metadata.awarded || []).join('\n') || ''"
></div>
<template v-else>
<ul v-if="metadata.awarded?.length">
<li v-for="award in metadata.awarded" :key="award">{{ award }}</li>
</ul>
<p v-else>Add award lines</p>
</template>
</article>
<!-- DESCRIPTION -->
<article class="project-metadata__full" :class="{ 'project-metadata__article--editable': isEditable }">
<span>Description</span>
<div
v-if="isEditable"
contenteditable="true"
class="project-metadata__editable project-metadata__description-edit"
:data-placeholder="'Add a project description to populate this summary row.'"
@blur="onDescriptionBlur"
v-html="metadata.description || ''"
></div>
<div v-else-if="metadata.description" class="project-metadata__description" v-html="metadata.description"></div>
<p v-else>Add a project description to populate this summary row.</p>
</article>
</section>
</template>
<style scoped>
.project-metadata {
container-type: inline-size;
display: grid;
gap: 1.25rem;
grid-template-columns: 1fr 1fr;
}
.project-metadata article:last-child {
grid-column: 1 / -1;
}
.project-metadata__full {
grid-column: 1 / -1;
}
.project-metadata--mobile {
grid-template-columns: 1fr;
}
.project-metadata--mobile .project-metadata__full {
grid-column: auto;
}
article {
background: var(--project-surface, rgba(255, 255, 255, 0.74));
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 1.35rem;
display: grid;
gap: 0.85rem;
padding: 1.35rem;
}
.project-metadata__article--editable {
cursor: text;
transition: border-color 0.15s, box-shadow 0.15s;
}
.project-metadata__article--editable:hover {
border-color: rgba(15, 23, 42, 0.18);
box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.06);
}
.project-metadata__article--editable:focus-within {
border-color: rgba(14, 116, 144, 0.45);
box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.12);
}
.project-metadata__editable {
outline: none;
}
.project-metadata__editable:empty::before {
color: var(--project-muted, #6b7280);
content: attr(data-placeholder);
pointer-events: none;
}
.project-metadata__awarded-edit {
color: var(--project-ink, #111827);
font-size: 1rem;
line-height: 1.6;
white-space: pre-wrap;
}
.project-metadata__description-edit {
color: var(--project-ink, #111827);
line-height: 1.6;
}
span {
color: var(--project-muted, #6b7280);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
strong,
p,
li {
color: var(--project-ink, #111827);
line-height: 1.6;
}
strong {
font-size: 1.05rem;
}
ul,
p {
margin: 0;
padding: 0;
}
ul {
list-style: none;
}
@container (max-width: 600px) {
.project-metadata {
grid-template-columns: 1fr;
}
.project-metadata article:last-child {
grid-column: auto;
}
.project-metadata__full {
grid-column: auto;
}
}
@media (max-width: 900px) {
.project-metadata {
grid-template-columns: 1fr;
}
.project-metadata article:last-child {
grid-column: auto;
}
.project-metadata__full {
grid-column: auto;
}
}
</style>

View File

@@ -0,0 +1,204 @@
<script setup>
import Link from '@tiptap/extension-link';
import TextAlign from '@tiptap/extension-text-align';
import Underline from '@tiptap/extension-underline';
import StarterKit from '@tiptap/starter-kit';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import { watch } from 'vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue']);
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
Underline,
Link.configure({ openOnClick: false }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
],
editorProps: {
attributes: { class: 'rte__content' },
},
onUpdate({ editor: e }) {
emit('update:modelValue', e.getHTML());
},
});
// Sync external value changes (e.g. block switched)
watch(
() => props.modelValue,
(val) => {
if (editor.value && editor.value.getHTML() !== val) {
editor.value.commands.setContent(val, false);
}
},
);
const setLink = () => {
const url = window.prompt('URL', editor.value?.getAttributes('link').href ?? '');
if (url === null) return;
if (url === '') {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
};
</script>
<template>
<div class="rte" v-if="editor">
<div class="rte__toolbar">
<!-- Text style -->
<div class="rte__group">
<button type="button" :class="{ 'rte__btn--active': editor.isActive('bold') }" @click="editor.chain().focus().toggleBold().run()" title="Bold"><strong>B</strong></button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive('italic') }" @click="editor.chain().focus().toggleItalic().run()" title="Italic"><em>I</em></button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive('underline') }" @click="editor.chain().focus().toggleUnderline().run()" title="Underline"><u>U</u></button>
</div>
<!-- Headings -->
<div class="rte__group">
<button type="button" :class="{ 'rte__btn--active': editor.isActive('heading', { level: 2 }) }" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" title="Heading 2">H2</button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive('heading', { level: 3 }) }" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" title="Heading 3">H3</button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive('paragraph') }" @click="editor.chain().focus().setParagraph().run()" title="Paragraph">P</button>
</div>
<!-- Lists -->
<div class="rte__group">
<button type="button" :class="{ 'rte__btn--active': editor.isActive('bulletList') }" @click="editor.chain().focus().toggleBulletList().run()" title="Bullet list"> List</button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive('orderedList') }" @click="editor.chain().focus().toggleOrderedList().run()" title="Numbered list">1. List</button>
</div>
<!-- Alignment -->
<div class="rte__group">
<button type="button" :class="{ 'rte__btn--active': editor.isActive({ textAlign: 'left' }) }" @click="editor.chain().focus().setTextAlign('left').run()" title="Align left"></button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive({ textAlign: 'center' }) }" @click="editor.chain().focus().setTextAlign('center').run()" title="Align center"></button>
<button type="button" :class="{ 'rte__btn--active': editor.isActive({ textAlign: 'right' }) }" @click="editor.chain().focus().setTextAlign('right').run()" title="Align right"></button>
</div>
<!-- Link -->
<div class="rte__group">
<button type="button" :class="{ 'rte__btn--active': editor.isActive('link') }" @click="setLink" title="Set link">🔗</button>
<button v-if="editor.isActive('link')" type="button" @click="editor.chain().focus().unsetLink().run()" title="Remove link"> Link</button>
</div>
<!-- History -->
<div class="rte__group">
<button type="button" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()" title="Undo"></button>
<button type="button" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()" title="Redo"></button>
</div>
</div>
<EditorContent :editor="editor" />
</div>
</template>
<style scoped>
.rte {
border: 1px solid rgba(14, 116, 144, 0.35);
border-radius: 0.75rem;
overflow: hidden;
}
.rte__toolbar {
align-items: center;
background: rgba(248, 250, 252, 0.95);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.4rem 0.5rem;
}
.rte__group {
align-items: center;
border-right: 1px solid rgba(15, 23, 42, 0.1);
display: flex;
gap: 0.1rem;
padding-right: 0.35rem;
}
.rte__group:last-child {
border-right: 0;
padding-right: 0;
}
.rte__toolbar button {
background: transparent;
border: 0;
border-radius: 0.4rem;
color: #374151;
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
min-width: 1.8rem;
padding: 0.25rem 0.4rem;
transition: background 0.1s, color 0.1s;
}
.rte__toolbar button:hover:not(:disabled) {
background: rgba(14, 116, 144, 0.1);
color: #0f766e;
}
.rte__toolbar button:disabled {
color: #cbd5e1;
cursor: default;
}
.rte__btn--active {
background: rgba(14, 116, 144, 0.15) !important;
color: #0f766e !important;
}
/* Editor area — :deep() needed to reach ProseMirror's non-scoped DOM */
:deep(.rte__content) {
min-height: 8rem;
outline: none;
padding: 0.75rem 1rem;
}
:deep(.rte__content p),
:deep(.rte__content h2),
:deep(.rte__content h3),
:deep(.rte__content ul),
:deep(.rte__content ol) {
margin: 0 0 0.6em;
}
:deep(.rte__content p:last-child),
:deep(.rte__content h2:last-child),
:deep(.rte__content h3:last-child),
:deep(.rte__content ul:last-child),
:deep(.rte__content ol:last-child) {
margin-bottom: 0;
}
:deep(.rte__content h2) {
font-size: 1.2rem;
font-weight: 700;
}
:deep(.rte__content h3) {
font-size: 1rem;
font-weight: 700;
}
:deep(.rte__content ul),
:deep(.rte__content ol) {
padding-left: 1.4rem;
}
:deep(.rte__content a) {
color: #0f766e;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,281 @@
<script setup>
import { computed, inject } from 'vue';
import { useImageUpload } from '../../composables/useImageUpload';
import { useIntersectionActivation } from '../../composables/useIntersectionActivation';
import { getBunnyEmbedUrl, getFrameIoEmbedUrl, getYouTubeEmbedUrl } from '../../schema/projectSchema';
const props = defineProps({
slotData: {
type: Object,
required: true,
},
blockId: {
type: String,
required: true,
},
slotKey: {
type: String,
default: 'slot',
},
activeLang: {
type: String,
default: '',
},
column: {
type: Boolean,
default: false,
},
});
const uploadUrl = inject('editorUploadUrl', null);
const updateBlockImage = inject('editorUpdateBlockImage', null);
// Image upload for image-type slot
const { isDragOver, isUploading, fileInputRef, onDragOver, onDragLeave, onDrop, openPicker, onFileSelected } = useImageUpload(
() => uploadUrl?.value ?? null,
(url) => updateBlockImage?.(props.blockId, props.slotKey, null, url),
);
const resolveAlt = (alt, fallback) => {
if (!alt) return fallback;
if (typeof alt === 'string') return alt || fallback;
return alt[props.activeLang] || alt[Object.keys(alt)[0]] || fallback;
};
// Video activation
const { isActive, target } = useIntersectionActivation();
const youtubeUrl = computed(() => getYouTubeEmbedUrl(props.slotData?.media));
const frameIoUrl = computed(() => getFrameIoEmbedUrl(props.slotData?.media?.url));
const bunnyUrl = computed(() => getBunnyEmbedUrl(props.slotData?.media));
// Text display
const displayContent = computed(() => {
const c = props.slotData?.content;
if (typeof c === 'string') return c;
if (c && typeof c === 'object') {
return c[props.activeLang] || c[Object.keys(c)[0]] || '';
}
return '';
});
</script>
<template>
<!-- TEXT -->
<section v-if="slotData.type === 'text'" class="slot-text">
<div v-if="displayContent" class="slot-text__body" v-html="displayContent"></div>
<p v-else class="slot-text__placeholder">Add narrative copy for this text slot.</p>
</section>
<!-- IMAGE -->
<figure
v-else-if="slotData.type === 'image'"
class="slot-image"
:class="{
'slot-image--column': column,
'slot-image--over': isDragOver,
'slot-image--uploading': isUploading,
'slot-image--editable': !!uploadUrl?.value,
}"
@dragover="uploadUrl?.value ? onDragOver($event) : null"
@dragleave="onDragLeave"
@drop="uploadUrl?.value ? onDrop($event) : null"
@click="uploadUrl?.value ? openPicker() : null"
>
<img v-if="slotData.image?.url" :src="slotData.image.url" :alt="resolveAlt(slotData.image.alt, 'Project image')" loading="lazy">
<div v-else class="slot-image__placeholder">
{{ isUploading ? 'Uploading…' : (uploadUrl?.value ? 'Drop or click to upload.' : 'Add image URL.') }}
</div>
<div v-if="isDragOver" class="slot-image__overlay"></div>
<div v-if="isUploading && slotData.image?.url" class="slot-image__overlay slot-image__overlay--uploading"></div>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="onFileSelected">
</figure>
<!-- VIDEO -->
<section
v-else-if="slotData.type === 'video'"
ref="target"
class="slot-video"
:class="{ 'slot-video--column': column }"
>
<iframe
v-if="slotData.media?.type === 'youtube' && isActive && youtubeUrl"
:src="youtubeUrl"
title="Video slot"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
<iframe
v-else-if="slotData.media?.type === 'frameio' && isActive && frameIoUrl"
:src="frameIoUrl"
title="Video slot"
loading="lazy"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
<div
v-else-if="slotData.media?.type === 'bunny' && isActive && bunnyUrl"
class="slot-video__bunny"
>
<iframe
:src="bunnyUrl"
loading="lazy"
style="border:0;position:absolute;top:0;height:100%;width:100%;"
allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;"
allowfullscreen
/>
</div>
<video
v-else-if="slotData.media?.type === 'video' && isActive && slotData.media?.url"
:autoplay="slotData.media?.autoplay === true"
:muted="slotData.media?.muted === true"
:loop="slotData.media?.loop === true"
controls
playsinline
preload="metadata"
>
<source :src="slotData.media.url">
</video>
<div v-else class="slot-video__placeholder">Add a video URL to render this slot.</div>
</section>
</template>
<style scoped>
/* ---- Text ---- */
.slot-text {
align-items: center;
display: flex;
justify-content: center;
min-height: 8rem;
padding: 2rem;
text-align: center;
}
.slot-text__placeholder {
color: var(--project-muted, #6b7280);
font-size: clamp(1.05rem, 1.8vw, 1.4rem);
line-height: 1.75;
margin: 0;
}
:deep(.slot-text__body p),
:deep(.slot-text__body h2),
:deep(.slot-text__body h3),
:deep(.slot-text__body ul),
:deep(.slot-text__body ol) {
color: var(--project-ink, #111827);
font-size: clamp(1.05rem, 1.8vw, 1.4rem);
line-height: 1.75;
margin: 0 0 0.75em;
}
:deep(.slot-text__body p:last-child),
:deep(.slot-text__body h2:last-child),
:deep(.slot-text__body ul:last-child) {
margin-bottom: 0;
}
:deep(.slot-text__body h2) {
font-size: clamp(1.3rem, 2.2vw, 1.8rem);
font-weight: 700;
}
:deep(.slot-text__body h3) {
font-size: clamp(1.1rem, 1.9vw, 1.5rem);
font-weight: 700;
}
/* ---- Image ---- */
.slot-image {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(148, 163, 184, 0.15));
border-radius: 1.65rem;
margin: 0;
overflow: hidden;
position: relative;
transition: outline-color 0.15s;
}
.slot-image--column {
aspect-ratio: 4 / 4;
border-radius: 1.5rem;
}
.slot-image--editable {
cursor: pointer;
}
.slot-image--over {
outline: 2px dashed rgba(14, 116, 144, 0.7);
outline-offset: -2px;
}
.slot-image img {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.slot-image--uploading img {
opacity: 0.5;
}
.slot-image__placeholder {
align-items: center;
color: var(--project-muted, #6b7280);
display: flex;
height: 100%;
justify-content: center;
padding: 2rem;
text-align: center;
}
.slot-image__overlay {
background: rgba(14, 116, 144, 0.15);
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.slot-image__overlay--uploading {
background: rgba(15, 23, 42, 0.3);
}
/* ---- Video ---- */
.slot-video {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(148, 163, 184, 0.15));
border-radius: 1.65rem;
overflow: hidden;
}
.slot-video--column {
aspect-ratio: 1 / 1;
border-radius: 1.5rem;
}
.slot-video iframe,
.slot-video video {
border: 0;
display: block;
height: 100%;
width: 100%;
}
.slot-video__bunny {
padding-top: 56.25%;
position: relative;
}
.slot-video__placeholder {
align-items: center;
color: var(--project-muted, #6b7280);
display: flex;
height: 100%;
justify-content: center;
padding: 2rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import BlockSlot from './BlockSlot.vue';
defineProps({
block: {
type: Object,
required: true,
},
activeLang: {
type: String,
default: '',
},
});
</script>
<template>
<section class="block-full-width">
<BlockSlot
:slot-data="block.slot"
:block-id="block.id"
slot-key="slot"
:active-lang="activeLang"
:column="false"
/>
</section>
</template>
<style scoped>
.block-full-width {
width: 100%;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup>
import { inject } from 'vue';
import { useImageUpload } from '../../composables/useImageUpload';
const props = defineProps({
block: {
type: Object,
required: true,
},
activeLang: {
type: String,
default: '',
},
});
const resolveAlt = (alt, fallback) => {
if (!alt) return fallback;
if (typeof alt === 'string') return alt || fallback;
return alt[props.activeLang] || alt[Object.keys(alt)[0]] || fallback;
};
const uploadUrl = inject('editorUploadUrl', null);
const updateBlockImage = inject('editorUpdateBlockImage', null);
const { isDragOver, isUploading, fileInputRef, onDragOver, onDragLeave, onDrop, openPicker, onFileSelected } = useImageUpload(
() => uploadUrl?.value ?? null,
(url) => updateBlockImage?.(props.block.id, 'image', null, url),
);
</script>
<template>
<section
class="block-image-full"
:class="{ 'block-image-full--over': isDragOver, 'block-image-full--uploading': isUploading, 'block-image-full--editable': !!uploadUrl?.value }"
@dragover="uploadUrl?.value ? onDragOver($event) : null"
@dragleave="onDragLeave"
@drop="uploadUrl?.value ? onDrop($event) : null"
@click="uploadUrl?.value ? openPicker() : null"
>
<img v-if="block.image?.url" :src="block.image.url" :alt="resolveAlt(block.image.alt, 'Project image')" loading="lazy">
<div v-else class="block-image-full__placeholder">
{{ isUploading ? 'Uploading…' : (uploadUrl?.value ? 'Drop image here or click to upload.' : 'Add a full-width image URL.') }}
</div>
<div v-if="isDragOver" class="block-image-full__drop-overlay">Drop to upload</div>
<div v-if="isUploading && block.image?.url" class="block-image-full__uploading-overlay">Uploading</div>
</section>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="onFileSelected">
</template>
<style scoped>
.block-image-full {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(148, 163, 184, 0.15));
border-radius: 1.65rem;
overflow: hidden;
position: relative;
transition: outline-color 0.15s;
}
.block-image-full--editable {
cursor: pointer;
}
.block-image-full--over {
outline: 2px dashed rgba(14, 116, 144, 0.7);
outline-offset: -2px;
}
img {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.block-image-full--uploading img {
opacity: 0.5;
}
.block-image-full__placeholder {
align-items: center;
color: var(--project-muted, #6b7280);
display: flex;
height: 100%;
justify-content: center;
padding: 2rem;
text-align: center;
}
.block-image-full__drop-overlay,
.block-image-full__uploading-overlay {
align-items: center;
background: rgba(14, 116, 144, 0.55);
bottom: 0;
color: #fff;
display: flex;
font-size: 1rem;
font-weight: 700;
justify-content: center;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.block-image-full__uploading-overlay {
background: rgba(15, 23, 42, 0.4);
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
block: {
type: Object,
required: true,
},
activeLang: {
type: String,
default: '',
},
});
const displayContent = computed(() => {
const c = props.block.content;
// Legacy: plain string
if (typeof c === 'string') return c;
// Object: try active lang, then first available, then empty
if (c && typeof c === 'object') {
return c[props.activeLang] || c[Object.keys(c)[0]] || '';
}
return '';
});
</script>
<template>
<section class="block-text">
<div v-if="displayContent" class="block-text__body" v-html="displayContent"></div>
<p v-else class="block-text__placeholder">Add narrative copy for this full-width text row.</p>
</section>
</template>
<style scoped>
.block-text {
margin: 0 auto;
max-width: 56rem;
text-align: center;
}
.block-text__placeholder {
color: var(--project-muted, #6b7280);
font-size: clamp(1.05rem, 1.8vw, 1.4rem);
line-height: 1.75;
margin: 0;
}
:deep(.block-text__body p),
:deep(.block-text__body h2),
:deep(.block-text__body h3),
:deep(.block-text__body ul),
:deep(.block-text__body ol) {
color: var(--project-ink, #111827);
font-size: clamp(1.05rem, 1.8vw, 1.4rem);
line-height: 1.75;
margin: 0 0 0.75em;
}
:deep(.block-text__body p:last-child),
:deep(.block-text__body h2:last-child),
:deep(.block-text__body h3:last-child),
:deep(.block-text__body ul:last-child),
:deep(.block-text__body ol:last-child) {
margin-bottom: 0;
}
:deep(.block-text__body h2) {
font-size: clamp(1.3rem, 2.2vw, 1.8rem);
font-weight: 700;
}
:deep(.block-text__body h3) {
font-size: clamp(1.1rem, 1.9vw, 1.5rem);
font-weight: 700;
}
:deep(.block-text__body ul),
:deep(.block-text__body ol) {
display: inline-block;
padding-left: 1.4rem;
text-align: left;
}
:deep(.block-text__body a) {
color: #0f766e;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup>
import { computed } from 'vue';
import { useIntersectionActivation } from '../../composables/useIntersectionActivation';
import { getBunnyEmbedUrl, getFrameIoEmbedUrl, getYouTubeEmbedUrl } from '../../schema/projectSchema';
const props = defineProps({
slotData: {
type: Object,
required: true,
},
activeLang: {
type: String,
default: '',
},
});
const { isActive, target } = useIntersectionActivation();
const youtubeUrl = computed(() => getYouTubeEmbedUrl(props.slotData?.media));
const frameIoUrl = computed(() => getFrameIoEmbedUrl(props.slotData?.media?.url));
const bunnyUrl = computed(() => getBunnyEmbedUrl(props.slotData?.media));
const displayContent = computed(() => {
const c = props.slotData?.content;
if (typeof c === 'string') return c;
if (c && typeof c === 'object') {
return c[props.activeLang] || c[Object.keys(c)[0]] || '';
}
return '';
});
const resolveAlt = (alt, fallback) => {
if (!alt) return fallback;
if (typeof alt === 'string') return alt || fallback;
return alt[props.activeLang] || alt[Object.keys(alt)[0]] || fallback;
};
</script>
<template>
<!-- TEXT -->
<div v-if="slotData.type === 'text'" class="project-text-block" v-html="displayContent"></div>
<!-- IMAGE -->
<img
v-else-if="slotData.type === 'image' && slotData.image?.url"
:src="slotData.image.url"
:alt="resolveAlt(slotData.image?.alt, '')"
loading="lazy"
>
<!-- VIDEO -->
<div v-else-if="slotData.type === 'video'" ref="target" class="project-video-wrap">
<iframe
v-if="slotData.media?.type === 'youtube' && isActive && youtubeUrl"
:src="youtubeUrl"
title="Project video"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
<iframe
v-else-if="slotData.media?.type === 'frameio' && isActive && frameIoUrl"
:src="frameIoUrl"
title="Project video"
loading="lazy"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
<div v-else-if="slotData.media?.type === 'bunny' && bunnyUrl" class="project-video-wrap__bunny">
<iframe
v-if="isActive"
:src="bunnyUrl"
loading="lazy"
allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;"
allowfullscreen
/>
</div>
<video
v-else-if="slotData.media?.type === 'video' && isActive && slotData.media?.url"
class="project-video"
:autoplay="slotData.media?.autoplay === true"
:muted="slotData.media?.muted === true"
:loop="slotData.media?.loop === true"
playsinline
>
<source :src="slotData.media.url" type="video/mp4">
</video>
</div>
</template>
<style scoped>
img {
display: block;
height: auto;
width: 100%;
}
.project-text-block {
color: #f5f5f5;
line-height: 1.6;
padding: 1rem 0;
}
.project-video-wrap {
aspect-ratio: 16 / 9;
overflow: hidden;
position: relative;
width: 100%;
}
.project-video-wrap iframe,
.project-video-wrap video {
border: 0;
display: block;
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
.project-video-wrap__bunny {
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
.project-video-wrap__bunny iframe {
height: 100%;
width: 100%;
}
video.project-video {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
</style>

View File

@@ -0,0 +1,139 @@
<script setup>
import { inject } from 'vue';
import { useImageUpload } from '../../composables/useImageUpload';
const props = defineProps({
block: {
type: Object,
required: true,
},
activeLang: {
type: String,
default: '',
},
});
const resolveAlt = (alt, fallback) => {
if (!alt) return fallback;
if (typeof alt === 'string') return alt || fallback;
return alt[props.activeLang] || alt[Object.keys(alt)[0]] || fallback;
};
const uploadUrl = inject('editorUploadUrl', null);
const updateBlockImage = inject('editorUpdateBlockImage', null);
const { isDragOver: isDragOver0, isUploading: isUploading0, fileInputRef: fileInputRef0, onDragOver: onDragOver0, onDragLeave: onDragLeave0, onDrop: onDrop0, openPicker: openPicker0, onFileSelected: onFileSelected0 } = useImageUpload(
() => uploadUrl?.value ?? null,
(url) => updateBlockImage?.(props.block.id, 'images', 0, url),
);
const { isDragOver: isDragOver1, isUploading: isUploading1, fileInputRef: fileInputRef1, onDragOver: onDragOver1, onDragLeave: onDragLeave1, onDrop: onDrop1, openPicker: openPicker1, onFileSelected: onFileSelected1 } = useImageUpload(
() => uploadUrl?.value ?? null,
(url) => updateBlockImage?.(props.block.id, 'images', 1, url),
);
const dragHandlers = [
{ isDragOver: isDragOver0, isUploading: isUploading0, fileInputRef: fileInputRef0, onDragOver: onDragOver0, onDragLeave: onDragLeave0, onDrop: onDrop0, openPicker: openPicker0, onFileSelected: onFileSelected0 },
{ isDragOver: isDragOver1, isUploading: isUploading1, fileInputRef: fileInputRef1, onDragOver: onDragOver1, onDragLeave: onDragLeave1, onDrop: onDrop1, openPicker: openPicker1, onFileSelected: onFileSelected1 },
];
</script>
<template>
<section class="block-images-split">
<figure
v-for="(image, index) in block.images"
:key="`${block.id}-${index}`"
:class="{
'block-images-split__figure--over': dragHandlers[index]?.isDragOver.value,
'block-images-split__figure--uploading': dragHandlers[index]?.isUploading.value,
'block-images-split__figure--editable': !!uploadUrl?.value,
}"
@dragover="uploadUrl?.value ? dragHandlers[index]?.onDragOver($event) : null"
@dragleave="dragHandlers[index]?.onDragLeave()"
@drop="uploadUrl?.value ? dragHandlers[index]?.onDrop($event) : null"
@click="uploadUrl?.value ? dragHandlers[index]?.openPicker() : null"
>
<img v-if="image?.url" :src="image.url" :alt="resolveAlt(image.alt, `Project image ${index + 1}`)" loading="lazy">
<div v-else class="block-images-split__placeholder">
{{ dragHandlers[index]?.isUploading.value ? 'Uploading…' : (uploadUrl?.value ? 'Drop or click to upload.' : `Drop in image URL ${index + 1}`) }}
</div>
<div v-if="dragHandlers[index]?.isDragOver.value" class="block-images-split__overlay">Drop to upload</div>
<div v-if="dragHandlers[index]?.isUploading.value && image?.url" class="block-images-split__overlay block-images-split__overlay--uploading">Uploading</div>
<input :ref="dragHandlers[index]?.fileInputRef" type="file" accept="image/*" style="display:none" @change="dragHandlers[index]?.onFileSelected($event)">
</figure>
</section>
</template>
<style scoped>
.block-images-split {
display: grid;
gap: 1.4rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
figure {
aspect-ratio: 4 / 4;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(148, 163, 184, 0.15));
border-radius: 1.5rem;
margin: 0;
overflow: hidden;
position: relative;
transition: outline-color 0.15s;
}
.block-images-split__figure--editable {
cursor: pointer;
}
.block-images-split__figure--over {
outline: 2px dashed rgba(14, 116, 144, 0.7);
outline-offset: -2px;
}
img {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.block-images-split__figure--uploading img {
opacity: 0.5;
}
.block-images-split__placeholder {
align-items: center;
color: var(--project-muted, #6b7280);
display: flex;
height: 100%;
justify-content: center;
padding: 2rem;
text-align: center;
}
.block-images-split__overlay {
align-items: center;
background: rgba(14, 116, 144, 0.55);
bottom: 0;
color: #fff;
display: flex;
font-size: 0.9rem;
font-weight: 700;
justify-content: center;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.block-images-split__overlay--uploading {
background: rgba(15, 23, 42, 0.4);
}
@media (max-width: 900px) {
.block-images-split {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup>
import BlockSlot from './BlockSlot.vue';
defineProps({
block: {
type: Object,
required: true,
},
activeLang: {
type: String,
default: '',
},
});
</script>
<template>
<section class="block-two-columns">
<BlockSlot
:slot-data="block.left"
:block-id="block.id"
slot-key="left"
:active-lang="activeLang"
:column="true"
/>
<BlockSlot
:slot-data="block.right"
:block-id="block.id"
slot-key="right"
:active-lang="activeLang"
:column="true"
/>
</section>
</template>
<style scoped>
.block-two-columns {
align-items: center;
display: grid;
gap: 1.4rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup>
import { computed } from 'vue';
import { useIntersectionActivation } from '../../composables/useIntersectionActivation';
import { getBunnyEmbedUrl, getFrameIoEmbedUrl, getYouTubeEmbedUrl } from '../../schema/projectSchema';
const props = defineProps({
block: {
type: Object,
required: true,
},
});
const { isActive, target } = useIntersectionActivation();
const youtubeUrl = computed(() => getYouTubeEmbedUrl(props.block?.media));
const frameIoUrl = computed(() => getFrameIoEmbedUrl(props.block?.media?.url));
const bunnyUrl = computed(() => getBunnyEmbedUrl(props.block?.media));
</script>
<template>
<section ref="target" class="block-video">
<iframe
v-if="block.media?.type === 'youtube' && isActive && youtubeUrl"
:src="youtubeUrl"
title="Project video block"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
/>
<iframe
v-else-if="block.media?.type === 'frameio' && isActive && frameIoUrl"
:src="frameIoUrl"
title="Project video block"
loading="lazy"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
<iframe
v-else-if="block.media?.type === 'bunny' && isActive && bunnyUrl"
:src="bunnyUrl"
title="Project video block"
loading="lazy"
style="border:0;position:absolute;top:0;height:100%;width:100%;"
allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;"
allowfullscreen
/>
<video
v-else-if="block.media?.type === 'video' && isActive && block.media?.url"
:autoplay="block.media?.autoplay === true"
:muted="block.media?.muted === true"
:loop="block.media?.loop === true"
controls
playsinline
preload="metadata"
>
<source :src="block.media.url">
</video>
<div v-else class="block-video__placeholder">Add a video block URL to render this row.</div>
</section>
</template>
<style scoped>
.block-video {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.08), rgba(148, 163, 184, 0.15));
border-radius: 1.65rem;
overflow: hidden;
}
iframe,
video {
border: 0;
display: block;
height: 100%;
width: 100%;
}
.block-video__placeholder {
align-items: center;
color: var(--project-muted, #6b7280);
display: flex;
height: 100%;
justify-content: center;
padding: 2rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,101 @@
import { inject, ref } from 'vue';
/**
* Shared image upload composable used by both the left-panel ImageDropZone
* and the right-side preview block drop zones.
*
* @param {() => string | null} getUploadUrl A getter (or plain string) for the upload endpoint.
* @param {(url: string) => void} onSuccess Called immediately with a blob URL, then again with the server URL.
*/
export const useImageUpload = (getUploadUrl, onSuccess) => {
const isDragOver = ref(false);
const isUploading = ref(false);
const error = ref(null);
// Plugs into the parent editor's pending-upload counter so the form submit
// can be blocked while this upload is in flight.
const pendingUploads = inject('editorPendingUploads', null);
const uploadFile = async (file) => {
if (!file || !file.type.startsWith('image/')) {
return;
}
const uploadUrl = typeof getUploadUrl === 'function' ? getUploadUrl() : getUploadUrl;
if (!uploadUrl) {
return;
}
// Emit blob URL immediately so the preview renders at once
const blobUrl = URL.createObjectURL(file);
onSuccess(blobUrl);
isUploading.value = true;
error.value = null;
if (pendingUploads) pendingUploads.value++;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
const formData = new FormData();
formData.append('image', file);
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken },
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
URL.revokeObjectURL(blobUrl);
onSuccess(data.url);
} catch {
error.value = 'Upload failed. Please try again.';
URL.revokeObjectURL(blobUrl);
onSuccess('');
} finally {
isUploading.value = false;
if (pendingUploads) pendingUploads.value--;
}
};
const onDragOver = (event) => {
event.preventDefault();
isDragOver.value = true;
};
const onDragLeave = () => {
isDragOver.value = false;
};
const onDrop = (event) => {
event.preventDefault();
isDragOver.value = false;
const file = event.dataTransfer?.files?.[0];
if (file) {
uploadFile(file);
}
};
const fileInputRef = ref(null);
const openPicker = () => {
if (!fileInputRef.value) return;
fileInputRef.value.value = '';
fileInputRef.value.click();
};
const onFileSelected = (event) => {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
};
return { isDragOver, isUploading, error, fileInputRef, onDragOver, onDragLeave, onDrop, openPicker, onFileSelected, uploadFile };
};

View File

@@ -0,0 +1,40 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
export const useIntersectionActivation = (options = { rootMargin: '180px 0px' }) => {
const target = ref(null);
const isActive = ref(false);
let observer;
onMounted(() => {
if (typeof IntersectionObserver === 'undefined') {
isActive.value = true;
return;
}
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) {
return;
}
isActive.value = true;
observer?.disconnect();
});
}, options);
if (target.value) {
observer.observe(target.value);
} else {
isActive.value = true;
}
});
onBeforeUnmount(() => {
observer?.disconnect();
});
return {
target,
isActive,
};
};

View File

@@ -0,0 +1,497 @@
const BLOCK_TYPES = ['FullWidth', 'TwoColumns'];
const SLOT_TYPES = ['text', 'image', 'video'];
const MEDIA_TYPES = ['youtube', 'frameio', 'bunny', 'video', 'image'];
let blockCounter = 0;
const nextBlockId = (prefix = 'block') => `${prefix}-${Date.now()}-${blockCounter++}`;
const toString = (value) => (typeof value === 'string' ? value : '');
// Blob URLs are temporary object URLs only valid in the current browser session.
// Strip them so they are never serialized into the saved JSON structure.
const sanitizeUrl = (value) => {
const str = toString(value);
return str.startsWith('blob:') ? '' : str;
};
const normalizeAwards = (value) => {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => toString(item).trim()).filter(Boolean);
}
return toString(value)
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
};
const normalizeMedia = (media) => {
const candidate = media && typeof media === 'object' ? media : {};
const type = MEDIA_TYPES.includes(candidate.type) ? candidate.type : 'image';
return {
type,
url: sanitizeUrl(candidate.url),
poster: sanitizeUrl(candidate.poster),
autoplay: candidate.autoplay === true,
loop: candidate.loop === true,
muted: candidate.muted === true,
};
};
const normalizeImage = (image, defaultLang = '') => {
const candidate = image && typeof image === 'object' ? image : {};
const rawAlt = candidate.alt;
let alt;
if (!rawAlt) {
alt = {};
} else if (typeof rawAlt === 'string') {
alt = defaultLang ? { [defaultLang]: rawAlt } : { _: rawAlt };
} else if (typeof rawAlt === 'object') {
alt = Object.fromEntries(Object.entries(rawAlt).map(([k, v]) => [k, toString(v)]));
} else {
alt = {};
}
return {
url: sanitizeUrl(candidate.url),
alt,
};
};
export const PROJECT_SCHEMA_EXAMPLE = {
header: {
headline: 'Modern Office Design',
},
thumbnailMedia: {
type: 'image',
url: '',
poster: '',
},
heroMedia: {
type: 'youtube',
url: 'https://www.youtube.com/watch?v=ScMzIvxBSi4',
},
metadata: {
clientName: 'SOZ',
awarded: ['SOF - Gold Award - Art Direction', 'SOF - Silver Award - Design'],
description: 'A modular project schema that can drive both the public page and the cPad editor preview.',
},
contentBlocks: [
{
id: nextBlockId(),
type: 'FullWidth',
slot: {
type: 'text',
content: 'Project overview text rendered inside a full-width row.',
image: { url: '', alt: '' },
media: { type: 'youtube', url: '', poster: '' },
},
},
{
id: nextBlockId(),
type: 'TwoColumns',
left: {
type: 'image',
content: {},
image: { url: 'https://picsum.photos/1200/900?random=11', alt: 'Left project visual' },
media: { type: 'youtube', url: '', poster: '' },
},
right: {
type: 'image',
content: {},
image: { url: 'https://picsum.photos/1200/900?random=12', alt: 'Right project visual' },
media: { type: 'youtube', url: '', poster: '' },
},
},
{
id: nextBlockId(),
type: 'FullWidth',
slot: {
type: 'video',
content: {},
image: { url: '', alt: '' },
media: { type: 'youtube', url: 'https://www.youtube.com/watch?v=ysz5S6PUM-U', poster: '' },
},
},
],
};
export const createEmptyProjectSchema = () => ({
header: {
headline: '',
subline: '',
},
thumbnailMedia: {
type: 'image',
url: '',
poster: '',
autoplay: false,
loop: false,
muted: false,
},
heroMedia: {
type: 'image',
url: '',
poster: '',
},
metadata: {
clientName: '',
awarded: [],
description: '',
},
contentBlocks: [],
});
export const createBlock = (type) => {
const createSlot = (slotType = 'text') => ({
type: slotType,
content: {},
image: { url: '', alt: {} },
media: { type: 'youtube', url: '', poster: '' },
});
if (type === 'TwoColumns') {
return {
id: nextBlockId(),
type: 'TwoColumns',
name: '',
hidden: false,
left: createSlot('image'),
right: createSlot('image'),
};
}
// FullWidth (default)
return {
id: nextBlockId(),
type: 'FullWidth',
name: '',
hidden: false,
slot: createSlot('text'),
};
};
const normalizeSlot = (slot, defaultLang = '') => {
const candidate = slot && typeof slot === 'object' ? slot : {};
const type = SLOT_TYPES.includes(candidate.type) ? candidate.type : 'text';
const rawContent = candidate.content;
let content = {};
if (rawContent && typeof rawContent === 'string') {
content = defaultLang ? { [defaultLang]: rawContent } : { _: rawContent };
} else if (rawContent && typeof rawContent === 'object') {
content = Object.fromEntries(Object.entries(rawContent).map(([k, v]) => [k, toString(v)]));
}
return {
type,
content,
image: normalizeImage(candidate.image, defaultLang),
media: normalizeMedia(candidate.media),
};
};
const normalizeBlock = (block, index, defaultLang = '') => {
const candidate = block && typeof block === 'object' ? block : {};
const type = candidate.type;
const id = toString(candidate.id) || `block-${index}`;
// --- Legacy migrations ---
if (type === 'FullWidthText') {
const rawContent = candidate.content;
let content = {};
if (rawContent && typeof rawContent === 'string') {
content = defaultLang ? { [defaultLang]: rawContent } : { _: rawContent };
} else if (rawContent && typeof rawContent === 'object') {
content = Object.fromEntries(Object.entries(rawContent).map(([k, v]) => [k, toString(v)]));
}
return {
id, type: 'FullWidth',
slot: { type: 'text', content, image: normalizeImage(null, defaultLang), media: normalizeMedia(null) },
};
}
if (type === 'FullWidthImage') {
return {
id, type: 'FullWidth',
slot: { type: 'image', content: {}, image: normalizeImage(candidate.image, defaultLang), media: normalizeMedia(null) },
};
}
if (type === 'Video') {
return {
id, type: 'FullWidth',
slot: { type: 'video', content: {}, image: normalizeImage(null, defaultLang), media: normalizeMedia(candidate.media) },
};
}
if (type === 'TwoColumnImages') {
const images = Array.isArray(candidate.images) ? candidate.images.slice(0, 2) : [];
return {
id, type: 'TwoColumns',
left: { type: 'image', content: {}, image: normalizeImage(images[0], defaultLang), media: normalizeMedia(null) },
right: { type: 'image', content: {}, image: normalizeImage(images[1], defaultLang), media: normalizeMedia(null) },
};
}
// --- New types ---
if (type === 'TwoColumns') {
return {
id,
type: 'TwoColumns',
name: toString(candidate.name) || '',
hidden: candidate.hidden === true,
left: normalizeSlot(candidate.left, defaultLang),
right: normalizeSlot(candidate.right, defaultLang),
};
}
// FullWidth (default)
return {
id, type: 'FullWidth',
name: toString(candidate.name) || '',
hidden: candidate.hidden === true,
slot: normalizeSlot(candidate.slot, defaultLang),
};
};
export const normalizeProjectSchema = (project, defaultLang = '') => {
const candidate = project && typeof project === 'object' ? project : {};
const base = createEmptyProjectSchema();
const contentBlocks = Array.isArray(candidate.contentBlocks)
? candidate.contentBlocks
: Array.isArray(candidate.blocks)
? candidate.blocks
: [];
return {
header: {
headline: toString(candidate.header?.headline ?? candidate.project_title),
subline: toString(candidate.header?.subline),
},
thumbnailMedia: normalizeMedia(candidate.thumbnailMedia ?? candidate.thumbnail_media),
heroMedia: normalizeMedia(candidate.heroMedia ?? candidate.hero_media),
metadata: {
clientName: toString(candidate.metadata?.clientName),
awarded: normalizeAwards(candidate.metadata?.awarded),
description: toString(candidate.metadata?.description),
},
contentBlocks: contentBlocks.map((b, i) => normalizeBlock(b, i, defaultLang)),
backgroundColor: toString(candidate.backgroundColor ?? base.backgroundColor),
};
};
export const createProjectSchemaFromLegacy = (legacy) => {
const candidate = legacy && typeof legacy === 'object' ? legacy : {};
const heroMedia = candidate.youtube
? { type: 'youtube', url: candidate.youtube }
: candidate.video
? { type: 'video', url: candidate.video }
: { type: 'image', url: candidate.pictureCatalogFull || candidate.pictureCover || '' };
const contentBlocks = [];
const gallery = Array.isArray(candidate.gallery) ? candidate.gallery : [];
if (gallery.length >= 2) {
contentBlocks.push({
type: 'TwoColumnImages',
images: [gallery[0], gallery[1]],
});
} else if (candidate.pictureCover || candidate.pictureCatalog) {
contentBlocks.push({
type: 'TwoColumnImages',
images: [
{ url: candidate.pictureCover || candidate.pictureCatalog || '', alt: '' },
{ url: candidate.pictureCatalog || candidate.pictureCover || '', alt: '' },
],
});
}
if (candidate.description) {
contentBlocks.push({
type: 'FullWidthText',
content: candidate.description,
});
}
if (candidate.pictureCatalogFull) {
contentBlocks.push({
type: 'FullWidthImage',
image: { url: candidate.pictureCatalogFull, alt: '' },
});
}
if (gallery.length >= 4) {
contentBlocks.push({
type: 'TwoColumnImages',
images: [gallery[2], gallery[3]],
});
}
if (candidate.video) {
contentBlocks.push({
type: 'Video',
media: { type: 'video', url: candidate.video },
});
}
return normalizeProjectSchema({
header: {
headline: candidate.headline,
},
heroMedia,
metadata: {
clientName: candidate.clientName,
awarded: candidate.awarded,
description: candidate.description,
},
contentBlocks,
});
};
export const buildInitialProject = (payload) => {
const candidate = payload && typeof payload === 'object' ? payload : {};
const defaultLang = toString(candidate.defaultLanguage);
const hasModernStructure = candidate.structure
&& typeof candidate.structure === 'object'
&& !Array.isArray(candidate.structure)
&& (candidate.structure.header || candidate.structure.heroMedia || candidate.structure.contentBlocks || candidate.structure.blocks);
if (hasModernStructure) {
return normalizeProjectSchema(candidate.structure, defaultLang);
}
if (candidate.legacy) {
return createProjectSchemaFromLegacy(candidate.legacy);
}
if (candidate.header || candidate.heroMedia || candidate.contentBlocks) {
return normalizeProjectSchema(candidate, defaultLang);
}
return normalizeProjectSchema(PROJECT_SCHEMA_EXAMPLE);
};
export const serializeProjectSchema = (project) => JSON.stringify(normalizeProjectSchema(project), null, 2);
export const getYouTubeEmbedUrl = (media) => {
const obj = media && typeof media === 'object' ? media : { url: media };
const value = toString(obj.url).trim();
if (!value) {
return '';
}
try {
const parsed = new URL(value);
const autoplay = obj.autoplay === true;
const loop = obj.loop === true;
const muted = obj.muted === true;
let embedUrl = '';
let videoId = '';
if (parsed.hostname.includes('youtu.be')) {
videoId = parsed.pathname.replace('/', '');
} else {
videoId = parsed.searchParams.get('v') || '';
}
if (!videoId && parsed.hostname.includes('youtube.com') && parsed.pathname.startsWith('/embed/')) {
videoId = parsed.pathname.split('/').pop() || '';
}
if (!videoId) {
return value;
}
embedUrl = `https://www.youtube.com/embed/${videoId}`;
const embed = new URL(embedUrl);
embed.searchParams.set('autoplay', autoplay ? '1' : '0');
embed.searchParams.set('mute', muted ? '1' : '0');
embed.searchParams.set('loop', loop ? '1' : '0');
if (loop) {
embed.searchParams.set('playlist', videoId);
}
return embed.toString();
} catch (error) {
return value;
}
};
export const getBunnyEmbedUrl = (media) => {
const obj = media && typeof media === 'object' ? media : { url: media };
const value = toString(obj.url).trim();
if (!value) {
return '';
}
try {
const parsed = new URL(value);
// Accept player.mediadelivery.net URLs directly
if (parsed.hostname === 'player.mediadelivery.net') {
// Strip existing playback params so we control them from the schema
['autoplay', 'loop', 'muted', 'preload', 'responsive'].forEach(p => parsed.searchParams.delete(p));
parsed.searchParams.set('autoplay', obj.autoplay ? 'true' : 'false');
parsed.searchParams.set('loop', obj.loop ? 'true' : 'false');
parsed.searchParams.set('muted', obj.muted ? 'true' : 'false');
parsed.searchParams.set('preload', 'true');
parsed.searchParams.set('responsive', 'true');
return parsed.toString();
}
return value;
} catch (error) {
return value;
}
};
export const getFrameIoEmbedUrl = (url) => {
const value = toString(url).trim();
if (!value) {
return '';
}
try {
const parsed = new URL(value);
// Already a player embed URL — use as-is
if (parsed.hostname === 'player.frame.io') {
return value;
}
// Review / presentation share link: https://app.frame.io/reviews/{id}
if (parsed.hostname === 'app.frame.io' && parsed.pathname.startsWith('/reviews/')) {
return value;
}
// New Frame.io share links: https://next.frame.io/share/{id}
if (parsed.hostname === 'next.frame.io') {
return value;
}
// Short share link: https://f.io/{token}
if (parsed.hostname === 'f.io') {
return value;
}
return value;
} catch (error) {
return value;
}
};
export const blockTypeOptions = [
{ type: 'FullWidth', label: 'Full Width' },
{ type: 'TwoColumns', label: 'Two Columns' },
];
export const mediaTypeOptions = MEDIA_TYPES;

259
resources/lang/en/admin.php Normal file
View File

@@ -0,0 +1,259 @@
<?php
return [
'2FA' => '2FA',
'2FA_ENABLE_DESCRIPTION' => '&amp;lt;p&amp;gt;Two factor authentication (2FA) strengthens access security by requiring two methods (also referred to as factors) to verify your identity. Two factor authentication protects against phishing, social engineering and password brute force attacks and secures your logins from attackers exploiting weak or stolen credentials.&amp;lt;/p&amp;gt; &amp;lt;p&amp;gt;To Enable Two Factor Authentication on your Account, you need to do following steps&amp;lt;/p&amp;gt; &amp;lt;ol&amp;gt; &amp;lt;li&amp;gt;Click on Generate Secret Button , To Generate a Unique secret QR code for your profile&amp;lt;/li&amp;gt; &amp;lt;li&amp;gt;Verify the OTP from Google Authenticator Mobile App&amp;lt;/li&amp;gt; &amp;lt;/ol&amp;gt;',
'2FA_ENABLE_STEP_1' => '1. Scan this barcode with your Google Authenticator App:',
'2FA_ENABLE_STEP_2' => '2.Enter the pin the code to Enable 2FA',
'2FA_MAIL' => 'mail for 2FA',
'ACCESS' => 'Access',
'ACCESS_RESTRICTED' => 'Access to this page is restricted',
'ACCESS_RESTRICTED_TEXT' => 'Please check with the site admin if you believe this is a mistake.',
'ACCESS_TO_CONTROL_PANEL' => 'Access to control panel',
'ACTION' => 'Action',
'ACTIVATE_2FA_SECURITY' => 'Activate 2FA security',
'ACTIVATE_2FA_SECURITY_DESCRIPTION' => 'When 2FA is active you will receive security code in your mailbox',
'ACTIVATE_SHARETHIS_INTEGRATION' => 'Activate ShareThis integration',
'ACTIVATE_TRUENDO_CONSENT' => 'Activate Truendo Consent',
'ACTIVE' => 'Active',
'ADD' => 'Add',
'ADD_SELECTED_PERMISSIONS' => 'Add selected permissions',
'ADD_TRANSLATION' => 'Add translation',
'ADMIN_USERS' => 'Admin users',
'ADMINISTRATOR_ACCESS' => 'Administrator access',
'ALERT' => 'Alert',
'ALL' => 'All',
'ALL_LANGUAGES' => 'All languages',
'ALL_USERS' => 'All users',
'ANGLE' => 'Angle',
'APP_DOMAIN' => 'App domain',
'APP_NAME' => 'Application name',
'APP_URL' => 'Application URL',
'ARE_YOU_SURE' => 'Are you sure?',
'ARE_YOU_SURE_YOU_WANT_TO_DELETE_THIS_TRANSLATION' => 'Are you sure you want to delete this translation?',
'ATTACHMENTS' => 'Attachments',
'AUTHENTICATOR_CODE' => 'Enter the code from authenticaror',
'AUTHOR' => 'Author',
'BACK' => 'Back',
'BACKEND' => 'Backend',
'BROWSER' => 'Browser',
'BROWSER_SESSIONS' => 'Browser Sessions',
'BUILD' => 'Build',
'CALENDAR' => 'Calendar',
'CHOOSE_COLOR_SCHEME' => 'Choose color scheme',
'CHOOSE_FILE' => 'Choose file',
'CHOOSE_YOUR_LANGUAGE' => 'Choose your language',
'CLOSE' => 'Close',
'CODE' => 'Code',
'COLOR' => 'Color',
'CONFIG_WAS_UPDATED' => 'Configuration was updated',
'CONFIGURATION' => 'Configuration',
'CONTROL_PANEL' => 'Control Panel',
'CONTROL_PANEL_MANAGER' => 'Contol Panel Manager',
'COPY' => 'Copy',
'COUNTRY' => 'Country',
'CPAD' => 'cPad',
'CREATED' => 'Created',
'CSV' => 'CSV',
'CSV_IMPORTED_SUCCESSFULLY' => 'CSV imported successfully',
'CURRENT_PASSWORD' => 'Current Password',
'DASHBOARD' => 'Dashboard',
'DATA_HAS_BEEN_UPDATED' => 'Data has been updated',
'DATE' => 'Date',
'DAYS' => 'Days',
'DEBUGBAR_SHOW_FOR_SELECTED_IP' => 'Display Debugbar for selected IP',
'DELETE' => 'Delete',
'DELETE_POST' => 'Delete post',
'DESCRIPTION' => 'Description',
'DISABLE_2FA' => 'Disable 2FA',
'DISCOVER' => 'Discover',
'DISPLAY_DEBUG_BLOCKS_ON_FRONTEND' => 'Display debug for block on frontend',
'DOCUMENTS' => 'Documents',
'DOESNT_EXISTS' => 'Doesn&amp;apos;t exists',
'DRAG_AND_DROP' => 'Drag and Drop',
'EDIT' => 'Edit',
'EDITOR' => 'Editor',
'EMAIL' => 'Email',
'EMAIL_ADDRESS' => 'E-mail address',
'ENABLE_2FA' => 'Enable 2FA',
'ENABLE_DEBUGBAR' => 'Enable Debugbar',
'ENABLE_FRONTEND' => 'Enable frontend',
'ENABLE_QUEUE' => 'Enable queue',
'ENTER_THE_2FA_AUTHORIZATION_CODE' => 'Eneter the 2FA authorization code',
'ENTER_URL_TO_FETCH_IMAGE' => 'Enter URL to fetch image',
'ERROR' => 'Error',
'ERROR_IMPORTING_CSV' => 'Error importing CSV',
'ERROR_LOADING_FORM' => 'Error while loading form',
'ERROR_REORDERING_ITEMS' => 'Error reordering items',
'ERROR_SAVING_FORM' => 'Error while saving form data',
'ERROR_UPDATING_RECORD' => 'Error updating record',
'EVENT' => 'Event',
'EXPORT_AS_CSV' => 'Export as CSV',
'FACEBOOK' => 'Facebook',
'FACEBOOK_APP_ID' => 'Facebook App ID',
'FACEBOOK_PAGE' => 'Facebook page',
'FAILED_TO_CREATE_FOLDER' => 'Failed to create folder',
'FILE_MANAGER' => 'Filemanager',
'FILEMANAGER' => 'Filemanager',
'FILENAME' => 'Filename',
'FLAG' => 'Flag',
'FOLDER' => 'Folder',
'FORBIDDEN' => 'Forbidden',
'FORGOT_PASSWORD' => 'Forgot password',
'FRONTEND' => 'Frontend',
'GENERATE_SECRET_KEY_TO_ENABLE_2FA' => 'Generate secret key to enable 2FA',
'GET_YOUR_CODE' => 'Get your code',
'GOOGLE_ADSENSE_PUBLISHER_ID' => 'Google AdSense publisher ID',
'GOOGLE_SITE_VERIFICATION' => 'Google Site verification',
'GRADIENT_BACKGROUND' => 'Gradient background',
'GROUP' => 'Group',
'HELLO' => 'Hello',
'ID' => 'ID',
'IMPORT' => 'Import',
'IMPORT_AS_CSV' => 'Import from CSV',
'IMPORT_MISSING_TRANSLATIONS' => 'Import missing translations',
'IMPORT_TRANSLATIONS' => 'Import translations',
'INSTAGRAM' => 'Instagram',
'INVALID_CSV_STRUCTURE' => 'Invalid CSV structure',
'IP' => 'IP',
'ITEMS_SUCCESSFULLY_REORDERED' => 'Items successfully reordered',
'KEYCODE' => 'Keycode',
'KEYCODE_ALREADY_EXISTS' => 'Current keycode already exists in system. If you will click save button you will delete old keycode data and replaced with new one',
'KEYCODE_OK' => 'This keycode doesnt exists yet',
'LANGUAGE' => 'Language',
'LANGUAGE_STATE_SUCCESSFULLY_CHANGED' => 'Language state was successffully changed',
'LANGUAGES' => 'Languages',
'LANGUAGES_REORDER_SUCCESS' => 'Languages reordered',
'LAST_30_DAYS' => 'Last 30 days',
'LAST_7_DAYS' => 'Last 7 days',
'LAST_ACTIVE' => 'Last active',
'LIST' => 'List',
'LOGIN' => 'Login',
'LOGO' => 'Logo',
'LOGO_ALT' => 'Logo Alt',
'LOGO_MICROTAGS' => 'Logo microtags',
'LOGO_TITLE' => 'Logo Title',
'LOGOUT' => 'Logout',
'MAINTENANCE' => 'Maintenance',
'MAINTENANCE_MODE' => 'Maintenance mode',
'META' => 'Meta',
'META_DESCRIPTION' => 'Meta Description',
'META_KEYWORDS' => 'Meta Keywords',
'METHOD' => 'Methoda',
'MIGRATIONS' => 'Migrations',
'MISSING' => 'Missing',
'MISSING_DATA' => 'Missing data',
'MISSING_FOLDER_NAME' => 'Missing folder name',
'MISSING_ID' => 'Missing ID',
'MISSING_WIDGET' => 'Missing Widget',
'MS_VALIDATE' => 'MS Validate',
'NAME' => 'Name',
'NAVIGATION' => 'Navigation',
'NEED_HELP' => 'Need Help',
'NETWORK_RESPONSE_WAS_NOT_OK' => 'Network response was not ok',
'NEW_PASSWORD' => 'New Password',
'NEWS' => 'News',
'NO' => 'No',
'NO_ACCESS' => 'No access',
'NO_ACTIVE_LANGUAGES' => 'No active languages',
'NO_COMPANY' => 'No company',
'NO_CONTENT' => 'No Content',
'NO_DATA_AVAILABLE' => 'No data available',
'NO_PERMISSIONS' => 'No Permissions',
'NO_PRIVILEGES' => 'No privileges',
'NO_PRIVILEGIES' => 'No privilegies',
'NON_ADMIN_USERS' => 'Non admin users',
'OF' => 'of',
'OG_DESCRIPTION' => 'OpenGraph Description',
'OG_PICTURE' => 'OpenGraph picture',
'OG_TITLE' => 'OpenGraph title',
'OG_TYPE' => 'OpenGraph Type',
'OPTIONS' => 'Options',
'OR' => 'or',
'OS' => 'OS',
'PAGE_TITLE' => 'Page title',
'PASSWORD' => 'Password',
'PERMISSION_MODULE' => 'Permission module',
'PERMISSION_PROBLEM' => 'Permission problem',
'PERMISSIONS' => 'Permissions',
'PERMISSIONS_REVIEW' => 'Permission review',
'PICTURE' => 'Picture',
'PLUGINS' => 'Plugins',
'PROFILE' => 'Profile',
'PROFILE_PICTURE' => 'Profile picture',
'PROJECT_NAME' => 'Project name',
'PUBLISHER' => 'Publisher',
'QUEUE' => 'Queue',
'QUICK_LINKS' => 'Quick links',
'RECORD_CREATED_SUCCESSFULLY' => 'Record updated successfully',
'RECORD_UPDATED_SUCCESSFULLY' => 'Updated successfully',
'REFRESH' => 'Refresh',
'REMOVE_PICTURE' => 'Remove Picture',
'REQUIRED_PERMISSIONS' => 'Required permissions',
'REQUIRED_ROLES' => 'Required roles',
'RESEND_CODE' => 'Resend code',
'REVIEW_PERMISSIONS' => 'Review permissions',
'ROLES' => 'Roles',
'ROW_MUST_BE_A_POSITIVE_INTEGER' => 'Row must be positive integer number',
'SAVE' => 'Save',
'SEARCH' => 'Search',
'SELECT_ALL' => 'Select all',
'SELECT_GROUP' => 'Select group',
'SELECT_LANGUAGES' => 'Select languages',
'SELECT_USER_TYPE' => 'Select user type',
'SETTINGS' => 'Settings',
'SETUP' => 'Setup',
'SHARETHIS_PROPERTY_ID' => 'Sharethis Property ID',
'SHOW' => 'Show',
'SHOWING' => 'Showing',
'SIZE' => 'Size',
'SLUG' => 'Slug',
'SORT' => 'Sort',
'STAMP_ADD' => 'Created',
'STAMP_END' => 'Finished',
'STAMP_START' => 'Start',
'STATUS' => 'Status',
'STATUS_CHANGED' => 'Status changed',
'SUBJECT' => 'Subject',
'SUBMIT' => 'Submit',
'SUPPORTS' => 'Supports',
'SURNAME' => 'Surname',
'TAGS' => 'TAGS',
'TEMPLATE' => 'Template',
'TEMPLATES' => 'Templates',
'THERE_WAS_A_PROBLEM_WITH_YOUR_FETCH_OPERATION' => 'There was a problem with your fetch operation',
'THIS_DEVICE' => 'This device',
'THIS_MONTH' => 'This month',
'TIME' => 'Time',
'TITLE' => 'Title',
'TODAY' => 'Today',
'TRANSLATE' => 'Translate',
'TRANSLATION' => 'Translation',
'TRANSLATION_UPDATED' => 'Tanslations were updated',
'TRANSLATIONS' => 'Translations',
'TRUENDO_SITE_ID' => 'Truendo Site ID',
'TWITTER' => 'Twitter',
'TWITTER_HANDLE' => 'Twitter handle',
'TWITTER_PAGE' => 'Twitter page',
'TYPE' => 'Type',
'UID' => 'UID',
'UNKNOWN' => 'Unknown',
'UPDATE' => 'Update',
'UPDATED' => 'Updated',
'URL' => 'URL',
'USER' => 'User',
'USER_DETAILS' => 'User details',
'USER_GROUP' => 'User group',
'USER_GROUPS' => 'User groups',
'USERS' => 'Users',
'VALUE' => 'Value',
'VERIFY_PASSWORD' => 'Verify password',
'VERSION' => 'Version',
'VIEW' => 'View',
'WARNING' => 'Important',
'WE_SENT_CODE_TO_EMAIL' => 'Code was sent to your email address',
'WEATHER' => 'Weather',
'WRONG_EMAIL_OR_PASSWORD' => 'Wrong email address or password',
'YES' => 'Yes',
'YOU_DONT_HAVE_ACCESS' => 'You don&amp;apos;t have access',
'YOUR_CURRENT_IP' => 'Your current IP',
];

5
resources/lang/en/fp.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
'OG_DESCRIPTION' => 'OpenGraph Description',
'OG_TITLE' => 'OpenGraph title',
];

260
resources/lang/sl/admin.php Normal file
View File

@@ -0,0 +1,260 @@
<?php
return [
'2FA' => '2FA',
'2FA_ENABLE_DESCRIPTION' => '&amp;lt;p&amp;gt;Dvofaktorska avtentikacija (2FA) krepi varnost dostopa z zahtevo po dveh metodah (imenovanih tudi faktorji) za preverjanje vaše identitete. Dvofaktorska avtentikacija vas zaščiti pred krajo identitete, socialnim inženiringom ter napadi z vdiranjem gesel in varuje vaše prijave pred napadalci, ki izkoriščajo šibke ali ukradene poverilnice.&amp;lt;/p&amp;gt;&amp;lt;p&amp;gt;Za omogočitev dvofaktorske avtentikacije na vašem računu morate opraviti naslednje korake:&amp;lt;/p&amp;gt;&amp;lt;ol&amp;gt;&amp;lt;li&amp;gt;Kliknite na gumb &amp;quot;Ustvari skrivnost&amp;quot;, da ustvarite edinstveno skrivnostno QR kodo za svoj profil.&amp;lt;/li&amp;gt;&amp;lt;li&amp;gt;Preverite OTP (enkratno geslo) s pomočjo mobilne aplikacije Google Authenticator.&amp;lt;/li&amp;gt;&amp;lt;/ol&amp;gt;',
'2FA_ENABLE_STEP_1' => '1. Skenirajte to črtno kodo z aplikacijo Google Authenticator:',
'2FA_ENABLE_STEP_2' => '2. Vnesite kodo PIN za omogočitev 2FA.',
'2FA_MAIL' => 'mail za dvostopenjsko preverjanje',
'ACCESS' => 'Dostop',
'ACCESS_RESTRICTED' => 'Dostop do te strani je omejen',
'ACCESS_RESTRICTED_TEXT' => 'Prosimo, da preverite pri skrbniku strani, če mislite, da je prišlo do napake.',
'ACCESS_TO_CONTROL_PANEL' => 'Dostop do nadzorne plošče',
'ACTION' => 'Akcija',
'ACTIVATE_2FA_SECURITY' => 'Omogoči dvostopenjsko preverjanje (2FA)',
'ACTIVATE_2FA_SECURITY_DESCRIPTION' => 'Ob aktivaciji 2FA boste na vaš email prejeli dostopno kodo ob poskusu prijave v sistem',
'ACTIVATE_SHARETHIS_INTEGRATION' => 'Aktiviraj ShareThis integracijo',
'ACTIVATE_TRUENDO_CONSENT' => 'Aktiviraj Truendo Consent',
'ACTIVE' => 'Aktiven',
'ADD' => 'Dodaj',
'ADD_SELECTED_PERMISSIONS' => 'Dodaj izbrane pravice',
'ADD_TRANSLATION' => 'Dodaj prevod',
'ADMIN_USERS' => 'Administratorji',
'ADMINISTRATOR_ACCESS' => 'Administratorski dostop',
'ALERT' => 'Opozorilo',
'ALL' => 'Vse',
'ALL_LANGUAGES' => 'Vsi jeziki',
'ALL_USERS' => 'Vsi uporabniki',
'ANGLE' => 'Kot',
'APP_DOMAIN' => 'Domena aplikacije',
'APP_NAME' => 'Naziv aplikacije',
'APP_URL' => 'URL aplikacije',
'ARE_YOU_SURE' => 'Resnično želite izbrisati?',
'ARE_YOU_SURE_YOU_WANT_TO_DELETE_THIS_TRANSLATION' => 'Ste prepričani, da želite izbrisati ta prevod?',
'ATTACHMENTS' => 'Priponke',
'AUTHENTICATOR_CODE' => 'Vpišite kodo, ki ste jo prejeli s pomočja avtentikatorja',
'AUTHOR' => 'Avtor',
'BACK' => 'Nazaj',
'BACKEND' => 'Admin',
'BROWSE' => 'Brskaj',
'BROWSER' => 'Brskalnik',
'BROWSER_SESSIONS' => 'Seje brskalnika',
'BUILD' => 'Verzija',
'CALENDAR' => 'Kolendar',
'CHOOSE_COLOR_SCHEME' => 'Izberite barvno shemo',
'CHOOSE_FILE' => 'Izberite datoteko',
'CHOOSE_YOUR_LANGUAGE' => 'Izberite jezik',
'CLOSE' => 'Zapri',
'CODE' => 'Šifra',
'COLOR' => 'Barva',
'CONFIG_WAS_UPDATED' => 'Konfiguracija je bila posodobljena',
'CONFIGURATION' => 'Nastavitve',
'CONTROL_PANEL' => 'Nadzorna plošča',
'CONTROL_PANEL_MANAGER' => 'Nadzorna plošča',
'COPY' => 'Kopiraj',
'COUNTRY' => 'Država',
'CPAD' => 'cPad',
'CREATED' => 'Ustvarjeno',
'CSV' => 'CSV',
'CSV_IMPORTED_SUCCESSFULLY' => 'CSV datoteka je bila uvožena',
'CURRENT_PASSWORD' => 'Trenutno geslo',
'DASHBOARD' => 'Domov',
'DATA_HAS_BEEN_UPDATED' => 'Podatki so bili posodobljeni',
'DATE' => 'Datum',
'DAYS' => 'Dni',
'DEBUGBAR_SHOW_FOR_SELECTED_IP' => 'Prikaži razhroščevalno vrstico za izbran IP',
'DELETE' => 'Odstrani',
'DELETE_POST' => 'Izbriši',
'DESCRIPTION' => 'Opis',
'DISABLE_2FA' => 'Onemogoči 2FA',
'DISCOVER' => 'Odkrij',
'DISPLAY_DEBUG_BLOCKS_ON_FRONTEND' => 'Prikaži bloke za razhroščevanje na sprednji strani',
'DOCUMENTS' => 'Dokumenti',
'DOESNT_EXISTS' => 'Ne obstaja',
'DRAG_AND_DROP' => 'Povleci in spusti',
'EDIT' => 'Uredi',
'EDITOR' => 'Urejevalnik',
'EMAIL' => 'Email',
'EMAIL_ADDRESS' => 'E-poštni naslov',
'ENABLE_2FA' => 'Omogoči 2FA',
'ENABLE_DEBUGBAR' => 'Omogoči Debugbar',
'ENABLE_FRONTEND' => 'Omogoči frontend',
'ENABLE_QUEUE' => 'Omogoči čakalno vrsto',
'ENTER_THE_2FA_AUTHORIZATION_CODE' => 'Vpišite 2vtorizacijsko kodo 2FA',
'ENTER_URL_TO_FETCH_IMAGE' => 'Vpišitue URL povezavo za prenos slike',
'ERROR' => 'Napaka',
'ERROR_IMPORTING_CSV' => 'Napaka pri uvozu CSV',
'ERROR_LOADING_FORM' => 'Napaka pri nalaganju obrazca',
'ERROR_REORDERING_ITEMS' => 'Napaka pri preurejanju elementov',
'ERROR_SAVING_FORM' => 'Napaka pri shranjevanju obrazca',
'ERROR_UPDATING_RECORD' => 'Napaka pri posodobitvi zapisa',
'EVENT' => 'Dogodek',
'EXPORT_AS_CSV' => 'Izvozi v datoteko CSV',
'FACEBOOK' => 'Facebook',
'FACEBOOK_APP_ID' => 'Facebook App ID',
'FACEBOOK_PAGE' => 'Facebook stran',
'FAILED_TO_CREATE_FOLDER' => 'Ne morem narediti mape',
'FILE_MANAGER' => 'Urejevalnik datotek',
'FILEMANAGER' => 'Datoteke',
'FILENAME' => 'Datoteka',
'FLAG' => 'Zastava',
'FOLDER' => 'Mapa',
'FORBIDDEN' => 'Prepovedano',
'FORGOT_PASSWORD' => 'Pozabljeno geslo',
'FRONTEND' => 'Spletna stran',
'GENERATE_SECRET_KEY_TO_ENABLE_2FA' => 'Ustvari tajni ključ za vklop 2FA avtentikacije',
'GET_YOUR_CODE' => 'Pridobite kodo',
'GOOGLE_ADSENSE_PUBLISHER_ID' => 'Google AdSense ID založnika',
'GOOGLE_SITE_VERIFICATION' => 'Google preverjanje spletne strani',
'GRADIENT_BACKGROUND' => 'Gradientno ozadje (preliv)',
'GROUP' => 'Skupina',
'HELLO' => 'Pozdravljen',
'ID' => 'ID',
'IMPORT' => 'Uvozi',
'IMPORT_AS_CSV' => 'Uvozi iz CSV datoteke',
'IMPORT_MISSING_TRANSLATIONS' => 'Uvozi manjkajoče prevode',
'IMPORT_TRANSLATIONS' => 'Uvozi prevode',
'INSTAGRAM' => 'Instagram',
'INVALID_CSV_STRUCTURE' => 'Napačna struktura CSV',
'IP' => 'IP',
'ITEMS_SUCCESSFULLY_REORDERED' => 'Sortirano',
'KEYCODE' => 'Koda',
'KEYCODE_ALREADY_EXISTS' => 'Vpisana koda že obstaja v sistem. Če potrdite boste izbrisali obstoječo z trenutno vsebino.',
'KEYCODE_OK' => 'Vpisana koda še ne obstaja v sistemu',
'LANGUAGE' => 'Jezik',
'LANGUAGE_STATE_SUCCESSFULLY_CHANGED' => 'Jezik je bil uspešno spremenjen',
'LANGUAGES' => 'Jeziki',
'LANGUAGES_REORDER_SUCCESS' => 'Jeziki so bili uspešno preurejeni',
'LAST_30_DAYS' => 'Zadnjih 30 dni',
'LAST_7_DAYS' => 'Zadnjih 7 dni',
'LAST_ACTIVE' => 'Nazadnje dejaven',
'LIST' => 'Seznam',
'LOGIN' => 'Prijava',
'LOGO' => 'Logotip',
'LOGO_ALT' => 'Nadomestno besedilo za logotip',
'LOGO_MICROTAGS' => 'Mikrooznake za logotip',
'LOGO_TITLE' => 'Logo Title',
'LOGOUT' => 'Odjava',
'MAINTENANCE' => 'Vzdrževanje',
'MAINTENANCE_MODE' => 'Način vzdrževanja',
'META' => 'Meta',
'META_DESCRIPTION' => 'Meta description',
'META_KEYWORDS' => 'Meta keywords',
'METHOD' => 'Metoda',
'MIGRATIONS' => 'Migracije',
'MISSING' => 'Manjkajoče',
'MISSING_DATA' => 'Manjkajoči podatki',
'MISSING_FOLDER_NAME' => 'Manjkajoča mapa',
'MISSING_ID' => 'Manjka ID',
'MISSING_WIDGET' => 'Manjkajoč blok',
'MS_VALIDATE' => 'MS Validate',
'NAME' => 'Ime',
'NAVIGATION' => 'Navigacija',
'NEED_HELP' => 'Pomoč',
'NETWORK_RESPONSE_WAS_NOT_OK' => 'Omrežni odgovor ni bil uspešen',
'NEW_PASSWORD' => 'Novo geslo',
'NEWS' => 'Novice',
'NO' => 'Ne',
'NO_ACCESS' => 'Nimate dostopa',
'NO_ACTIVE_LANGUAGES' => 'Ni aktivnih jezikov',
'NO_COMPANY' => 'Ni podjetja',
'NO_CONTENT' => 'Brez vsebine',
'NO_DATA_AVAILABLE' => 'Podatki niso na voljo',
'NO_PERMISSIONS' => 'Nimate dovoljenja',
'NO_PRIVILEGES' => 'Nimate pravic',
'NO_PRIVILEGIES' => 'Nimate pravic',
'NON_ADMIN_USERS' => 'Navadni uporabniki',
'OF' => 'od',
'OG_DESCRIPTION' => 'OpenGraph opis',
'OG_PICTURE' => 'OpenGraph slika',
'OG_TITLE' => 'OpenGraph naslov',
'OG_TYPE' => 'OpenGraph Tip',
'OPTIONS' => 'Možnosti',
'OR' => 'ali',
'OS' => 'OS',
'PAGE_TITLE' => 'Page title',
'PASSWORD' => 'Geslo',
'PERMISSION_MODULE' => 'Modul dovoljenj',
'PERMISSION_PROBLEM' => 'Težava z pravicami (PERMISSION)',
'PERMISSIONS' => 'Dovoljenja',
'PERMISSIONS_REVIEW' => 'Pregled dovoljenj',
'PICTURE' => 'Slika',
'PLUGINS' => 'Vtičniki',
'PROFILE' => 'Profil',
'PROFILE_PICTURE' => 'Profilna slika',
'PROJECT_NAME' => 'Ime projekta',
'PUBLISHER' => 'Založnik',
'QUEUE' => 'Čakalna vrsta',
'QUICK_LINKS' => 'Hitre povezave',
'RECORD_CREATED_SUCCESSFULLY' => 'Zapis uspešno ustvarjen',
'RECORD_UPDATED_SUCCESSFULLY' => 'Zapis je bil uspešno posodobljen',
'REFRESH' => 'Osveži',
'REMOVE_PICTURE' => 'Odstrani sliko',
'REQUIRED_PERMISSIONS' => 'Zahtevana dovoljenja',
'REQUIRED_ROLES' => 'Zahtevana dovoljenja',
'RESEND_CODE' => 'Ponovno pošlji kodo',
'REVIEW_PERMISSIONS' => 'Pregled dovoljenj',
'ROLES' => 'Pravice',
'ROW_MUST_BE_A_POSITIVE_INTEGER' => 'Vrstica mora biti pozitivno celo število',
'SAVE' => 'Shrani',
'SEARCH' => 'Išči',
'SELECT_ALL' => 'Izberi vse',
'SELECT_GROUP' => 'Izberi skupino',
'SELECT_LANGUAGES' => 'Izberite jezike',
'SELECT_USER_TYPE' => 'Izberite tip uporabnika',
'SETTINGS' => 'Nastavitve',
'SETUP' => 'Nastavitve',
'SHARETHIS_PROPERTY_ID' => 'Sharethis Property ID',
'SHOW' => 'Prikaži',
'SHOWING' => 'Prikazujem',
'SIZE' => 'Velikost',
'SLUG' => 'Slug',
'SORT' => 'Sortiranje',
'STAMP_ADD' => 'Ustvarjeno',
'STAMP_END' => 'Zaključeno',
'STAMP_START' => 'Začetek',
'STATUS' => 'Status',
'STATUS_CHANGED' => 'Status je spremenjen',
'SUBJECT' => 'Zadeva',
'SUBMIT' => 'Potrdi',
'SUPPORTS' => 'Podpira',
'SURNAME' => 'Priimek',
'TAGS' => 'Značke',
'TEMPLATE' => 'Predloga',
'TEMPLATES' => 'Predloge',
'THERE_WAS_A_PROBLEM_WITH_YOUR_FETCH_OPERATION' => 'Prišlo je do težave pri pridobivanju podatkov',
'THIS_DEVICE' => 'Trenutna naprava',
'THIS_MONTH' => 'Ta mesec',
'TIME' => 'Čas',
'TITLE' => 'Naslov',
'TODAY' => 'Danes',
'TRANSLATE' => 'Prevedi',
'TRANSLATION' => 'Prevod',
'TRANSLATION_UPDATED' => 'Prevodi so posodobljeni',
'TRANSLATIONS' => 'Prevodi',
'TRUENDO_SITE_ID' => 'Truendo Site ID',
'TWITTER' => 'Twitter',
'TWITTER_HANDLE' => 'Twitter ime',
'TWITTER_PAGE' => 'Twitter stran',
'TYPE' => 'Tip',
'UID' => 'UID',
'UNKNOWN' => 'Neznano',
'UPDATE' => 'Osveži',
'UPDATED' => 'Posodobljeno',
'URL' => 'Povezava',
'USER' => 'Uporabnik',
'USER_DETAILS' => 'Podatki o uporabniku',
'USER_GROUP' => 'Uporabniška skupina',
'USER_GROUPS' => 'Uporabniške skupine',
'USERS' => 'Uporabniki',
'VALUE' => 'Vrednost',
'VERIFY_PASSWORD' => 'Ponovi geslo',
'VERSION' => 'Verzija',
'VIEW' => 'Pregled',
'WARNING' => 'Pomembno',
'WE_SENT_CODE_TO_EMAIL' => 'Koda je bila poslana na elektronski naslov',
'WEATHER' => 'Vreme',
'WRONG_EMAIL_OR_PASSWORD' => 'Napačen email naslov ali geslo',
'YES' => 'Da',
'YOU_DONT_HAVE_ACCESS' => 'Nimate dovoljenja',
'YOUR_CURRENT_IP' => 'Vaš trenutni IP naslov',
];

View File

@@ -0,0 +1,98 @@
<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="{{ $assetBase }}/img/love.svg">
<link rel="stylesheet" href="{{ $assetBase }}/css/bootstrap.min.css" />
<link rel="stylesheet" href="{{ $assetBase }}/css/owl.carousel.min.css" />
<link rel="stylesheet" href="{{ $assetBase }}/css/owl.theme.default.min.css" />
<link rel="stylesheet" href="{{ $assetBase }}/css/style.css" />
<link rel="stylesheet" href="{{ $assetBase }}/css/responsive.css" />
<title>{{ $title }}</title>
@stack('head')
</head>
<body>
@php
$showPageReveal = in_array($page ?? null, ['home', 'about'], true);
@endphp
@if($showPageReveal)
<div class="page-reveal"></div>
@endif
<!------ Header Area Start ----->
<header class="header">
<div class="container-fluid">
<div class="row">
<div class="col-lg-12">
<nav class="header-wrapper nav-desktop">
<a href="{{ route('home') }}">Home</a>
<a href="{{ route('work') }}">Work</a>
<div class="site-logo">
<a href="{{ route('home') }}"><img src="{{ $assetBase }}/img/logo.svg" alt="Aritmija logo"></a>
</div>
<a href="{{ route('about') }}">About</a>
<a href="{{ route('contact') }}">Contact</a>
</nav>
<div class="mobile-nav">
<div class="love-sm">
<a href="{{ route('home') }}"><img src="{{ $assetBase }}/img/love.svg" alt="Aritmija icon"></a>
</div>
<div class="logo-sm">
<a href="{{ route('home') }}"><img src="{{ $assetBase }}/img/logo.svg" alt="Aritmija logo"></a>
</div>
<div class="menu-trigger">
<span></span>
<span></span>
</div>
</div>
</div>
</div>
</div>
</header> <!------ Header Area End ----->
<!------ Slide Mobile Menu Area Start ----->
<div class="slide-menu">
<div class="menu-close">
<span></span>
<span></span>
</div>
<div class="menu_item">
<ul>
<li @class(['active' => ($page ?? null) === 'home'])><a href="{{ route('home') }}">Home</a></li>
<li @class(['active' => ($page ?? null) === 'about'])><a href="{{ route('about') }}">About</a></li>
<li @class(['active' => ($page ?? null) === 'work'])><a href="{{ route('work') }}">Work</a></li>
<li @class(['active' => ($page ?? null) === 'contact'])><a href="{{ route('contact') }}">Contact</a></li>
</ul>
</div>
<div class="info-bottom">
<span>En</span>
<img src="{{ $assetBase }}/img/love.svg" alt="Aritmija icon">
<span>Sl</span>
</div>
</div>
@yield('content')
<footer @class(['footer' => ($page ?? null) === 'home'])>
@if (($page ?? null) === 'home')
<x-block keycode="FOOTER-HOME-BLOCK"></x-block>
@endif
<x-block keycode="FOOTER-BLOCK"></x-block>
</footer>
<!-- Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.13/dist/lenis.min.js"></script>
<script src="{{ $assetBase }}/js/jquery.min.js"></script>
<script src="{{ $assetBase }}/js/Popper.js"></script>
<script src="{{ $assetBase }}/js/bootstrap.min.js"></script>
<script src="{{ $assetBase }}/js/owl.carousel.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Draggable.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
<script src="https://unpkg.com/gsap@3/dist/MorphSVGPlugin.min.js"></script>
<script src="{{ $assetBase }}/js/main.js"></script>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,45 @@
@extends('layouts.site')
@section('content')
<div class="terms-area">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="terms-content">
<div class="content-block">
<h2>Terms of Service</h2>
<h4>Omejitev odgovornosti</h4>
<p>Družba Aritmija se bo po najboljših močeh trudila zagotavljati najbolj točne in najnovejše podatke na svojih straneh, vendar opozarja uporabnike spletnih strani, da so besedila informativnega značaja, zato ne jamči in ne prevzema nobene odgovornosti za njihovo točnost in celovitost strani. Vsi uporabniki objavljeno vsebino uporabljajo na lastno odgovornost.</p>
<p>Niti družba Aritmija niti katera koli druga pravna ali fizična oseba, ki je sodelovala pri nastanku in izdelavi teh spletnih strani, ni odgovorna za občasno nedelovanje strani, za morebitno netočnost informacij in tudi ne za morebitno škodo, nastalo zaradi uporabe netočnih ali nepopolnih informacij, zato ne odgovarja za nobeno škodo ali neprijetnosti, ki bi izhajale iz obstoja spletnih strani, iz dostopa do in/ali uporabe in/ali nezmožnosti uporabe informacij na teh spletnih straneh in/ali za kakršne koli napake ali pomanjkljivosti v njihovi vsebini, ne glede na to, ali so bili obveščeni o možnosti take škode.</p>
<p>Ker obstajajo na spletni strani družbe Aritmija določene povezave na druge spletne strani, ki niso v nikakršni povezavi z družbo Aritmija in nad katerimi družba Aritmija nima nadzora, družba Aritmija ne more jamčiti in tudi ne prejemati ali posredovati pritožb glede točnosti vsebin katerekoli spletne strani, za katero nudi povezavo ali referenco in ne prevzema nobene odgovornosti za zaščito podatkov na teh spletnih straneh.</p>
<p>Družba Aritmija si pridržuje pravico, da kadarkoli spremeni, dodaja ali odstrani vsebino teh spletnih strani dodajanja ali odstranitve vsebin, objavljenih na spletni strani www.aritmija.si kadarkoli, na kakršenkoli način, delno ali v celoti, ne glede na razlog ter brez predhodnega opozorila. Vsi uporabniki vso objavljeno vsebino uporabljajo na lastno odgovornost.</p>
</div>
<div class="content-block">
<h4>Zaupnost podatkov</h4>
<p>Družba Aritmija avtomatsko zbira podatke o uporabi teh strani, predvsem podatke, katere strani so največkrat obiskane, število obiskovalcev, koliko časa obiskovalci ostanejo na spletnem mestu, ipd.. Ti podatki ne omogočajo vpogleda v osebne podatke uporabnikov. Uporabili jih bomo z namenom izboljšanja uporabe spletnega mesta. Družba Aritmija spoštuje vašo zasebnost in se zavezuje, da bo varovala zasebnost uporabnikov spletnega mesta.</p>
<p>Avtorske pravice <br>
Vsa vsebina, objavljena na spletnih straneh www.aritmija.si, je last družbe Aritmija in je v zakonsko dovoljenem okviru predmet avtorske zaščite ali drugih oblik zaščite intelektualne lastnine.</p>
<p>
Dokumenti, objavljeni na teh spletnih straneh, se lahko uporabljajo izključno v nekomercialne namene, in se jih ne sme spreminjati, prepisovati, razmnoževati, ponovno objavljati, pošiljati po pošti ali kako drugače razširjati v komercialne namene brez izrecnega pisnega dovoljenja družbe Aritmija. <br>
Vse reprodukcije ali primerki vsebine teh spletnih strani morajo ohraniti tudi vse navedene označbe avtorskih pravic, drugih obvestil o pravicah intelektualne lastnine ali obvestil o drugih pravicah (© 2021 Aritmija - Vse pravice pridržane). <br>
Blagovne znamke in storitvene znamke, ki se pojavljajo na teh straneh, so registrirane blagovne znamke, katerih imetnik ali uporabnik licence je družba Aritmija ali njene povezane družbe. Uporaba teh znamk je izrecno prepovedana, razen v primerih, ki so določeni v tem besedilu. <br>
<p>Družba Aritmija aktivno uveljavlja pravice do intelektualne lastnine v največjem možnem obsegu, ki ga omogoča zakonodaja.</p>
</p>
</div>
<div class="content-block">
<h4>Avtorske pravice</h4>
<p>Vsa vsebina, objavljena na spletnih straneh www.aritmija.si, je last družbe Aritmija in je v zakonsko dovoljenem okviru predmet avtorske zaščite ali drugih oblik zaščite intelektualne lastnine.</p>
<p>Dokumenti, objavljeni na teh spletnih straneh, se lahko uporabljajo izključno v nekomercialne namene, in se jih ne sme spreminjati, prepisovati, razmnoževati, ponovno objavljati, pošiljati po pošti ali kako drugače razširjati v komercialne namene brez izrecnega pisnega dovoljenja družbe Aritmija.</p>
<p>Vse reprodukcije ali primerki vsebine teh spletnih strani morajo ohraniti tudi vse navedene označbe avtorskih pravic, drugih obvestil o pravicah intelektualne lastnine ali obvestil o drugih pravicah (© 2021 Aritmija - Vse pravice pridržane). Blagovne znamke in storitvene znamke, ki se pojavljajo na teh straneh, so registrirane blagovne znamke, katerih imetnik ali uporabnik licence je družba Aritmija ali njene povezane družbe. Uporaba teh znamk je izrecno prepovedana, razen v primerih, ki so določeni v tem besedilu.</p>
<p>Družba Aritmija aktivno uveljavlja pravice do intelektualne lastnine v največjem možnem obsegu, ki ga omogoča zakonodaja.</p>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,15 @@
@extends('layouts.site')
@section('content')
<x-block keycode="ABOUT-PAGE-MAIN"></x-block>
<x-block keycode="ABOUT-PAGE-WORKING-TOGETHER"></x-block>
<x-block keycode="ABOUT-PAGE-CREATIVE"></x-block>
<x-block keycode="ABOUT-PAGE-SLIDER"></x-block>
<x-block keycode="ABOUT-PAGE-INSPIRATION"></x-block>
@php
/*<x-block keycode="ABOUT-PAGE-ABOUT-AREA"></x-block>*/
@endphp
<x-block keycode="HOME-PAGE-BRANDING-AREA"></x-block>
<x-block keycode="ABOUT-PAGE-WORKING-TOGETHER-BOTTOM"></x-block>
@endsection

View File

@@ -0,0 +1,12 @@
@extends('layouts.site')
@section('content')
@php($formErrors = $errors ?? new \Illuminate\Support\ViewErrorBag())
<div class="contact-area">
<div class="container">
<div class="row"><div class="col-lg-12"><div class="contact-wrapper"><div class="contact-title"><h2>{{ trans('fp.LETS_MAKE_MAGIC') }}</h2><p>{{ trans('fp.CONTACT_DESCRIPTION') }}</p></div><div class="contact-form"><form action="{{ route('contact.submit') }}" method="POST">@csrf<div class="single-item"><input type="text" name="name" value="{{ old('name') }}" placeholder="{{ trans('fp.NAME') }}">@if ($formErrors->has('name'))<p class="text-danger mt-2">{{ $formErrors->first('name') }}</p>@endif</div><div class="single-item"><input type="email" name="email" value="{{ old('email') }}" placeholder="{{ trans('fp.EMAIL') }}">@if ($formErrors->has('email'))<p class="text-danger mt-2">{{ $formErrors->first('email') }}</p>@endif</div><div class="single-item"><textarea name="message" placeholder="{{ trans('fp.MESSAGE') }}">{{ old('message') }}</textarea>@if ($formErrors->has('message'))<p class="text-danger mt-2">{{ $formErrors->first('message') }}</p>@endif</div><div class="single-item submit-btn"><button type="submit" class="btn__primary">{{ trans('fp.SEND_IT') }} <span><img src="{{ $assetBase }}/img/send.svg" alt="{{ trans('fp.SEND') }}"></span></button></div></form></div></div></div></div>
</div>
</div>
<section class="contact-note-area"><div class="container"><div class="row"><div class="col-lg-12"><div class="note-text"><h2>Follow us on Instagram, LinkedIn or Behance for the latest news &amp; updates.</h2></div></div></div></div></section>
@endsection

View File

@@ -0,0 +1,65 @@
@extends('layouts.site')
@push('head')
@if(!empty($seo['meta_description']))
<meta name="description" content="{{ $seo['meta_description'] }}">
@endif
@if(!empty($seo['meta_keywords']))
<meta name="keywords" content="{{ $seo['meta_keywords'] }}">
@endif
@if(!empty($seo['meta_author']))
<meta name="author" content="{{ $seo['meta_author'] }}">
@endif
@if(!empty($seo['meta_publisher']))
<meta name="publisher" content="{{ $seo['meta_publisher'] }}">
@endif
@if(!empty($seo['meta_copyright']))
<meta name="copyright" content="{{ $seo['meta_copyright'] }}">
@endif
@if(!empty($seo['meta_refresh']))
<meta http-equiv="refresh" content="{{ $seo['meta_refresh'] }}">
@endif
<meta property="og:type" content="{{ $seo['og_type'] }}">
<meta property="og:title" content="{{ $seo['og_title'] }}">
@if(!empty($seo['og_description']))
<meta property="og:description" content="{{ $seo['og_description'] }}">
@endif
@if(!empty($seo['og_image']))
<meta property="og:image" content="{{ $seo['og_image'] }}">
@endif
<meta property="og:url" content="{{ request()->url() }}">
@endpush
@section('content')
@php
/*
<section class="hero-area">
<div class="video-hero">
<x-block keycode="HOME-PAGE-VIDEO"></x-block>
</div>
<a href="{{ route('contact') }}" class="lets-talk-btn">Lets talk</a>
<div class="language-action">
<ul>
@foreach($activeLanguages as $language)
<li @class(['active' => app()->getLocale() === $language->iso])>
<button type="button" onclick="window.location.href='{{ route('home', ['locale' => $language->iso]) }}'">{{ strtoupper($language->iso) }}</button>
</li>
@endforeach
</ul>
</div>
</section>
*/
@endphp
<x-block keycode="HOME-PAGE-AREA-HOME"></x-block>
<x-block keycode="HOME-PAGE-SHOWCASE"></x-block>
<x-block keycode="HOME-PAGE-ABOUT-AREA"></x-block>
<x-block keycode="HOME-PAGE-VIDEO"></x-block>
<x-block keycode="HOME-PAGE-WORKING-TOGETHER"></x-block>
<x-block keycode="HOME-PAGE-BRANDING-AREA"></x-block>
<x-block keycode="HOME-PAGE-BRIEF-AREA"></x-block>
<x-block keycode="HOME-PAGE-CTA-AREA"></x-block>
@endsection

View File

@@ -0,0 +1,37 @@
@extends('layouts.site')
@push('head')
@if(!empty($seo['meta_description']))
<meta name="description" content="{{ $seo['meta_description'] }}">
@endif
@if(!empty($seo['meta_keywords']))
<meta name="keywords" content="{{ $seo['meta_keywords'] }}">
@endif
@if(!empty($seo['meta_author']))
<meta name="author" content="{{ $seo['meta_author'] }}">
@endif
@if(!empty($seo['meta_publisher']))
<meta name="publisher" content="{{ $seo['meta_publisher'] }}">
@endif
@if(!empty($seo['meta_copyright']))
<meta name="copyright" content="{{ $seo['meta_copyright'] }}">
@endif
@if(!empty($seo['meta_refresh']))
<meta http-equiv="refresh" content="{{ $seo['meta_refresh'] }}">
@endif
<meta property="og:type" content="{{ $seo['og_type'] }}">
<meta property="og:title" content="{{ $seo['og_title'] }}">
@if(!empty($seo['og_description']))
<meta property="og:description" content="{{ $seo['og_description'] }}">
@endif
@if(!empty($seo['og_image']))
<meta property="og:image" content="{{ $seo['og_image'] }}">
@endif
<meta property="og:url" content="{{ request()->url() }}">
@endpush
@section('content')
<main class="page-content">
{!! $page->content->content ?? '' !!}
</main>
@endsection

View File

@@ -0,0 +1,49 @@
@extends('layouts.site')
@push('head')
@if(!empty($seo['meta_description']))
<meta name="description" content="{{ $seo['meta_description'] }}">
@endif
@if(!empty($seo['meta_keywords']))
<meta name="keywords" content="{{ $seo['meta_keywords'] }}">
@endif
@if(!empty($seo['meta_author']))
<meta name="author" content="{{ $seo['meta_author'] }}">
@endif
@if(!empty($seo['meta_publisher']))
<meta name="publisher" content="{{ $seo['meta_publisher'] }}">
@endif
@if(!empty($seo['meta_copyright']))
<meta name="copyright" content="{{ $seo['meta_copyright'] }}">
@endif
@if(!empty($seo['meta_refresh']))
<meta http-equiv="refresh" content="{{ $seo['meta_refresh'] }}">
@endif
<meta property="og:type" content="{{ $seo['og_type'] }}">
<meta property="og:title" content="{{ $seo['og_title'] }}">
@if(!empty($seo['og_description']))
<meta property="og:description" content="{{ $seo['og_description'] }}">
@endif
@if(!empty($seo['og_image']))
<meta property="og:image" content="{{ $seo['og_image'] }}">
@endif
<meta property="og:url" content="{{ request()->url() }}">
@endpush
@section('content')
<div class="terms-area">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="terms-content">
<div class="content-block">
{!! html_entity_decode((string) ($content->translation->content ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8') !!}
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,63 @@
{{--
Renders a single project slot: image, text, video (youtube/bunny/frameio/mp4).
Expected $slot shape:
type : 'image' | 'text' | 'video'
image : ['url' => string, 'alt' => string]
media : ['type' => string, 'url' => string, 'embedUrl' => string] | null
--}}
@php
$stretch = $stretch ?? false;
$squareEmbed = $squareEmbed ?? false;
$slotType = $slot['type'] ?? '';
@endphp
@if($slotType === 'image' && !empty($slot['image']['url']))
<div @class(['project-slot', 'project-slot--image', 'project-slot--stretch' => $stretch])>
<img class="project-slot__image" src="{{ $slot['image']['url'] }}" alt="{{ $slot['image']['alt'] ?? '' }}">
</div>
@elseif($slotType === 'text' && trim(strip_tags((string) ($slot['text'] ?? ''))) !== '')
<div @class(['project-slot', 'project-slot--text', 'project-slot--stretch' => $stretch, 'd-flex' => true, 'align-items-center' => true])>
<div class="project-slot__text col-12 col-lg-8 offset-lg-2">
<div class="text-center w-100">
{!! $slot['text'] !!}
</div>
</div>
</div>
@elseif($slotType === 'video' && !empty($slot['media']))
@php $media = $slot['media']; @endphp
<div @class(['project-slot', 'project-slot--video', 'project-slot--stretch' => $stretch])>
@if(in_array($media['type'], ['youtube', 'bunny', 'frameio']) && !empty($media['embedUrl']))
<div class="project-slot__embed">
<iframe
class="project-slot__iframe"
src="{!! $media['embedUrl'] !!}"
frameborder="0"
@if(!empty($media['autoplay']))
style="width: 100%;{{ $squareEmbed ? ' aspect-ratio: 1/1;' : ' aspect-ratio: 16/9;' }};pointer-events:none;"
@else
style="width: 100%;{{ $squareEmbed ? ' aspect-ratio: 1/1;' : ' aspect-ratio: 16/9;' }}"
@endif
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen>
</iframe>
</div>
@elseif($media['type'] === 'video' && !empty($media['url']))
<video
class="project-video project-slot__video"
@if(!empty($media['autoplay'])) autoplay @endif
@if(!empty($media['muted'])) muted @endif
@if(!empty($media['loop'])) loop @endif
playsinline>
<source src="{{ $media['url'] }}" type="video/mp4">
</video>
@endif
</div>
@endif

View File

@@ -0,0 +1,172 @@
@extends('layouts.site')
@push('head')
@if(!empty($seo['meta_description']))
<meta name="description" content="{{ $seo['meta_description'] }}">
@endif
<meta property="og:type" content="article">
<meta property="og:title" content="{{ $seo['og_title'] }}">
@if(!empty($seo['og_description']))
<meta property="og:description" content="{{ $seo['og_description'] }}">
@endif
@if(!empty($seo['og_image']))
<meta property="og:image" content="{{ $seo['og_image'] }}">
@endif
<meta property="og:url" content="{{ $seo['og_url'] }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $seo['og_title'] }}">
@if(!empty($seo['og_description']))
<meta name="twitter:description" content="{{ $seo['og_description'] }}">
@endif
@if(!empty($seo['og_image']))
<meta name="twitter:image" content="{{ $seo['og_image'] }}">
@endif
<link rel="canonical" href="{{ $seo['og_url'] }}">
@endpush
@section('content')
<section class="projects-area">
<div class="container">
{{-- ── Header row ─────────────────────────────────────── --}}
<div class="row">
<div class="col-lg-12">
<div class="single-project">
{{-- Title + subline --}}
<div class="project-info">
<div class="row">
<div class="col-lg-6 col-md-8">
<h2>{!! $project['headline'] !!}</h2>
</div>
@if($project['subline'])
<div class="col-lg-6 col-md-4">
<div class="subtitle">{!! $project['subline'] !!}</div>
</div>
@endif
</div>
</div>
{{-- Hero media --}}
@if($project['hero'])
<div class="thumbnail-wrap">
@include('pages.partials.project-slot', ['slot' => $project['hero']])
</div>
@endif
{{-- Client, awarded and description in the original template structure --}}
@if($project['hero'] || $project['clientName'] || !empty($project['awarded']) || $project['description'])
<div class="row mt-5">
<div class="col-lg-6">
<div class="project-info mb-0">
@if($project['year'])
<div class="client-info">
<h4>{{ trans('fp.YEAR') }}</h4>
<span>{{ $project['year'] }}</span>
</div>
@endif
<div class="client-info">
<h4>{{ trans('fp.CLIENT') }}</h4>
@if($project['clientName'])
<span>{!! $project['clientName'] !!}</span>
@else
<span>-</span>
@endif
</div>
<div class="project-details">
@if(!empty($project['awarded']))
<h4>{{ trans('fp.AWARDED') }}</h4>
@foreach($project['awarded'] as $award)
<p>{!! $award !!}</p>
@endforeach
@endif
</div>
</div>
</div>
<div class="col-lg-6">
<div class="description-text">
@if($project['description'])
@php($description = trim($project['description']))
@if(str_contains($description, '<'))
{!! $description !!}
@else
<p>{!! nl2br(e($description)) !!}</p>
@endif
@else
<p>-</p>
@endif
</div>
</div>
</div>
@endif
</div>
</div>
</div>
{{-- ── Content blocks ──────────────────────────────────── --}}
@foreach($project['blocks'] as $block)
@if(!empty($block['hidden']))
@continue
@endif
@if($block['type'] === 'FullWidth' && ($block['slot']['type'] ?? '') === 'text')
{{-- Text block: centred quote style --}}
<div class="row project-content justify-content-center">
<div class="col-lg-6 text-center">
{!! $block['slot']['text'] !!}
</div>
</div>
@elseif($block['type'] === 'FullWidth')
<div class="row">
<div class="col-lg-12">
<div class="single-project">
<div class="thumbnail-wrap">
@include('pages.partials.project-slot', ['slot' => $block['slot']])
</div>
</div>
</div>
</div>
@elseif($block['type'] === 'TwoColumns')
<div class="row">
<div class="col-lg-6 col-md-6">
<div class="single-project">
<div class="thumbnail-wrap">
@include('pages.partials.project-slot', ['slot' => $block['left'], 'squareEmbed' => true])
</div>
</div>
</div>
<div class="col-lg-6 col-md-6">
<div class="single-project">
<div class="thumbnail-wrap">
@include('pages.partials.project-slot', ['slot' => $block['right'], 'squareEmbed' => true])
</div>
</div>
</div>
</div>
@endif
@endforeach
</div>
{{-- ── Next project ─────────────────────────────────────── --}}
@if(!empty($nextProject))
<div class="container mt-4">
<div class="row">
<div class="col-lg-12">
<div class="btn-wrapper">
<a href="{{ $nextProject['url'] }}" class="common-btn">
Next project <span><img src="{{ $assetBase }}/img/arrow.svg" alt=""></span>
</a>
</div>
</div>
</div>
</div>
@endif
</section>
@endsection

View File

@@ -0,0 +1,34 @@
@extends('layouts.site')
@section('content')
<div class="contact-area">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="contact-wrapper">
<div class="contact-title">
<h2>Thank you!</h2>
<p>Your question has been sent, we will <br>
get back to you shortly.</p>
</div>
<div class="back-home">
<a href="{{ route('home') }}" class="btn__primary">Back to homepage <span><img src="{{ $assetBase }}/img/send.svg" alt=""></span></a>
</div>
</div>
</div>
</div>
</div>
</div>
<section class="contact-note-area">
<div class="container">
<div class="row">
<div class="col-lg-12">
<div class="note-text">
<h2>Follow us on Instagram, LinkedIn or Behance for the latest news &amp; updates.</h2>
</div>
</div>
</div>
</div>
</section>
@endsection

View File

@@ -0,0 +1,122 @@
@extends('layouts.site')
@section('content')
<section class="portfolio-area">
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<x-block keycode="WORK-HEADER"></x-block>
</div> <!-- end .col-lg-12 -->
</div> <!-- end .row -->
<div class="row">
<div class="col-lg-12">
<div class="portfolio-filter">
<button class="active" data-filter="all">{{ trans('fp.ALL') }}</button>
@foreach($categories as $category)
<button data-filter="{{ $category['filter'] }}">{{ $category['title'] }}</button>
@endforeach
</div>
<div class="portfolio-projects">
@forelse(collect($artworks)->sortBy('sort_order')->values() as $artwork)
@php
$subline = $artwork['subline'] ?? null;
$subtitle = $artwork['subtitle'] ?? null;
$thumbnail = $artwork['thumbnail'] ?? ['type' => 'image', 'url' => $artwork['image'] ?? null];
@endphp
<div class="project-card {{ implode(' ', $artwork['filters']) }}" data-order="{{ $artwork['sort_order'] ?? 0 }}" style="order: {{ $artwork['sort_order'] ?? 0 }};">
<a href="{{ $artwork['url'] }}">
<div class="project-img-wrapper">
@if(($thumbnail['type'] ?? 'image') === 'bunny' && !empty($thumbnail['embedUrl']))
<iframe class="project-slot__iframe"
src="{!! $thumbnail['embedUrl'] !!}"
frameborder="0"
style="width: 100%; aspect-ratio: 587/673;pointer-events:none;"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen="">
</iframe>
@else
<img src="{{ $thumbnail['url'] ?? $artwork['image'] }}" alt="{{ $artwork['title'] }}" loading="lazy">
@endif
</div></a>
<div class="portfolio-info">
<h4>{{ $artwork['title'] }}</h4>
@if($subline || $subtitle)
<span>{{ $subline ?: $subtitle }}</span>
@endif
</div> <!-- end .portfolio-info -->
</div> <!-- end .project-card -->
@empty
<div class="mt-4 text-center">
<p class="text-center">{{ trans('fp.NO_PUBLISHED_PROJECTS_YET') }}</p>
</div> <!-- end .mt-4 -->
@endforelse
</div> <!-- end .portfolio-projects -->
</div> <!-- end .col-lg-12 -->
</div> <!-- end .row -->
</div> <!-- end .container -->
</section> <!-- end .portfolio-area -->
@endsection
@push('scripts')
<script>
gsap.registerPlugin(ScrollTrigger);
document.fonts.ready.then(() => {
gsap.set('.portfolio-filter button', { opacity: 1 });
gsap.fromTo(
'.portfolio-filter button',
{
y: '-=50',
opacity: 0,
rotation: 'random(-30,30)'
},
{
y: '0',
opacity: 1,
rotation: 0,
duration: 0.8,
ease: 'back.out(1.5)',
stagger: 0.15,
scrollTrigger: {
trigger: '.portfolio-filter',
start: 'top 80%',
toggleActions: 'play none none none'
}
}
);
});
</script>
@endpush
@push('scripts')
<script>
gsap.registerPlugin(ScrollTrigger);
document.fonts.ready.then(() => {
gsap.set('.portfolio-filter button', { opacity: 1 });
gsap.fromTo(
'.portfolio-filter button',
{
y: '-=50',
opacity: 0,
rotation: 'random(-30,30)'
},
{
y: '0',
opacity: 1,
rotation: 0,
duration: 0.8,
ease: 'back.out(1.5)',
stagger: 0.15,
scrollTrigger: {
trigger: '.portfolio-filter',
start: 'top 80%',
toggleActions: 'play none none none'
}
}
);
});
</script>
@endpush

File diff suppressed because one or more lines are too long