Update
This commit is contained in:
11
resources/css/app.css
Normal file
11
resources/css/app.css
Normal 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
1
resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
4
resources/js/bootstrap.js
vendored
Normal file
4
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
41
resources/js/projects-renderer.js
Normal file
41
resources/js/projects-renderer.js
Normal 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);
|
||||
});
|
||||
282
resources/js/projects-renderer/ProjectPageRenderer.vue
Normal file
282
resources/js/projects-renderer/ProjectPageRenderer.vue
Normal 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>
|
||||
1918
resources/js/projects-renderer/ProjectStructureEditor.vue
Normal file
1918
resources/js/projects-renderer/ProjectStructureEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
281
resources/js/projects-renderer/components/ImageDropZone.vue
Normal file
281
resources/js/projects-renderer/components/ImageDropZone.vue
Normal 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>
|
||||
@@ -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>
|
||||
136
resources/js/projects-renderer/components/ProjectHeadline.vue
Normal file
136
resources/js/projects-renderer/components/ProjectHeadline.vue
Normal 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>
|
||||
157
resources/js/projects-renderer/components/ProjectHero.vue
Normal file
157
resources/js/projects-renderer/components/ProjectHero.vue
Normal 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>
|
||||
210
resources/js/projects-renderer/components/ProjectMetadata.vue
Normal file
210
resources/js/projects-renderer/components/ProjectMetadata.vue
Normal 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>
|
||||
204
resources/js/projects-renderer/components/RichTextEditor.vue
Normal file
204
resources/js/projects-renderer/components/RichTextEditor.vue
Normal 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>
|
||||
281
resources/js/projects-renderer/components/blocks/BlockSlot.vue
Normal file
281
resources/js/projects-renderer/components/blocks/BlockSlot.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
141
resources/js/projects-renderer/components/blocks/PublicSlot.vue
Normal file
141
resources/js/projects-renderer/components/blocks/PublicSlot.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
101
resources/js/projects-renderer/composables/useImageUpload.js
Normal file
101
resources/js/projects-renderer/composables/useImageUpload.js
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
497
resources/js/projects-renderer/schema/projectSchema.js
Normal file
497
resources/js/projects-renderer/schema/projectSchema.js
Normal 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
259
resources/lang/en/admin.php
Normal file
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
return [
|
||||
'2FA' => '2FA',
|
||||
'2FA_ENABLE_DESCRIPTION' => '&lt;p&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.&lt;/p&gt; &lt;p&gt;To Enable Two Factor Authentication on your Account, you need to do following steps&lt;/p&gt; &lt;ol&gt; &lt;li&gt;Click on Generate Secret Button , To Generate a Unique secret QR code for your profile&lt;/li&gt; &lt;li&gt;Verify the OTP from Google Authenticator Mobile App&lt;/li&gt; &lt;/ol&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&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&apos;t have access',
|
||||
'YOUR_CURRENT_IP' => 'Your current IP',
|
||||
];
|
||||
5
resources/lang/en/fp.php
Normal file
5
resources/lang/en/fp.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
return [
|
||||
'OG_DESCRIPTION' => 'OpenGraph Description',
|
||||
'OG_TITLE' => 'OpenGraph title',
|
||||
];
|
||||
260
resources/lang/sl/admin.php
Normal file
260
resources/lang/sl/admin.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
return [
|
||||
'2FA' => '2FA',
|
||||
'2FA_ENABLE_DESCRIPTION' => '&lt;p&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.&lt;/p&gt;&lt;p&gt;Za omogočitev dvofaktorske avtentikacije na vašem računu morate opraviti naslednje korake:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;Kliknite na gumb &quot;Ustvari skrivnost&quot;, da ustvarite edinstveno skrivnostno QR kodo za svoj profil.&lt;/li&gt;&lt;li&gt;Preverite OTP (enkratno geslo) s pomočjo mobilne aplikacije Google Authenticator.&lt;/li&gt;&lt;/ol&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',
|
||||
];
|
||||
98
resources/views/layouts/site.blade.php
Normal file
98
resources/views/layouts/site.blade.php
Normal 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>
|
||||
45
resources/views/pages/_terms.blade.php
Normal file
45
resources/views/pages/_terms.blade.php
Normal 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
|
||||
15
resources/views/pages/about.blade.php
Normal file
15
resources/views/pages/about.blade.php
Normal 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
|
||||
12
resources/views/pages/contact.blade.php
Normal file
12
resources/views/pages/contact.blade.php
Normal 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 & updates.</h2></div></div></div></div></section>
|
||||
@endsection
|
||||
65
resources/views/pages/home.blade.php
Normal file
65
resources/views/pages/home.blade.php
Normal 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">Let’s 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
|
||||
37
resources/views/pages/index.blade.php
Normal file
37
resources/views/pages/index.blade.php
Normal 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
|
||||
49
resources/views/pages/page.blade.php
Normal file
49
resources/views/pages/page.blade.php
Normal 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
|
||||
63
resources/views/pages/partials/project-slot.blade.php
Normal file
63
resources/views/pages/partials/project-slot.blade.php
Normal 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
|
||||
172
resources/views/pages/project.blade.php
Normal file
172
resources/views/pages/project.blade.php
Normal 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
|
||||
|
||||
34
resources/views/pages/thankyou.blade.php
Normal file
34
resources/views/pages/thankyou.blade.php
Normal 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 & updates.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
122
resources/views/pages/work.blade.php
Normal file
122
resources/views/pages/work.blade.php
Normal 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
|
||||
225
resources/views/welcome.blade.php
Normal file
225
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user