{{/* Hugo Blox: Portfolio */}} {{/* Documentation: https://hugoblox.com/blocks/ */}} {{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}} {{/* Initialise */}} {{ $page := .wcPage }} {{ $block := .wcBlock }} {{ $content := $block.content }} {{ if not (reflect.IsMap $content) }}{{ $content = dict }}{{ end }} {{ $design := $block.design }} {{ if not (reflect.IsMap $design) }}{{ $design = dict }}{{ end }} {{/* Enable Alpine.js and animations for this block */}} {{ $page.Store.Set "has_alpine" true }} {{ $animations_enabled := true }} {{ if isset $design "animations" }} {{ $animations_enabled = partial "functions/coerce_bool" (dict "value" (index $design "animations") "default" true) }} {{ end }} {{ if $animations_enabled }} {{ $page.Store.Set "has_animations" true }} {{ end }} {{/* Query projects */}} {{ $query := site.Pages }} {{ $filters := index $content "filters" }} {{ if not (reflect.IsMap $filters) }}{{ $filters = dict }}{{ end }} {{ $folders_raw := index $filters "folders" }} {{ $folders := slice }} {{ if reflect.IsSlice $folders_raw }} {{ range $folders_raw }} {{ $folder := strings.TrimSpace (printf "%v" .) }} {{ if $folder }}{{ $folders = $folders | append $folder }}{{ end }} {{ end }} {{ else if eq (printf "%T" $folders_raw) "string" }} {{ $folder := strings.TrimSpace $folders_raw }} {{ if $folder }}{{ $folders = slice $folder }}{{ end }} {{ end }} {{ if eq (len $folders) 0 }}{{ $folders = slice "projects" }}{{ end }} {{ $query = where $query "Section" "in" $folders }} {{ $query = where $query "Kind" "eq" "page" }} {{/* Get archive page for "View All" link */}} {{ $archive_page := "" }} {{ $main_folder := index $folders 0 }} {{ $archive_page = site.GetPage "Section" $main_folder }} {{/* Sort */}} {{ $sort_by := "Date" }} {{ $sort_by_raw := index $content "sort_by" }} {{ if eq (printf "%T" $sort_by_raw) "string" }} {{ $sort_by_raw = strings.TrimSpace $sort_by_raw }} {{ if ne $sort_by_raw "" }}{{ $sort_by = $sort_by_raw }}{{ end }} {{ end }} {{ $sort_by = partial "functions/get_sort_by_parameter" $sort_by }} {{ $sort_ascending := partial "functions/coerce_bool" (dict "value" (index $content "sort_ascending") "default" false) }} {{ $sort_order := cond $sort_ascending "asc" "desc" }} {{ $query = sort $query $sort_by $sort_order }} {{ $all_items := $query }} {{/* Get filter buttons */}} {{ $buttons_raw := index $content "buttons" }} {{ $buttons := slice }} {{ if reflect.IsSlice $buttons_raw }} {{ $buttons = $buttons_raw }} {{ else if and $buttons_raw (reflect.IsMap $buttons_raw) }} {{ $buttons = slice $buttons_raw }} {{ end }} {{ if eq (len $buttons) 0 }} {{ $buttons = slice (dict "name" "All" "tag" "*") }} {{ end }} {{ $default_idx := partial "functions/coerce_int" (dict "value" (index $content "default_button_index") "default" 0 "min" 0) }} {{ $default_tag := "*" }} {{ if lt $default_idx (len $buttons) }} {{ with index $buttons $default_idx }} {{ if reflect.IsMap . }} {{ with index . "tag" }}{{ $default_tag = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ end }} {{ end }} {{ end }} {{ if eq $default_tag "" }}{{ $default_tag = "*" }}{{ end }} {{/* Status configuration */}} {{ $status_content := index $content "status" }} {{ if not (reflect.IsMap $status_content) }}{{ $status_content = dict }}{{ end }} {{ $status_text_override := "" }} {{ with index $status_content "text" }} {{ if eq (printf "%T" .) "string" }}{{ $status_text_override = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ end }} {{ $status_design := index $design "status_badge" }} {{ if not (reflect.IsMap $status_design) }}{{ $status_design = dict }}{{ end }} {{ $show_status_badge := partial "functions/coerce_bool" (dict "value" (index $status_design "enable") "default" true) }} {{/* Store total count before limiting */}} {{ $total_count := len $all_items }} {{/* Limit per filter while keeping per-button visibility accurate */}} {{ $count := partial "functions/coerce_int" (dict "value" (index $content "count") "default" 0 "min" 0) }} {{ $button_items := dict }} {{ $allowed_map := dict }} {{ range $buttons }} {{ if reflect.IsMap . }} {{ $tag := "*" }} {{ with index . "tag" }}{{ $tag = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ if eq $tag "" }}{{ $tag = "*" }}{{ end }} {{ $tag_key := lower $tag }} {{ $items := slice }} {{ if le $count 0 }} {{ if eq $tag_key "*" }} {{ $items = $all_items }} {{ else }} {{ $items = where $all_items "Params.tags" "intersect" (slice $tag_key) }} {{ if eq (len $items) 0 }} {{ $items = where $all_items "Params.tags" "intersect" (slice $tag) }} {{ end }} {{ end }} {{ else }} {{ if eq $tag_key "*" }} {{ $items = first $count $all_items }} {{ else }} {{ $items = first $count (where $all_items "Params.tags" "intersect" (slice $tag_key)) }} {{ if eq (len $items) 0 }} {{ $items = first $count (where $all_items "Params.tags" "intersect" (slice $tag)) }} {{ end }} {{ end }} {{ end }} {{ $button_items = merge $button_items (dict $tag_key $items) }} {{ range $items }} {{ $key := .RelPermalink }} {{ $allowed := slice }} {{ with index $allowed_map $key }}{{ $allowed = . }}{{ end }} {{ if not (in $allowed $tag) }} {{ $allowed = $allowed | append $tag }} {{ end }} {{ $allowed_map = merge $allowed_map (dict $key $allowed) }} {{ end }} {{ end }} {{ end }} {{/* Build render list in sorted order, only keeping items that belong to a filter */}} {{ $render_items := slice }} {{ range $all_items }} {{ if isset $allowed_map .RelPermalink }} {{ $render_items = $render_items | append . }} {{ end }} {{ end }} {{/* Fallback to all items if something went wrong */}} {{ if eq (len $render_items) 0 }} {{ $render_items = $all_items }} {{ range $render_items }} {{ $allowed_map = merge $allowed_map (dict .RelPermalink (slice "*")) }} {{ end }} {{ end }} {{ $query = $render_items }} {{ $default_items := index $button_items (lower $default_tag) | default (slice) }} {{ $displayed_count := len $default_items }} {{/* Columns */}} {{ $columns := partial "functions/coerce_int" (dict "value" (index $design "columns") "default" 3 "min" 1 "max" 4) }} {{ $grid_class := "md:grid-cols-2 lg:grid-cols-3" }} {{ if eq $columns 2 }} {{ $grid_class = "md:grid-cols-2" }} {{ else if eq $columns 4 }} {{ $grid_class = "md:grid-cols-2 lg:grid-cols-4" }} {{ end }} {{ $fallback_icon := "code-bracket" }} {{ $fallback_raw := index $design "fallback_icon" }} {{ if eq (printf "%T" $fallback_raw) "string" }} {{ $fallback_icon = $fallback_raw }} {{ end }}
{{/* Header */}}
{{ with index $content "title" }}

