283 lines
11 KiB
Vue
283 lines
11 KiB
Vue
<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>
|