where('page_type', 'frontpage') ->value('content_id'); $frontpage = $frontpageId ? app(CmsPage::class)->load((int) $frontpageId, app()->getLocale()) : null; $seo = $this->pageSeo($frontpage, 'Home'); return $this->render('pages.home', $seo['title'], 'home', [ 'seo' => $seo, ]); } public function about(): View { return $this->render('pages.about', 'About Us', 'about'); } public function work(): View { $categoryMap = $this->projectCategoryMap(); return $this->render('pages.work', 'Works', 'work', [ 'artworks' => $this->portfolioProjects($categoryMap), 'categories' => array_values($categoryMap), ]); } public function project(string $locale, ?int $projectId = null, ?string $slug = null): View { $project = $this->findPublicProject($projectId); abort_if(!$project, 404); $payload = $this->projectRendererPayload($project); $headline = (string) ($project->headline ?: 'Single Project'); $metaTitle = (string) ($project->meta_title ?: $project->page_title ?: $headline); return $this->render('pages.project', $metaTitle, 'work', [ 'project' => $this->normalizeProjectForBlade($payload), 'nextProject' => $this->nextPublicProject((int) $project->project_id), 'seo' => [ 'title' => $metaTitle, 'meta_description' => (string) ($project->meta_description ?? ''), 'og_title' => (string) ($project->og_title ?: $metaTitle), 'og_description' => (string) ($project->og_description ?: $project->meta_description ?? ''), 'og_image' => $this->projectCoverUrl($project->picture_catalog ?? null) ?? $this->projectCoverUrl($project->picture_cover ?? null) ?? '', 'og_url' => request()->url(), ], ]); } public function contact(): View { return $this->render('pages.contact', 'Contact Us', 'contact'); } public function terms(): View { return $this->render('pages.terms', 'Terms', 'terms'); } public function thankyou(): View { return $this->render('pages.thankyou', 'Thank you!', 'contact'); } public function submitContact(StoreContactMessageRequest $request): RedirectResponse { Log::info('Website contact form submission', $request->validated()); return redirect()->route('thankyou'); } private function render(string $view, string $title, string $page, array $data = []): View { return view($view, array_merge($data, [ 'activeLanguages' => ActiveLanguage::getList('fp'), 'assetBase' => asset('assets'), 'page' => $page, 'title' => $title, ])); } private function pageSeo(?object $page, ?string $fallbackTitle = null): array { $meta = data_get($page, 'meta') ?? []; $og = data_get($page, 'og') ?? []; $translation = data_get($page, 'translation'); $content = data_get($page, 'content'); $title = (string) (data_get($meta, 'title') ?: data_get($translation, 'page_title') ?: data_get($content, 'headline') ?: $fallbackTitle ?: 'Home'); $description = (string) (data_get($meta, 'description') ?: data_get($translation, 'meta_description') ?: ''); return [ 'title' => $title, 'meta_description' => $description, 'meta_keywords' => (string) (data_get($meta, 'keywords') ?: ''), 'meta_author' => (string) (data_get($meta, 'author') ?: ''), 'meta_publisher' => (string) (data_get($meta, 'publisher') ?: ''), 'meta_copyright' => (string) (data_get($meta, 'copyright') ?: ''), 'meta_refresh' => data_get($meta, 'refresh'), 'og_title' => (string) (data_get($og, 'title') ?: $title), 'og_description' => (string) (data_get($og, 'description') ?: $description), 'og_image' => (string) (data_get($og, 'image') ?: ''), 'og_type' => (string) (data_get($og, 'type') ?: 'website'), ]; } private function portfolioProjects(array $categoryMap): array { return $this->applyPublicProjectOrder($this->publicProjectsQuery()) ->get() ->values() ->map(function ($project, int $index) use ($categoryMap) { $categoryIds = collect($this->decodeCategoryIds($project->categories ?? null)) ->filter(fn ($id) => is_numeric($id) && (int) $id > 0) ->map(fn ($id) => (int) $id) ->unique() ->values(); $filters = $categoryIds ->map(fn (int $id) => $categoryMap[$id]['filter'] ?? null) ->filter() ->values() ->all(); $headline = (string) ($project->headline ?: $project->name ?: 'Untitled project'); return [ 'id' => (int) $project->project_id, 'sort_order' => (int) ($project->num ?? 0), 'title' => $headline, 'subline' => (string) ($project->subline ?: ''), 'subtitle' => '', 'thumbnail' => $this->projectThumbnailForGrid($project, $index), 'image' => $this->projectCoverForGrid($project, $index), 'filters' => $filters, 'group' => $this->defaultProjectGroup(), 'url' => route('project', [ 'locale' => app()->getLocale(), 'projectId' => $project->project_id, 'slug' => Str::slug($headline), ]), ]; }) ->all(); } private function projectCategoryMap(): array { $locale = app()->getLocale(); $fallbackLocale = ActiveLanguage::getDefaultLanguage('fp') ?: config('app.fallback_locale'); return DB::table('projects_categories as c') ->leftJoin('projects_categories_description as cd', function ($join) use ($locale) { $join->on('c.category_id', '=', 'cd.category_id') ->where('cd.iso', '=', $locale); }) ->leftJoin('projects_categories_description as fallback_cd', function ($join) use ($fallbackLocale) { $join->on('c.category_id', '=', 'fallback_cd.category_id') ->where('fallback_cd.iso', '=', $fallbackLocale); }) ->where('c.parent_id', 0) ->where('c.active', 1) ->orderBy('c.num') ->select('c.category_id', DB::raw('COALESCE(cd.title, fallback_cd.title) as title')) ->get() ->mapWithKeys(function ($category) { $title = (string) ($category->title ?? ''); if ($title === '') { return []; } return [(int) $category->category_id => [ 'id' => (int) $category->category_id, 'title' => $title, 'filter' => (string) Str::of($title)->lower()->replaceMatches('/[^a-z0-9]+/', ''), ]]; }) ->all(); } private function findPublicProject(?int $projectId = null): ?object { $query = $this->applyPublicProjectOrder($this->publicProjectsQuery()); if ($projectId !== null) { $query->where('p.project_id', $projectId); } return $query->first(); } private function nextPublicProject(int $currentProjectId): ?array { $current = $this->findPublicProject($currentProjectId); if (!$current) { return null; } $next = $this->applyPublicProjectOrder( $this->publicProjectsQuery() ->where(function ($query) use ($current, $currentProjectId) { $query->where('p.num', '>', (int) ($current->num ?? 0)) ->orWhere(function ($nestedQuery) use ($current, $currentProjectId) { $nestedQuery->where('p.num', '=', (int) ($current->num ?? 0)) ->where('p.project_id', '<', $currentProjectId); }); }) )->first(); if (!$next) { $next = $this->applyPublicProjectOrder( $this->publicProjectsQuery() ->where('p.project_id', '<>', $currentProjectId) )->first(); } if (!$next) { return null; } $headline = (string) ($next->headline ?: $next->name ?: 'Next project'); return [ 'title' => $headline, 'url' => route('project', [ 'locale' => app()->getLocale(), 'projectId' => $next->project_id, 'slug' => Str::slug($headline), ]), ]; } private function publicProjectsQuery() { $locale = app()->getLocale(); $fallbackLocale = ActiveLanguage::getDefaultLanguage('fp') ?: config('app.fallback_locale'); return DB::table('projects as p') ->leftJoin('projects_description as pd', function ($join) use ($locale) { $join->on('p.project_id', '=', 'pd.project_id') ->where('pd.iso', '=', $locale); }) ->leftJoin('projects_description as fallback_pd', function ($join) use ($fallbackLocale) { $join->on('p.project_id', '=', 'fallback_pd.project_id') ->where('fallback_pd.iso', '=', $fallbackLocale); }) ->where('p.active', 1) ->select([ 'p.project_id', 'p.num', 'p.categories', 'p.picture_cover', 'p.picture_catalog', 'p.picture_catalog_full', 'p.video', 'p.structure', DB::raw('COALESCE(pd.headline, fallback_pd.headline) as headline'), DB::raw('COALESCE(pd.subline, fallback_pd.subline) as subline'), DB::raw('COALESCE(pd.name, fallback_pd.name) as name'), DB::raw('COALESCE(pd.year, fallback_pd.year) as year'), DB::raw('COALESCE(pd.preview, fallback_pd.preview) as preview'), DB::raw('COALESCE(pd.content, fallback_pd.content) as content'), DB::raw('COALESCE(pd.tag1, fallback_pd.tag1) as tag1'), DB::raw('COALESCE(pd.tag2, fallback_pd.tag2) as tag2'), DB::raw('COALESCE(pd.tag3, fallback_pd.tag3) as tag3'), DB::raw('COALESCE(pd.youtube, fallback_pd.youtube) as youtube'), DB::raw('COALESCE(pd.page_title, fallback_pd.page_title) as page_title'), DB::raw('COALESCE(pd.meta_title, fallback_pd.meta_title) as meta_title'), DB::raw('COALESCE(pd.meta_description, fallback_pd.meta_description) as meta_description'), DB::raw('COALESCE(pd.og_title, fallback_pd.og_title) as og_title'), DB::raw('COALESCE(pd.og_description, fallback_pd.og_description) as og_description'), ]); } private function applyPublicProjectOrder($query) { return $query ->orderBy('p.num') ->orderByDesc('p.project_id'); } private function normalizeProjectForBlade(array $payload): array { $structure = $payload['structure'] ?? []; $legacy = $payload['legacy'] ?? []; $lang = $payload['defaultLanguage'] ?? app()->getLocale(); $hasModernStructure = !empty($structure) && ( array_key_exists('header', $structure) || array_key_exists('heroMedia', $structure) || array_key_exists('contentBlocks', $structure) || array_key_exists('blocks', $structure) ); if ($hasModernStructure) { $legacyAwards = $this->bladeNormalizeHtmlList((array) ($legacy['awarded'] ?? [])); $structureAwards = $this->bladeNormalizeHtmlList((array) ($structure['metadata']['awarded'] ?? [])); return [ 'headline' => $this->bladeFirstFilledHtml($structure['header']['headline'] ?? null, $legacy['headline'] ?? null), 'subline' => $this->bladeFirstFilledHtml($structure['header']['subline'] ?? null, $legacy['subline'] ?? null), 'hero' => $this->bladeHeroSlot($structure['heroMedia'] ?? null), 'year' => $this->bladeFirstFilledText($structure['metadata']['year'] ?? null, $legacy['year'] ?? null), 'clientName' => $this->bladeFirstFilledHtml($structure['metadata']['clientName'] ?? null, $legacy['clientName'] ?? null), 'awarded' => !empty($structureAwards) ? $structureAwards : $legacyAwards, 'description'=> $this->bladeFirstFilledHtml($legacy['description'] ?? null, $structure['metadata']['description'] ?? null), 'blocks' => $this->bladeNormalizeBlocks( $structure['contentBlocks'] ?? $structure['blocks'] ?? [], $lang ), ]; } // Fall back to legacy flat fields $heroMedia = null; if (!empty($legacy['youtube'])) { $heroMedia = ['type' => 'youtube', 'url' => $legacy['youtube'], 'poster' => '']; } elseif (!empty($legacy['video'])) { $heroMedia = ['type' => 'video', 'url' => $legacy['video'], 'poster' => '']; } elseif (!empty($legacy['pictureCatalogFull'])) { $heroMedia = ['type' => 'image', 'url' => $legacy['pictureCatalogFull'], 'poster' => '']; } elseif (!empty($legacy['pictureCover'])) { $heroMedia = ['type' => 'image', 'url' => $legacy['pictureCover'], 'poster' => '']; } return [ 'headline' => $this->bladeNormalizeHtml((string) ($legacy['headline'] ?? '')), 'subline' => $this->bladeNormalizeHtml((string) ($legacy['subline'] ?? '')), 'hero' => $this->bladeHeroSlot($heroMedia), 'year' => $this->bladeNormalizeText((string) ($legacy['year'] ?? '')), 'clientName' => $this->bladeNormalizeHtml((string) ($legacy['clientName'] ?? '')), 'awarded' => array_map($this->bladeNormalizeHtml(...), (array) ($legacy['awarded'] ?? [])), 'description' => $this->bladeNormalizeHtml((string) ($legacy['description'] ?? '')), 'blocks' => [], ]; } private function bladeHeroSlot(?array $heroMedia): ?array { if (!$heroMedia) { return null; } $type = $heroMedia['type'] ?? 'image'; $url = $heroMedia['url'] ?? ''; if (!$url) { return null; } if ($type === 'image') { return [ 'type' => 'image', 'text' => '', 'image' => ['url' => $url, 'alt' => ''], 'media' => null, ]; } return [ 'type' => 'video', 'text' => '', 'image' => ['url' => $heroMedia['poster'] ?? '', 'alt' => ''], 'media' => $this->bladeNormalizeMedia($heroMedia), ]; } private function bladeNormalizeBlocks(array $blocks, string $lang): array { $result = []; foreach ($blocks as $block) { $type = $block['type'] ?? ''; // Legacy type migrations if ($type === 'FullWidthText') { $result[] = [ 'type' => 'FullWidth', 'hidden' => (bool) ($block['hidden'] ?? false), 'slot' => [ 'type' => 'text', 'text' => $this->bladeResolveText($block['content'] ?? [], $lang), 'image' => null, 'media' => null, ], ]; continue; } if ($type === 'FullWidthImage') { $img = $block['image'] ?? []; $result[] = [ 'type' => 'FullWidth', 'hidden' => (bool) ($block['hidden'] ?? false), 'slot' => [ 'type' => 'image', 'text' => '', 'image' => ['url' => $img['url'] ?? '', 'alt' => $this->bladeResolveText($img['alt'] ?? [], $lang)], 'media' => null, ], ]; continue; } if ($type === 'Video') { $result[] = [ 'type' => 'FullWidth', 'hidden' => (bool) ($block['hidden'] ?? false), 'slot' => [ 'type' => 'video', 'text' => '', 'image' => null, 'media' => $this->bladeNormalizeMedia($block['media'] ?? null), ], ]; continue; } if ($type === 'TwoColumnImages') { $images = $block['images'] ?? []; $result[] = [ 'type' => 'TwoColumns', 'hidden' => (bool) ($block['hidden'] ?? false), 'left' => $this->bladeImageSlot($images[0] ?? [], $lang), 'right' => $this->bladeImageSlot($images[1] ?? [], $lang), ]; continue; } if ($type === 'TwoColumns') { $result[] = [ 'type' => 'TwoColumns', 'hidden' => (bool) ($block['hidden'] ?? false), 'left' => $this->bladeNormalizeSlot($block['left'] ?? [], $lang), 'right' => $this->bladeNormalizeSlot($block['right'] ?? [], $lang), ]; continue; } // FullWidth (default) $result[] = [ 'type' => 'FullWidth', 'hidden' => (bool) ($block['hidden'] ?? false), 'slot' => $this->bladeNormalizeSlot($block['slot'] ?? [], $lang), ]; } return $result; } private function bladeNormalizeSlot(array $slot, string $lang): array { return [ 'type' => $slot['type'] ?? 'text', 'text' => $this->bladeResolveText($slot['content'] ?? [], $lang), 'image' => [ 'url' => $slot['image']['url'] ?? '', 'alt' => $this->bladeResolveText($slot['image']['alt'] ?? [], $lang), ], 'media' => $this->bladeNormalizeMedia($slot['media'] ?? null), ]; } private function bladeImageSlot(array $image, string $lang): array { return [ 'type' => 'image', 'text' => '', 'image' => ['url' => $image['url'] ?? '', 'alt' => $this->bladeResolveText($image['alt'] ?? [], $lang)], 'media' => null, ]; } private function bladeNormalizeMedia(?array $media): ?array { if (!$media) { return null; } $type = $media['type'] ?? 'image'; $url = $media['url'] ?? ''; if (!$url) { return null; } return [ 'type' => $type, 'url' => $url, 'poster' => $media['poster'] ?? '', 'autoplay' => ($media['autoplay'] ?? false) === true, 'loop' => ($media['loop'] ?? false) === true, 'muted' => ($media['muted'] ?? false) === true, 'embedUrl' => $this->bladeEmbedUrl($type, $media), ]; } private function bladeResolveText(mixed $content, string $lang): string { if (is_string($content)) { return $this->bladeNormalizeHtml($content); } if (!is_array($content) || empty($content)) { return ''; } $values = array_values($content); return $this->bladeNormalizeHtml((string) ($content[$lang] ?? $values[0] ?? '')); } private function bladeNormalizeHtml(string $value): string { $normalized = $value; for ($i = 0; $i < 3; $i++) { $decoded = html_entity_decode($normalized, ENT_QUOTES | ENT_HTML5, 'UTF-8'); if ($decoded === $normalized) { break; } $normalized = $decoded; } return $normalized; } private function bladeFirstFilledHtml(mixed ...$values): string { foreach ($values as $value) { $normalized = $this->bladeNormalizeHtml((string) ($value ?? '')); if (trim(strip_tags($normalized)) !== '') { return $normalized; } } return ''; } private function bladeNormalizeText(string $value): string { return trim(strip_tags($this->bladeNormalizeHtml($value))); } private function bladeFirstFilledText(mixed ...$values): string { foreach ($values as $value) { $normalized = $this->bladeNormalizeText((string) ($value ?? '')); if ($normalized !== '') { return $normalized; } } return ''; } private function bladeNormalizeHtmlList(array $values): array { $normalized = []; foreach ($values as $value) { $item = $this->bladeNormalizeHtml((string) ($value ?? '')); if (trim(strip_tags($item)) !== '') { $normalized[] = $item; } } return $normalized; } private function bladeEmbedUrl(string $type, array|string $media): string { $mediaObject = is_array($media) ? $media : ['url' => $media]; $url = (string) ($mediaObject['url'] ?? ''); if ($type === 'youtube') { preg_match('/(?:v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $url, $m); return isset($m[1]) ? 'https://www.youtube.com/embed/' . $m[1] . '?autoplay=' . (($mediaObject['autoplay'] ?? false) ? '1' : '0') . '&mute=' . (($mediaObject['muted'] ?? false) ? '1' : '0') . '&loop=' . (($mediaObject['loop'] ?? false) ? '1' : '0') . ((($mediaObject['loop'] ?? false) ? '&playlist=' . $m[1] : '')) : ''; } if ($type === 'bunny') { // Normalize /play/ → /embed/ regardless of what was stored $url = preg_replace('#player\.mediadelivery\.net/play/#', 'player.mediadelivery.net/embed/', $url); $params = http_build_query([ 'autoplay' => ($mediaObject['autoplay'] ?? false) ? 'true' : 'false', 'loop' => ($mediaObject['loop'] ?? false) ? 'true' : 'false', 'muted' => ($mediaObject['muted'] ?? false) ? 'true' : 'false', 'preload' => 'true', 'responsive' => 'true', ]); return $url . '?' . $params; } // frameio, video — url is used as-is (embed src or file src) return $url; } private function projectRendererPayload(object $project): array { return [ 'structure' => $this->decodeStructure($project->structure ?? null), 'defaultLanguage' => app()->getLocale(), 'legacy' => [ 'headline' => (string) ($project->headline ?? ''), 'subline' => (string) ($project->subline ?? ''), 'year' => (string) ($project->year ?? ''), 'clientName' => (string) ($project->name ?? ''), 'description' => (string) ($project->preview ?: $project->content ?: ''), 'awarded' => array_values(array_filter([ $project->tag1 ?? null, $project->tag2 ?? null, $project->tag3 ?? null, ])), 'youtube' => (string) ($project->youtube ?? ''), 'video' => $this->projectVideoUrl($project->video ?? null), 'pictureCover' => $this->projectCoverUrl($project->picture_cover ?? null), 'pictureCatalog' => $this->projectCoverUrl($project->picture_catalog ?? null), 'pictureCatalogFull' => $this->projectCoverUrl($project->picture_catalog_full ?? null), 'gallery' => $this->projectGallery((int) $project->project_id), ], ]; } private function projectGallery(int $projectId): array { return DB::table('projects_gallery') ->where('project_id', $projectId) ->orderBy('num') ->get() ->map(function ($photo) { return [ 'url' => asset('files/projects/gallery/' . (int) floor($photo->project_id / 100) . '/' . $photo->project_id . '/' . $photo->diskname), 'alt' => (string) ($photo->name ?? ''), ]; }) ->values() ->all(); } private function decodeCategoryIds(?string $categories): array { $decoded = json_decode($categories ?? '[]', true); return is_array($decoded) ? $decoded : []; } private function decodeStructure(?string $structure): array { $normalized = html_entity_decode((string) ($structure ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $decoded = json_decode($normalized, true); return is_array($decoded) ? $decoded : []; } private function projectCoverForGrid(object $project, int $index): string { return $this->projectCoverUrl($project->picture_cover ?? null) ?? $this->projectCoverUrl($project->picture_catalog ?? null) ?? asset('assets/img/portfolio-' . (($index % 6) + 1) . '.jpg'); } private function projectThumbnailForGrid(object $project, int $index): array { $structure = $this->decodeStructure($project->structure ?? null); $rawThumbnailMedia = is_array($structure['thumbnailMedia'] ?? null) ? $structure['thumbnailMedia'] : null; $thumbnailMedia = $this->bladeNormalizeMedia( $rawThumbnailMedia ); if (($thumbnailMedia['type'] ?? null) === 'bunny' && !empty($thumbnailMedia['embedUrl'])) { return [ 'type' => 'bunny', 'url' => '', 'embedUrl' => $thumbnailMedia['embedUrl'], ]; } $heroMedia = $this->bladeNormalizeMedia( is_array($structure['heroMedia'] ?? null) ? $structure['heroMedia'] : null ); $prefersBunnyThumbnail = ($rawThumbnailMedia['type'] ?? null) === 'bunny'; if ($prefersBunnyThumbnail && ($heroMedia['type'] ?? null) === 'bunny' && !empty($heroMedia['embedUrl'])) { return [ 'type' => 'bunny', 'url' => '', 'embedUrl' => $heroMedia['embedUrl'], ]; } return [ 'type' => 'image', 'url' => $this->projectCoverForGrid($project, $index), 'embedUrl' => '', ]; } private function projectCoverUrl(?string $filename): ?string { if (!$filename) { return null; } return asset('files/projects/cover/' . $filename); } private function projectVideoUrl(?string $filename): ?string { if (!$filename) { return null; } return asset('files/projects/video/' . $filename); } private function defaultProjectGroup(): array { return [ 'name' => 'Aritmija', 'label' => 'Uploaded by', 'icon' => 'fas fa-layer-group', 'color' => '#4050FF', ]; } }