{{ printf "%v" . | emojify | $page.RenderString }}

{{ end }} {{ with index $content "subtitle" }}

{{ printf "%v" . | emojify | $page.RenderString }}

{{ end }}
{{/* Filter Buttons */}} {{ if gt (len $buttons) 1 }}
{{ range $idx, $button := $buttons }} {{ if reflect.IsMap $button }} {{ $button_name := "" }} {{ with index $button "name" }}{{ $button_name = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ $button_tag := "*" }} {{ with index $button "tag" }}{{ $button_tag = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ end }} {{ end }}
{{ end }} {{/* Project Grid with staggered reveal animation */}}
{{ range $index, $item := $query }} {{ $item_key := $item.RelPermalink }} {{ $card_allowed_filters := index $allowed_map $item_key }} {{ if not $card_allowed_filters }}{{ $card_allowed_filters = slice "*" }}{{ end }} {{ $card_allowed_filters_json := $card_allowed_filters | jsonify }} {{/* Get tags as comma-separated string */}} {{ $tags := slice }} {{ $tags_raw := $item.Params.tags }} {{ if reflect.IsSlice $tags_raw }} {{ range $tags_raw }} {{ $tag := strings.TrimSpace (printf "%v" .) }} {{ if $tag }}{{ $tags = $tags | append $tag }}{{ end }} {{ end }} {{ else if eq (printf "%T" $tags_raw) "string" }} {{ $tag_string := strings.TrimSpace $tags_raw }} {{ if $tag_string }} {{ if strings.Contains $tag_string "," }} {{ range (split $tag_string ",") }} {{ $tag := strings.TrimSpace . }} {{ if $tag }}{{ $tags = $tags | append $tag }}{{ end }} {{ end }} {{ else }} {{ $tags = slice $tag_string }} {{ end }} {{ end }} {{ end }} {{/* Get status */}} {{ $status := "" }} {{ $status_raw := $item.Params.status }} {{ if eq (printf "%T" $status_raw) "string" }} {{ $status = strings.TrimSpace $status_raw }} {{ end }} {{ $status_display := $status }} {{ if ne $status_text_override "" }} {{ $status_display = $status_text_override }} {{ end }} {{ $status_color_source := cond (ne $status "" ) $status $status_display }} {{ $status_norm := upper $status_color_source }} {{ $status_class := "bg-emerald-600 text-white border-emerald-500 shadow-md shadow-emerald-500/30 dark:bg-emerald-500 dark:border-emerald-400" }} {{ if eq $status_norm "WIP" }} {{ $status_class = "bg-amber-500 text-white border-amber-400 shadow-md shadow-amber-400/30 dark:bg-amber-500" }} {{ else if eq $status_norm "ARCHIVED" }} {{ $status_class = "bg-gray-600 text-white border-gray-500 shadow-md shadow-gray-500/30 dark:bg-gray-500" }} {{ end }} {{ $render_status_badge := and $show_status_badge (ne $status_display "") }}
{{/* Card Header / Image */}}
{{ $image := $item.Resources.GetMatch "{featured,cover,thumbnail}*" }} {{ if $image }} {{ if not (reflect.IsImageResourceProcessable $image) }} {{ $item.Title }} {{ else }} {{ $img := $image.Fill "600x400 webp" }} {{ $item.Title }} {{ end }} {{ else }} {{/* Gradient placeholder with configurable icon */}}
{{ partial "functions/get_icon" (dict "name" $fallback_icon "attributes" "class=\"w-16 h-16 text-white/30\"") }}
{{ end }} {{/* Status Badge */}} {{ if $render_status_badge }}
{{ $status_display }}
{{ end }} {{/* Gradient Overlay */}}
{{/* Card Content - use flex-col to push links to bottom */}}
{{/* Title */}}

