diff --git a/index.html b/index.html index d124463..1170285 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,7 @@ +
diff --git a/priv/static/images/open-graph.png b/priv/static/images/open-graph.png index 819e3ba..be18172 100644 Binary files a/priv/static/images/open-graph.png and b/priv/static/images/open-graph.png differ diff --git a/sitemap.xml b/sitemap.xml index 82a582c..bafced1 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -2,7 +2,7 @@ https://links.juleskab.lat/ - 2026-01-11 + 2026-04-30 monthly 1.0 diff --git a/src/effects.mjs b/src/effects.mjs new file mode 100644 index 0000000..352fd38 --- /dev/null +++ b/src/effects.mjs @@ -0,0 +1,35 @@ +export function setupCursorEffect(onLoaded, onMouseMoved) { + setTimeout(onLoaded, 120); + + const el = () => document.getElementById("cursor"); + + window.addEventListener("mousemove", (e) => { + const c = el(); + if (c) { + c.style.left = e.clientX + "px"; + c.style.top = e.clientY + "px"; + } + const normX = e.clientX / (window.innerWidth || 1); + const normY = e.clientY / (window.innerHeight || 1); + onMouseMoved(normX, normY); + }); + + document.addEventListener("mouseover", (e) => { + if (!e.target.closest("a")) return; + const c = el(); + if (!c) return; + c.style.width = "36px"; + c.style.height = "36px"; + c.style.opacity = "0.35"; + }); + + document.addEventListener("mouseout", (e) => { + const link = e.target.closest("a"); + if (!link || link.contains(e.relatedTarget)) return; + const c = el(); + if (!c) return; + c.style.width = ""; + c.style.height = ""; + c.style.opacity = ""; + }); +} diff --git a/src/linktri.gleam b/src/linktri.gleam index 3766c0d..310b0c3 100644 --- a/src/linktri.gleam +++ b/src/linktri.gleam @@ -1,22 +1,16 @@ +import gleam/float import gleam/int import gleam/list -import gleam/option.{type Option, None, Some} -import gleam/string import lustre import lustre/attribute +import lustre/effect.{type Effect} import lustre/element.{type Element} import lustre/element/html -import styles.{general_styles} +import lustre/element/svg +import ui +import vans_styles.{vans_styles} -fn description() -> String { - string.join( - [ - "Software Engineer", "Tech Enthusiast", "Metalhead", - "Wannabe Pixel Artist", - ], - with: " ", - ) -} +// ── External JS ──────────────────────────────────────────────────────────── @external(javascript, "./date.mjs", "getCurrentYear") fn get_current_year() -> Int @@ -24,7 +18,13 @@ fn get_current_year() -> Int @external(javascript, "./routing.mjs", "getPath") fn get_path() -> String -const full_name = "Julio Cabrera" +@external(javascript, "./effects.mjs", "setupCursorEffect") +fn do_setup_cursor_effect( + on_loaded: fn() -> Nil, + on_mouse_moved: fn(Float, Float) -> Nil, +) -> Nil + +// ── Routing ───────────────────────────────────────────────────────────────── pub type Route { Home @@ -38,227 +38,676 @@ fn get_route() -> Route { } } -fn start_year() -> String { - int.to_string(2024) -} - -fn footer_content() -> String { - "© " - <> start_year() - <> "-" - <> int.to_string(get_current_year()) - <> " ~ " - <> " Juleskab" -} - -pub fn main() { - let app = lustre.simple(init, update, view) - let assert Ok(_) = lustre.start(app, "#app", Nil) -} - -// MODEL +// ── Types ─────────────────────────────────────────────────────────────────── pub type LinkType { - GitHub + Email LinkedIn - Portfolio Twitter + GitHub + Portfolio Blog - Email } pub type Link { - Link(title: String, url: String, link_type: LinkType, display_name: String) + Link(label: String, sub: String, href: String, link_type: LinkType) +} + +pub type LinkGroup { + LinkGroup(label: String, items: List(Link)) } pub type Model { Model( - social_links: List(Link), - work_links: List(Link), - hover_link: Option(String), - route: Route + link_groups: List(LinkGroup), + loaded: Bool, + mouse_x: Float, + mouse_y: Float, + route: Route, ) } -fn init(_flags) -> #(Model, Nil) { - let social_links = [ - Link("Email", "mailto:hello@juleskab.lat", Email, "hello@juleskab.lat"), - Link("LinkedIn", "https://www.linkedin.com/in/julio-cabrera-820", LinkedIn, "julio cabrera"), - Link("X", "https://twitter.com/arielcabrera_11", Twitter, "arielcabrera_11"), - ] - let work_links = [ - Link("GitHub", "https://github.com/juliocabrera820", GitHub, "juliocabrera820"), - Link("Portfolio", "https://juleskab.lat", Portfolio, "Portfolio"), - Link("Blog", "https://juleskab.lat/blog", Blog, "Blog"), +pub type Msg { + Loaded + MouseMoved(Float, Float) +} + +// ── Init ──────────────────────────────────────────────────────────────────── + +fn init(_flags) -> #(Model, Effect(Msg)) { + let link_groups = [ + LinkGroup("Contact", [ + Link( + "Personal Email", + "hello@juleskab.lat", + "mailto:hello@juleskab.lat", + Email, + ), + ]), + LinkGroup("Social Networks", [ + Link( + "LinkedIn", + "linkedin.com/in/julio-cabrera-820", + "https://www.linkedin.com/in/julio-cabrera-820", + LinkedIn, + ), + Link( + "Twitter / X", + "@arielcabrera_11", + "https://twitter.com/arielcabrera_11", + Twitter, + ), + ]), + LinkGroup("Projects", [ + Link( + "GitHub", + "github.com/juliocabrera820", + "https://github.com/juliocabrera820", + GitHub, + ), + Link("Portfolio", "juleskab.lat", "https://juleskab.lat", Portfolio), + Link("Blog", "juleskab.lat/blog", "https://blog.juleskab.lat", Blog), + ]), ] - #(Model(social_links: social_links, work_links: work_links, hover_link: None, route: get_route()), Nil) + #( + Model( + link_groups: link_groups, + loaded: False, + mouse_x: 0.5, + mouse_y: 0.5, + route: get_route(), + ), + effect.from(fn(dispatch) { + do_setup_cursor_effect( + fn() { dispatch(Loaded) }, + fn(x, y) { dispatch(MouseMoved(x, y)) }, + ) + }), + ) } -// UPDATE +// ── Update ────────────────────────────────────────────────────────────────── -pub type Msg { - MouseEnter(String) - MouseLeave - NavigateTo(Route) +pub fn main() { + let app = lustre.application(init, update, view) + let assert Ok(_) = lustre.start(app, "#app", Nil) } -fn update(model: #(Model, Nil), msg: Msg) -> #(Model, Nil) { +fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { - MouseEnter(url) -> #(Model(..model.0, hover_link: Some(url)), Nil) - MouseLeave -> #(Model(..model.0, hover_link: None), Nil) - NavigateTo(route) -> #(Model(..model.0, route: route), Nil) + Loaded -> #(Model(..model, loaded: True), effect.none()) + MouseMoved(x, y) -> + #(Model(..model, mouse_x: x, mouse_y: y), effect.none()) } } -// ICONS - Using CSS classes with background images +// ── Icon mapping ───────────────────────────────────────────────────────────── -fn get_icon_class(link_type: LinkType) -> String { +fn get_icon(link_type: LinkType) -> Element(Msg) { case link_type { - GitHub -> "link-icon link-icon-github" - LinkedIn -> "link-icon link-icon-linkedin" - Portfolio -> "link-icon link-icon-portfolio" - Twitter -> "link-icon link-icon-twitter" - Blog -> "link-icon link-icon-blog" - Email -> "link-icon link-icon-email" + Email -> ui.icon_mail() + LinkedIn -> ui.icon_linkedin() + Twitter -> ui.icon_twitter() + GitHub -> ui.icon_github() + Portfolio -> ui.icon_portfolio() + Blog -> ui.icon_blog() } } -// VIEW - -fn view(model_tuple: #(Model, Nil)) -> Element(Msg) { - let model = model_tuple.0 - - let font_styles = html.style([], general_styles()) +// ── View ───────────────────────────────────────────────────────────────────── +fn view(model: Model) -> Element(Msg) { let content = case model.route { Home -> view_home(model) NotFound -> view_404() } - html.div([attribute.class("root")], [ - font_styles, - html.div([attribute.class("container")], [content]), - ]) + html.div([], [html.style([], vans_styles()), content]) } +// ── 404 ────────────────────────────────────────────────────────────────────── + fn view_404() -> Element(Msg) { html.div([attribute.class("error-page")], [ - // Skateboard icon animation html.div([attribute.class("error-skateboard")], [ - html.span([attribute.class("skateboard-emoji")], [html.text("🛹")]) + html.span([attribute.class("skateboard-emoji")], [html.text("🛹")]), ]), - // Error code with glitch effect html.h1([attribute.class("error-code")], [html.text("404")]), - // Main message html.p([attribute.class("error-title")], [ - html.text("You went off the wall!") + html.text("You went off the wall!"), ]), - // Subtitle html.p([attribute.class("error-subtitle")], [ - html.text("Looks like this page bailed on you...") + html.text("Looks like this page bailed on you..."), ]), - // Fun skateboarding terms html.div([attribute.class("error-tips")], [ html.p([attribute.class("error-tip")], [ - html.text("Maybe try a different trick? 🤙") - ]) + html.text("Maybe try a different trick? 🤙"), + ]), ]), - // Back button with style html.a([attribute.href("/"), attribute.class("error-button")], [ html.span([attribute.class("error-button-icon")], [html.text("←")]), - html.span([attribute.class("error-button-text")], [html.text("Back to the ramp")]) - ]) + html.span([attribute.class("error-button-text")], [ + html.text("Back to the ramp"), + ]), + ]), ]) } +// ── Home ───────────────────────────────────────────────────────────────────── + fn view_home(model: Model) -> Element(Msg) { - // Profile card with avatar, name, bio and status - let profile_card = - html.div([attribute.class("profile-card")], [ - html.div([attribute.class("profile")], [ - // Avatar with decorative ring - html.div([attribute.class("avatar-wrapper")], [ - html.div([attribute.class("avatar-ring")], []), - html.img([ - attribute.src("/priv/static/images/avatar.webp"), - attribute.alt(full_name), - attribute.class("avatar"), - ]), + let px = { model.mouse_x -. 0.5 } *. 24.0 + let py = { model.mouse_y -. 0.5 } *. 18.0 + let parallax_transform = + "translate(" + <> float.to_string(px) + <> "px, " + <> float.to_string(py) + <> "px)" + + let num_groups = list.length(model.link_groups) + + html.div([], [ + // Checkerboard corners + view_checker_tl(), + view_checker_br(), + // Rotated side label + html.div([attribute.class("side-label")], [ + html.text("OFF THE WALL — SINCE 1966"), + ]), + // Scrolling bottom marquee + view_marquee(), + // Main page content + html.div([attribute.class("page")], [ + view_parallax_bg(parallax_transform), + html.div( + [attribute.class("card")], + list.flatten([ + [ + view_avatar(model.loaded), + view_name_block(model.loaded), + html.div([attribute.class("divider")], []), + ], + list.flatten( + list.index_map(model.link_groups, fn(group, gi) { + view_link_group(group, gi, num_groups, model.loaded) + }), + ), + [view_footer_tag(model.loaded)], ]), - html.h1([attribute.class("profile-name")], [html.text(full_name)]), - html.p([attribute.class("profile-bio")], [html.text(description())]), - // Status badge - html.div([attribute.class("status-badge")], [ - html.span([attribute.class("status-dot")], []), - html.span([], [html.text("Available for work")]) - ]) - ]) - ]) - - // Section title for social links - let social_section_title = - html.div([attribute.class("section-title")], [ - html.span([attribute.class("section-title-line")], []), - html.span([attribute.class("section-title-text")], [html.text("Connect with me")]), - html.span([attribute.class("section-title-line")], []) - ]) - - let social_link_elements = - list.map(model.social_links, render_link(model.hover_link, _)) - - // Section title for work links - let work_section_title = - html.div([attribute.class("section-title section-title-work")], [ - html.span([attribute.class("section-title-line")], []), - html.span([attribute.class("section-title-text")], [html.text("My work")]), - html.span([attribute.class("section-title-line")], []) - ]) - - let work_link_elements = - list.map(model.work_links, render_link(model.hover_link, _)) - - let footer = html.footer([attribute.class("footer")], [ - html.div([attribute.class("footer-text")], [ - html.text(footer_content()), - html.span([attribute.class("footer-heart")], [html.text(" ♥")]) - ]) + ), + ]), ]) +} - html.div([], [ - profile_card, - social_section_title, - html.div([attribute.class("main-content")], social_link_elements), - work_section_title, - html.div([attribute.class("main-content")], work_link_elements), - footer +fn view_avatar(loaded: Bool) -> Element(Msg) { + let wrap_class = case loaded { + True -> "avatar-wrap visible" + False -> "avatar-wrap" + } + html.div([attribute.class(wrap_class)], [ + html.div([attribute.class("avatar-ring")], [ + html.img([attribute.src("/priv/static/images/avatar.webp"), attribute.alt("Jules Kab"), attribute.class("avatar-placeholder")]) + ]), + html.div([attribute.class("avatar-badge")], [ + svg.svg( + [ + attribute.attribute("viewBox", "0 0 12 12"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.path([ + attribute.attribute("d", "M2 6h8M6 2l4 4-4 4"), + attribute.attribute("stroke", "#FAFAF7"), + attribute.attribute("stroke-width", "1.5"), + attribute.attribute("stroke-linecap", "round"), + attribute.attribute("stroke-linejoin", "round"), + ]), + ], + ), + ]), + ]) +} + +fn view_name_block(loaded: Bool) -> Element(Msg) { + let block_class = case loaded { + True -> "name-block visible" + False -> "name-block" + } + html.div([attribute.class(block_class)], [ + html.div([attribute.class("name")], [ + html.text("JULES"), + html.span([attribute.class("name-accent")], [html.text("KAB")]), + ]), + html.div([attribute.class("otw-tag")], [ + html.text("\"OFF THE WALL, ON THE KEYBOARD\""), + ]), + html.div([attribute.class("tagline")], [ + html.text(" Software Engineer · Tech Enthusiast"), + html.br([]), + html.text("Metalhead · Wannabe Pixel Artist"), + ]), ]) } -// --- RENDER HELPERS --- +fn view_footer_tag(loaded: Bool) -> Element(Msg) { + let footer_class = case loaded { + True -> "footer-tag visible" + False -> "footer-tag" + } + html.div([attribute.class(footer_class)], [ + html.text( + "JULESKAB · ON THE KEYBOARD · ©2024-" + <> int.to_string(get_current_year()), + ), + ]) +} -fn render_link(hover_link: Option(String), link: Link) -> Element(Msg) { - let is_hover = case hover_link { - Some(url) if url == link.url -> True - _ -> False +fn view_link_group( + group: LinkGroup, + group_idx: Int, + total_groups: Int, + loaded: Bool, +) -> List(Element(Msg)) { + let label_class = case loaded { + True -> "section-label visible" + False -> "section-label" } + let label_delay = + float.to_string(0.4 +. int.to_float(group_idx) *. 0.12) <> "s" + + let items = + list.index_map(group.items, fn(link, item_idx) { + let delay = + 0.5 + +. int.to_float(group_idx) + *. 0.12 + +. int.to_float(item_idx) + *. 0.09 + ui.link_item( + link.label, + link.sub, + link.href, + get_icon(link.link_type), + loaded, + float.to_string(delay) <> "s", + ) + }) + + let divider = case group_idx < total_groups - 1 { + True -> + html.div( + [ + attribute.class("divider"), + attribute.style([#("margin-bottom", "12px")]), + ], + [], + ) + False -> element.none() + } + + [ + html.div( + [ + attribute.class(label_class), + attribute.style([#("transition-delay", label_delay)]), + ], + [html.text("— " <> group.label)], + ), + html.div([attribute.class("links-group")], items), + divider, + ] +} + +// ── Parallax background decoratives ────────────────────────────────────────── + +fn view_parallax_bg(transform: String) -> Element(Msg) { + html.div( + [ + attribute.class("parallax-bg"), + attribute.style([ + #("transform", transform), + #("transition", "transform 0.6s cubic-bezier(0.23, 1, 0.32, 1)"), + ]), + ], + [ + // Soft blobs + html.div( + [ + attribute.class("parallax-circle"), + attribute.style([ + #("width", "320px"), + #("height", "320px"), + #("top", "10%"), + #("right", "8%"), + ]), + ], + [], + ), + html.div( + [ + attribute.class("parallax-circle"), + attribute.style([ + #("width", "180px"), + #("height", "180px"), + #("bottom", "18%"), + #("left", "6%"), + #("opacity", "0.4"), + ]), + ], + [], + ), + html.div( + [ + attribute.class("parallax-circle"), + attribute.style([ + #("width", "80px"), + #("height", "80px"), + #("top", "38%"), + #("left", "16%"), + #("opacity", "0.3"), + ]), + ], + [], + ), + // Ghost text + html.div( + [ + attribute.class("bg-word"), + attribute.style([ + #("font-size", "clamp(80px,14vw,160px)"), + #("top", "5%"), + #("right", "-2%"), + ]), + ], + [html.text("OFF")], + ), + html.div( + [ + attribute.class("bg-word"), + attribute.style([ + #("font-size", "clamp(80px,14vw,160px)"), + #("bottom", "28%"), + #("right", "-3%"), + ]), + ], + [html.text("WALL")], + ), + html.div( + [ + attribute.class("bg-word"), + attribute.style([ + #("font-size", "clamp(40px,6vw,80px)"), + #("bottom", "10%"), + #("left", "2%"), + #("letter-spacing", "0.18em"), + ]), + ], + [html.text("SINCE 1966")], + ), + // Checkerboard strips + html.div( + [ + attribute.class("bg-checker-strip"), + attribute.style([ + #("width", "45%"), + #("height", "14px"), + #("top", "48%"), + #("right", "0"), + #("opacity", "0.9"), + ]), + ], + [], + ), + html.div( + [ + attribute.class("bg-checker-strip"), + attribute.style([ + #("width", "30%"), + #("height", "14px"), + #("top", "65%"), + #("left", "0"), + #("opacity", "0.9"), + ]), + ], + [], + ), + // White geo bars + html.div( + [ + attribute.class("geo-bar"), + attribute.style([ + #("width", "120px"), + #("height", "9px"), + #("top", "22%"), + #("left", "10%"), + #("opacity", "0.5"), + ]), + ], + [], + ), + html.div( + [ + attribute.class("geo-bar"), + attribute.style([ + #("width", "60px"), + #("height", "9px"), + #("top", "28%"), + #("right", "18%"), + #("opacity", "0.4"), + ]), + ], + [], + ), + html.div( + [ + attribute.class("geo-bar"), + attribute.style([ + #("width", "180px"), + #("height", "9px"), + #("bottom", "22%"), + #("right", "10%"), + #("opacity", "0.35"), + ]), + ], + [], + ), + // Skate deck silhouettes + view_skate_deck("44px", "96px", "14%", "7%", "0.07", "rotate(30deg)"), + view_skate_deck("32px", "70px", "6%", "7%", "0.06", "rotate(-18deg)"), + // Plus / cross marks + view_plus_mark("36px", "62%", "18%", "0.1"), + view_plus_mark("22px", "18%", "5%", "0.07"), + ], + ) +} - let link_class = case is_hover { - True -> "link link-hover" - False -> "link" +fn view_skate_deck( + w: String, + h: String, + vert: String, + horiz: String, + opacity: String, + transform: String, +) -> Element(Msg) { + let position_key = case w == "44px" { + True -> "bottom" + False -> "top" + } + let horiz_key = case w == "44px" { + True -> "right" + False -> "left" } + svg.svg( + [ + attribute.class("bg-deco"), + attribute.style([ + #("width", w), + #("height", h), + #(position_key, vert), + #(horiz_key, horiz), + #("opacity", opacity), + #("transform", transform), + ]), + attribute.attribute("viewBox", "0 0 44 96"), + ], + [ + svg.rect([ + attribute.attribute("x", "4"), + attribute.attribute("y", "16"), + attribute.attribute("width", "36"), + attribute.attribute("height", "64"), + attribute.attribute("rx", "18"), + attribute.attribute("fill", "none"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "2.5"), + ]), + svg.circle([ + attribute.attribute("cx", "11"), + attribute.attribute("cy", "8"), + attribute.attribute("r", "5.5"), + attribute.attribute("fill", "none"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "2"), + ]), + svg.circle([ + attribute.attribute("cx", "33"), + attribute.attribute("cy", "8"), + attribute.attribute("r", "5.5"), + attribute.attribute("fill", "none"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "2"), + ]), + svg.circle([ + attribute.attribute("cx", "11"), + attribute.attribute("cy", "88"), + attribute.attribute("r", "5.5"), + attribute.attribute("fill", "none"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "2"), + ]), + svg.circle([ + attribute.attribute("cx", "33"), + attribute.attribute("cy", "88"), + attribute.attribute("r", "5.5"), + attribute.attribute("fill", "none"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "2"), + ]), + ], + ) +} - html.a( +fn view_plus_mark( + size: String, + vert: String, + horiz: String, + opacity: String, +) -> Element(Msg) { + svg.svg( + [ + attribute.class("bg-deco"), + attribute.style([ + #("width", size), + #("height", size), + #("top", vert), + #("right", horiz), + #("opacity", opacity), + ]), + attribute.attribute("viewBox", "0 0 36 36"), + ], [ - attribute.href(link.url), - attribute.target("_blank"), - attribute.rel("noopener noreferrer"), - attribute.class(link_class), - attribute.on("mouseenter", fn(_) { Ok(MouseEnter(link.url)) }), - attribute.on("mouseleave", fn(_) { Ok(MouseLeave) }), + svg.line([ + attribute.attribute("x1", "18"), + attribute.attribute("y1", "3"), + attribute.attribute("x2", "18"), + attribute.attribute("y2", "33"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "4"), + attribute.attribute("stroke-linecap", "round"), + ]), + svg.line([ + attribute.attribute("x1", "3"), + attribute.attribute("y1", "18"), + attribute.attribute("x2", "33"), + attribute.attribute("y2", "18"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "4"), + attribute.attribute("stroke-linecap", "round"), + ]), ], + ) +} + +// ── Checker corners ─────────────────────────────────────────────────────────── + +fn checker_cell(x: Int, y: Int, filled: Bool) -> Element(msg) { + svg.rect([ + attribute.attribute("x", int.to_string(x)), + attribute.attribute("y", int.to_string(y)), + attribute.attribute("width", "12"), + attribute.attribute("height", "12"), + attribute.attribute("fill", case filled { + True -> "#111" + False -> "transparent" + }), + attribute.attribute("opacity", case filled { + True -> "0.12" + False -> "0" + }), + ]) +} + +fn checker_cells(invert: Bool) -> List(Element(msg)) { + list.flat_map([0, 1, 2], fn(row) { + list.map([0, 1, 2], fn(col) { + let filled = case invert { + False -> { row + col } % 2 == 0 + True -> { row + col } % 2 == 1 + } + checker_cell(col * 12, row * 12, filled) + }) + }) +} + +fn checker_svg(invert: Bool) -> Element(msg) { + svg.svg( [ - html.span([attribute.class(get_icon_class(link.link_type))], []), - html.span([attribute.class("link-text")], [html.text(link.display_name)]), + attribute.attribute("width", "72"), + attribute.attribute("height", "72"), + attribute.attribute("viewBox", "0 0 72 72"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + attribute.class("checker-pattern"), ], + checker_cells(invert), ) -} \ No newline at end of file +} + +fn view_checker_tl() -> Element(Msg) { + html.div([attribute.class("checker-tl")], [checker_svg(False)]) +} + +fn view_checker_br() -> Element(Msg) { + html.div([attribute.class("checker-br")], [checker_svg(True)]) +} + +// ── Marquee ─────────────────────────────────────────────────────────────────── + +fn marquee_item(text: String) -> List(Element(Msg)) { + [ + html.span([], [html.text(text)]), + html.span([attribute.class("dot")], [html.text("✕")]), + ] +} + +fn view_marquee() -> Element(Msg) { + let words = ["OFF THE WALL", "ON THE KEYBOARD", "JULESKAB", "SOFTWARE ENGINEER", "METALHEAD", "WANNABE PIXEL ARTIST"] + let items = list.flatten(list.map(words, marquee_item)) + html.div([attribute.class("marquee-wrap")], [ + html.div( + [attribute.class("marquee-track")], + list.flatten([items, items]), + ), + ]) +} diff --git a/src/ui.gleam b/src/ui.gleam new file mode 100644 index 0000000..2fa7702 --- /dev/null +++ b/src/ui.gleam @@ -0,0 +1,259 @@ +import lustre/attribute +import lustre/element.{type Element} +import lustre/element/html +import lustre/element/svg + +// ── SVG Icons ────────────────────────────────────────────────────────────── +// Each icon matches the original vans.html icon components. +// All icons are generic Element(msg) — no Lustre Msg dependency. + +pub fn icon_mail() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.rect([ + attribute.attribute("x", "2"), + attribute.attribute("y", "5"), + attribute.attribute("width", "20"), + attribute.attribute("height", "14"), + attribute.attribute("rx", "2"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "1.8"), + attribute.attribute("fill", "none"), + ]), + svg.path([ + attribute.attribute("d", "M2 8l10 7 10-7"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "1.8"), + attribute.attribute("stroke-linecap", "round"), + ]), + ], + ) +} + +pub fn icon_linkedin() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.path([ + attribute.attribute( + "d", + "M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z", + ), + attribute.attribute("fill", "#111"), + ]), + svg.rect([ + attribute.attribute("x", "2"), + attribute.attribute("y", "9"), + attribute.attribute("width", "4"), + attribute.attribute("height", "12"), + attribute.attribute("fill", "#111"), + ]), + svg.circle([ + attribute.attribute("cx", "4"), + attribute.attribute("cy", "4"), + attribute.attribute("r", "2"), + attribute.attribute("fill", "#111"), + ]), + ], + ) +} + +pub fn icon_twitter() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.path([ + attribute.attribute("d", "M4 4h5l11 16h-5z"), + attribute.attribute("fill", "#111"), + ]), + svg.path([ + attribute.attribute("d", "M4 20L20 4"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "2"), + attribute.attribute("stroke-linecap", "round"), + ]), + ], + ) +} + +pub fn icon_github() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.path([ + attribute.attribute( + "d", + "M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z", + ), + attribute.attribute("fill", "#111"), + ]), + ], + ) +} + +pub fn icon_portfolio() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.rect([ + attribute.attribute("x", "3"), + attribute.attribute("y", "3"), + attribute.attribute("width", "7"), + attribute.attribute("height", "7"), + attribute.attribute("rx", "1"), + attribute.attribute("fill", "#111"), + ]), + svg.rect([ + attribute.attribute("x", "14"), + attribute.attribute("y", "3"), + attribute.attribute("width", "7"), + attribute.attribute("height", "7"), + attribute.attribute("rx", "1"), + attribute.attribute("fill", "#111"), + ]), + svg.rect([ + attribute.attribute("x", "3"), + attribute.attribute("y", "14"), + attribute.attribute("width", "7"), + attribute.attribute("height", "7"), + attribute.attribute("rx", "1"), + attribute.attribute("fill", "#111"), + ]), + svg.rect([ + attribute.attribute("x", "14"), + attribute.attribute("y", "14"), + attribute.attribute("width", "7"), + attribute.attribute("height", "7"), + attribute.attribute("rx", "1"), + attribute.attribute("fill", "#111"), + attribute.attribute("opacity", "0.4"), + ]), + ], + ) +} + +pub fn icon_blog() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.rect([ + attribute.attribute("x", "4"), + attribute.attribute("y", "3"), + attribute.attribute("width", "16"), + attribute.attribute("height", "18"), + attribute.attribute("rx", "2"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "1.8"), + ]), + svg.line([ + attribute.attribute("x1", "8"), + attribute.attribute("y1", "8"), + attribute.attribute("x2", "16"), + attribute.attribute("y2", "8"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "1.8"), + attribute.attribute("stroke-linecap", "round"), + ]), + svg.line([ + attribute.attribute("x1", "8"), + attribute.attribute("y1", "12"), + attribute.attribute("x2", "16"), + attribute.attribute("y2", "12"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "1.8"), + attribute.attribute("stroke-linecap", "round"), + ]), + svg.line([ + attribute.attribute("x1", "8"), + attribute.attribute("y1", "16"), + attribute.attribute("x2", "12"), + attribute.attribute("y2", "16"), + attribute.attribute("stroke", "#111"), + attribute.attribute("stroke-width", "1.8"), + attribute.attribute("stroke-linecap", "round"), + ]), + ], + ) +} + +pub fn icon_arrow() -> Element(msg) { + svg.svg( + [ + attribute.attribute("viewBox", "0 0 24 24"), + attribute.attribute("fill", "none"), + attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), + ], + [ + svg.path([ + attribute.attribute("d", "M5 12h14M13 6l6 6-6 6"), + attribute.attribute("stroke", "currentColor"), + attribute.attribute("stroke-width", "2"), + attribute.attribute("stroke-linecap", "round"), + attribute.attribute("stroke-linejoin", "round"), + ]), + ], + ) +} + +// ── Link Item ────────────────────────────────────────────────────────────── +// Renders a single link row. Hover effects are handled entirely by CSS. +// `delay` is a CSS time string like "0.5s" for staggered entrance animations. + +pub fn link_item( + label: String, + sub: String, + href: String, + icon: Element(msg), + loaded: Bool, + delay: String, +) -> Element(msg) { + let item_class = case loaded { + True -> "link-item visible" + False -> "link-item" + } + html.a( + [ + attribute.href(href), + attribute.target("_blank"), + attribute.rel("noopener noreferrer"), + attribute.class(item_class), + attribute.style([#("transition-delay", delay)]), + ], + [ + html.div([attribute.class("link-icon")], [icon]), + html.div( + [attribute.class("link-info")], + [ + html.span([attribute.class("link-label")], [html.text(label)]), + html.span([attribute.class("link-sub")], [html.text(sub)]), + ], + ), + html.div([attribute.class("link-arrow")], [icon_arrow()]), + ], + ) +} diff --git a/src/vans_styles.gleam b/src/vans_styles.gleam new file mode 100644 index 0000000..f86fd2a --- /dev/null +++ b/src/vans_styles.gleam @@ -0,0 +1,456 @@ +pub fn vans_styles() -> String { + "@import url('https://fonts.googleapis.com/css2?family=Anton&family=Space+Grotesk:wght@300;400;500;600&display=swap'); + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + :root { + --bg: #EDE8DF; + --bg2: #E4DDD3; + --red: #E31837; + --black: #111111; + --white: #FAFAF7; + --muted: #7A7066; + --font-display: 'Anton', sans-serif; + --font-body: 'Space Grotesk', sans-serif; + --spring: cubic-bezier(0.34, 1.56, 0.64, 1); + } + + html, body { + width: 100%; height: 100%; + background: var(--bg); + font-family: var(--font-body); + color: var(--black); + overflow-x: hidden; + cursor: none; + } + + a, button, [role='button'] { cursor: none; } + + #cursor { + position: fixed; + width: 10px; + height: 10px; + background: var(--red); + border-radius: 50%; + pointer-events: none; + z-index: 9999; + top: -100px; + left: -100px; + transform: translate(-50%, -50%); + transition: width 0.2s ease, height 0.2s ease, opacity 0.2s ease; + } + + .checker-tl, .checker-br { + position: fixed; + width: 72px; height: 72px; + pointer-events: none; + z-index: 10; + } + .checker-tl { top: 0; left: 0; } + .checker-br { bottom: 0; right: 0; } + + .geo-bar { + position: absolute; + background: var(--white); + opacity: 0.75; + } + + .marquee-wrap { + position: fixed; + bottom: 0; left: 0; right: 0; + height: 36px; + background: var(--red); + overflow: hidden; + z-index: 100; + display: flex; + align-items: center; + } + .marquee-track { + display: flex; + white-space: nowrap; + animation: marquee 22s linear infinite; + gap: 0; + } + .marquee-track span { + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 0.12em; + color: var(--white); + padding: 0 32px; + } + .marquee-track .dot { + color: var(--white); + opacity: 0.5; + padding: 0; + } + @keyframes marquee { + from { transform: translateX(0); } + to { transform: translateX(-50%); } + } + + .side-label { + position: fixed; + left: 24px; + top: 50%; + transform: translateY(-50%) rotate(-90deg); + transform-origin: center center; + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.25em; + color: var(--muted); + white-space: nowrap; + pointer-events: none; + z-index: 10; + } + @media (max-width: 768px) { + .side-label { display: none; } + } + + .page { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 24px 72px; + position: relative; + overflow: hidden; + } + + .parallax-bg { + position: absolute; + inset: -60px; + pointer-events: none; + z-index: 0; + } + .parallax-circle { + position: absolute; + border-radius: 50%; + background: var(--bg2); + opacity: 0.6; + } + + .bg-word { + position: absolute; + font-family: var(--font-display); + letter-spacing: -0.01em; + color: transparent; + -webkit-text-stroke: 1px rgba(17,17,17,0.055); + white-space: nowrap; + pointer-events: none; + user-select: none; + line-height: 1; + } + .bg-checker-strip { + position: absolute; + background-image: repeating-conic-gradient(rgba(17,17,17,0.06) 0% 25%, transparent 0% 50%); + background-size: 16px 16px; + pointer-events: none; + } + .bg-deco { + position: absolute; + pointer-events: none; + } + + .card { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + width: 100%; + max-width: 520px; + } + + .avatar-wrap { + position: relative; + margin-bottom: 28px; + opacity: 0; + transform: translateY(24px); + transition: opacity 0.7s ease 0.1s, transform 0.7s ease 0.1s; + } + .avatar-wrap.visible { + opacity: 1; + transform: translateY(0); + } + .avatar-ring { + width: 96px; height: 96px; + border-radius: 50%; + border: 3px solid var(--red); + display: flex; align-items: center; justify-content: center; + background: var(--bg2); + position: relative; + overflow: hidden; + } + .avatar-placeholder { + width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; + background: repeating-linear-gradient( + 45deg, + var(--bg2) 0px, var(--bg2) 6px, + var(--white) 6px, var(--white) 12px + ); + font-family: var(--font-display); + font-size: 28px; + color: var(--muted); + letter-spacing: 0.01em; + } + .avatar-badge { + position: absolute; + bottom: -4px; right: -4px; + width: 28px; height: 28px; + background: var(--red); + border-radius: 50%; + border: 2px solid var(--bg); + display: flex; align-items: center; justify-content: center; + } + .avatar-badge svg { width: 12px; height: 12px; } + + .name-block { + text-align: center; + margin-bottom: 8px; + opacity: 0; + transform: translateY(20px); + transition: opacity 0.7s ease 0.25s, transform 0.7s ease 0.25s; + } + .name-block.visible { opacity: 1; transform: translateY(0); } + .name { + font-family: var(--font-display); + font-size: clamp(38px, 7vw, 64px); + line-height: 1; + letter-spacing: 0.02em; + color: var(--black); + } + .name-accent { color: var(--red); } + + .otw-tag { + display: inline-block; + margin-top: 6px; + font-family: var(--font-display); + font-size: 11px; + letter-spacing: 0.22em; + color: var(--white); + background: var(--red); + padding: 3px 10px; + text-transform: uppercase; + } + + .tagline { + margin-top: 10px; + font-size: 12px; + color: var(--muted); + letter-spacing: 0.08em; + line-height: 1.7; + text-align: center; + max-width: 360px; + } + .tagline code { + font-family: 'Space Grotesk', monospace; + color: var(--red); + font-size: 11px; + } + + .divider { + width: 100%; + height: 1px; + background: linear-gradient(90deg, transparent, var(--black) 20%, var(--black) 80%, transparent); + opacity: 0.12; + margin: 24px 0 20px; + } + + .section-label { + font-family: var(--font-display); + font-size: 14px; + letter-spacing: 0.1em; + color: var(--muted); + text-transform: uppercase; + align-self: flex-start; + margin-bottom: 8px; + opacity: 0; + transform: translateX(-12px); + transition: opacity 0.5s ease, transform 0.5s ease; + } + .section-label.visible { opacity: 1; transform: translateX(0); } + + .links-group { + width: 100%; + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 8px; + } + + .link-item { + position: relative; + display: flex; + align-items: center; + gap: 14px; + padding: 13px 12px; + border-radius: 8px; + border-bottom: 1px solid rgba(17,17,17,0.07); + text-decoration: none; + color: var(--black); + overflow: hidden; + cursor: none; + opacity: 0; + transform: translateX(-20px); + transition: opacity 0.5s ease, transform 0.5s ease, + background 0.25s ease, border-color 0.25s ease; + } + .link-item.visible { + opacity: 1; + transform: translateX(0); + } + .link-item::before { + content: ''; + position: absolute; + inset: 0; + background: rgba(17,17,17,0.04); + border-radius: 8px; + opacity: 0; + transition: opacity 0.25s ease; + z-index: 0; + } + .link-item::after { + content: ''; + position: absolute; + left: 0; top: 10px; bottom: 10px; + width: 3px; + background: var(--red); + border-radius: 0 2px 2px 0; + transform: scaleY(0); + transform-origin: center; + transition: transform 0.35s var(--spring); + z-index: 2; + } + .link-item:hover::before { opacity: 1; } + .link-item:hover::after { transform: scaleY(1); } + .link-item:hover .link-label { color: var(--red); } + .link-item:hover .link-arrow { opacity: 1; transform: translateX(0); } + .link-item:hover .link-icon { + background: var(--red); + border-color: var(--red); + transform: scale(1.1); + } + .link-item:hover .link-info { transform: translateX(5px); } + .link-item:hover .link-icon svg path, + .link-item:hover .link-icon svg rect, + .link-item:hover .link-icon svg circle, + .link-item:hover .link-icon svg line { stroke: var(--white); } + .link-item:hover .link-icon svg path[fill='#111'], + .link-item:hover .link-icon svg rect[fill='#111'], + .link-item:hover .link-icon svg circle[fill='#111'] { fill: var(--white); } + + .link-icon { + position: relative; + z-index: 1; + width: 36px; height: 36px; + border-radius: 8px; + background: var(--bg2); + border: 1px solid rgba(17,17,17,0.08); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: background 0.2s ease, border-color 0.2s ease, + transform 0.4s var(--spring); + } + .link-icon svg { width: 16px; height: 16px; } + + .link-info { + position: relative; + z-index: 1; + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; + transition: transform 0.35s var(--spring); + } + + .link-label { + font-size: 14px; + font-weight: 500; + letter-spacing: 0.01em; + transition: color 0.2s ease; + } + .link-sub { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.04em; + } + + .link-arrow { + position: relative; + z-index: 1; + opacity: 0; + transform: translateX(-6px); + transition: opacity 0.25s ease, transform 0.35s var(--spring); + color: var(--red); + } + .link-arrow svg { width: 14px; height: 14px; } + + .footer-tag { + margin-top: 28px; + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.25em; + color: var(--muted); + opacity: 0; + transition: opacity 0.7s ease 1.2s; + } + .footer-tag.visible { opacity: 1; } + + .checker-pattern { image-rendering: pixelated; } + + .error-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 40px 24px 72px; + text-align: center; + } + .error-skateboard { font-size: 48px; margin-bottom: 16px; animation: skate 2s ease-in-out infinite; } + @keyframes skate { + 0%, 100% { transform: rotate(-8deg) translateX(-8px); } + 50% { transform: rotate(8deg) translateX(8px); } + } + .error-code { + font-family: var(--font-display); + font-size: clamp(80px, 20vw, 160px); + line-height: 1; + color: var(--black); + letter-spacing: 0.02em; + } + .error-title { + font-family: var(--font-display); + font-size: clamp(16px, 4vw, 24px); + letter-spacing: 0.08em; + color: var(--black); + margin-top: 12px; + } + .error-subtitle { + font-size: 13px; + color: var(--muted); + margin-top: 8px; + letter-spacing: 0.04em; + } + .error-tips { margin-top: 16px; } + .error-tip { font-size: 13px; color: var(--muted); } + .error-button { + margin-top: 32px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--black); + color: var(--white); + text-decoration: none; + font-family: var(--font-display); + font-size: 13px; + letter-spacing: 0.12em; + transition: background 0.2s ease; + } + .error-button:hover { background: var(--red); } + " +}