{{ $item.Title }}

{{/* Summary */}} {{ $summary := "" }} {{ $summary_raw := $item.Params.summary }} {{ if $summary_raw }} {{ $summary = strings.TrimSpace (printf "%v" $summary_raw) }} {{ end }} {{ if $summary }}

{{ $summary | $page.RenderString }}

{{ else }}

{{ $item.Summary | truncate 100 }}

{{ end }} {{/* Tech Stack Tags - fixed 2-line height for consistency */}} {{ $tech_stack := slice }} {{ $tech_raw := $item.Params.tech_stack }} {{ if reflect.IsSlice $tech_raw }} {{ range $tech_raw }} {{ $tech := strings.TrimSpace (printf "%v" .) }} {{ if $tech }}{{ $tech_stack = $tech_stack | append $tech }}{{ end }} {{ end }} {{ else if eq (printf "%T" $tech_raw) "string" }} {{ $tech_string := strings.TrimSpace $tech_raw }} {{ if $tech_string }} {{ if strings.Contains $tech_string "," }} {{ range (split $tech_string ",") }} {{ $tech := strings.TrimSpace . }} {{ if $tech }}{{ $tech_stack = $tech_stack | append $tech }}{{ end }} {{ end }} {{ else }} {{ $tech_stack = slice $tech_string }} {{ end }} {{ end }} {{ end }} {{ if gt (len $tech_stack) 0 }}
{{ range first 4 $tech_stack }} {{ . }} {{ end }} {{ if gt (len $tech_stack) 4 }} +{{ sub (len $tech_stack) 4 }} {{ end }}
{{ end }} {{/* Action Links - pushed to bottom with mt-auto */}} {{ $links := slice }} {{ $links_raw := $item.Params.links }} {{ $links_iter := slice }} {{ if reflect.IsSlice $links_raw }} {{ $links_iter = $links_raw }} {{ else if and $links_raw (reflect.IsMap $links_raw) }} {{ $links_iter = slice $links_raw }} {{ end }} {{ if gt (len $links_iter) 0 }} {{ range $links_iter }} {{ $url := "" }} {{ $label := "" }} {{ $icon := "" }} {{ $type := "" }} {{ $new_tab := false }} {{ if reflect.IsMap . }} {{ with index . "url" }}{{ $url = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ if not $url }} {{ with index . "link" }}{{ $url = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ end }} {{ with index . "label" }}{{ $label = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ if not $label }} {{ with index . "name" }}{{ $label = strings.TrimSpace (printf "%v" .) }}{{ end }} {{ end }} {{ with index . "type" }}{{ $type = lower (strings.TrimSpace (printf "%v" .)) }}{{ end }} {{ with index . "icon" }}{{ $icon = printf "%v" . }}{{ end }} {{ $new_tab = partial "functions/coerce_bool" (dict "value" (index . "new_tab") "default" false) }} {{ else if eq (printf "%T" .) "string" }} {{ $url = strings.TrimSpace . }} {{ end }} {{ if $url }} {{ if not $label }} {{ if eq $type "github" }} {{ $label = i18n "portfolio_link_code" | default "Code" }} {{ else if eq $type "live" }} {{ $label = i18n "portfolio_link_live" | default "Live" }} {{ else if eq $type "demo" }} {{ $label = i18n "portfolio_link_demo" | default "Demo" }} {{ else }} {{ $label = i18n "portfolio_link_default" | default "Link" }} {{ end }} {{ end }} {{ if not $icon }} {{ if eq $type "github" }} {{ $icon = "brands/github" }} {{ else if eq $type "live" }} {{ $icon = "globe-alt" }} {{ else if eq $type "demo" }} {{ $icon = "play" }} {{ else }} {{ $icon = "link" }} {{ end }} {{ end }} {{ $links = $links | append (dict "url" $url "label" $label "icon" $icon "new_tab" $new_tab) }} {{ end }} {{ end }} {{ end }} {{ if gt (len $links) 0 }}
{{ range $links }} {{ $icon := index . "icon" | default "link" }} {{ $label := index . "label" | default (i18n "portfolio_link_default" | default "Link") }} {{ $link_url := index . "url" }} {{ $new_tab := partial "functions/coerce_bool" (dict "value" (index . "new_tab") "default" false) }} {{ $target := "" }} {{ $scheme := (urls.Parse $link_url).Scheme }} {{ if not $scheme }} {{ if not (hasPrefix $link_url "#") }} {{ $link_url = $link_url | relLangURL }} {{ end }} {{ if eq (path.Ext $link_url) ".pdf" }}{{ $new_tab = true }}{{ end }} {{ else if in (slice "http" "https") $scheme }} {{ $new_tab = true }} {{ end }} {{ if $new_tab }} {{ $target = "target=\"_blank\" rel=\"noopener noreferrer\"" }} {{ end }} {{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-4 h-4\"") }} {{ $label }} {{ end }}
{{ end }}
{{ end }}
{{/* View All Link */}} {{/* Smart default: show link if there are more items than displayed, unless explicitly disabled */}} {{ $archive := index $content "archive" }} {{ if not (reflect.IsMap $archive) }}{{ $archive = dict }}{{ end }} {{ $default_show_archive := false }} {{ $default_show_archive = gt $total_count $displayed_count }} {{ $show_archive_link := partial "functions/coerce_bool" (dict "value" (index $archive "enable") "default" $default_show_archive) }} {{ if and $show_archive_link $archive_page }} {{ $archive_link := "" }} {{ $archive_link_raw := index $archive "link" }} {{ if eq (printf "%T" $archive_link_raw) "string" }} {{ $archive_link = strings.TrimSpace $archive_link_raw }} {{ end }} {{ if not $archive_link }} {{ $archive_link = $archive_page.RelPermalink }} {{ end }} {{ $archive_target := "" }} {{ $archive_scheme := (urls.Parse $archive_link).Scheme }} {{ if not $archive_scheme }} {{ if not (hasPrefix $archive_link "#") }} {{ $archive_link = $archive_link | relLangURL }} {{ end }} {{ else if in (slice "http" "https") $archive_scheme }} {{ $archive_target = "target=\"_blank\" rel=\"noopener\"" }} {{ end }} {{ $archive_text := "" }} {{ $archive_text_raw := index $archive "text" }} {{ if eq (printf "%T" $archive_text_raw) "string" }} {{ $archive_text = strings.TrimSpace $archive_text_raw }} {{ end }} {{ if not $archive_text }}{{ $archive_text = i18n "portfolio_view_all" | default "View All Projects" }}{{ end }}
{{ $archive_text | emojify }} {{ partial "functions/get_icon" (dict "name" "arrow-right" "attributes" "class=\"w-4 h-4\"") }}
{{ end }}