384 lines
10 KiB
JavaScript
384 lines
10 KiB
JavaScript
(function ($) {
|
|
"use strict";
|
|
|
|
const state = {
|
|
spTimer: null,
|
|
spEnabled: true,
|
|
activeId: null
|
|
};
|
|
|
|
function showToast(msg) {
|
|
const $toast = $("#appToast");
|
|
if (!$toast.length) return;
|
|
$("#toastMsg").text(msg);
|
|
const t = bootstrap.Toast.getOrCreateInstance($toast[0], { delay: 2600 });
|
|
t.show();
|
|
}
|
|
|
|
function smoothScrollTo(hash) {
|
|
const $target = $(hash);
|
|
if (!$target.length) return;
|
|
$("html, body").stop(true).animate(
|
|
{ scrollTop: $target.offset().top - 90 },
|
|
450
|
|
);
|
|
}
|
|
|
|
function openProjectModal(projectId) {
|
|
const p = (window.PROJECTS || []).find(x => x.id === projectId);
|
|
if (!p) return;
|
|
|
|
$("#pmTitle").text(p.title + " • " + p.year);
|
|
$("#pmSummary").text(p.summary);
|
|
|
|
const $tech = $("#pmTech").empty();
|
|
(p.tech || []).forEach(t => $("<span/>").addClass("tag").text(t).appendTo($tech));
|
|
|
|
const $links = $("#pmLinks").empty();
|
|
if (p.links && p.links.length) {
|
|
p.links.forEach(l => {
|
|
$("<a/>", {
|
|
class: "btn btn-light btn-sm",
|
|
href: l.url,
|
|
target: "_blank",
|
|
rel: "noreferrer"
|
|
}).text(l.label).appendTo($links);
|
|
});
|
|
} else {
|
|
$("<span/>", { class: "text-white/50 text-sm" }).text("No public links yet.").appendTo($links);
|
|
}
|
|
|
|
const $gallery = $("#pmGallery").empty();
|
|
const imgs = Array.isArray(p.images) ? p.images : [];
|
|
if (imgs.length) {
|
|
imgs.forEach((url, idx) => {
|
|
$("<a/>", {
|
|
class: "pm-thumb",
|
|
href: url,
|
|
target: "_blank",
|
|
rel: "noreferrer"
|
|
}).append(
|
|
$("<img/>", { src: url, alt: `${p.title} screenshot ${idx + 1}` })
|
|
).appendTo($gallery);
|
|
});
|
|
} else {
|
|
$("<div/>", { class: "text-white/50 small" }).text("No screenshots yet.").appendTo($gallery);
|
|
}
|
|
|
|
const $modal = $("#projectModal");
|
|
if ($modal.length) {
|
|
const modal = bootstrap.Modal.getOrCreateInstance($modal[0]);
|
|
modal.show();
|
|
}
|
|
}
|
|
|
|
function applyProjectFilter(tag) {
|
|
tag = (tag || "all").toString().toLowerCase();
|
|
$(".chip").removeClass("active");
|
|
$(`.chip[data-filter="${tag}"]`).addClass("active");
|
|
|
|
$(".project-card").each(function () {
|
|
const $c = $(this);
|
|
const t = ($c.data("project-tag") || "").toString().toLowerCase();
|
|
const show = (tag === "all") || (t === tag);
|
|
if (show) $c.stop(true).fadeIn(140);
|
|
else $c.stop(true).fadeOut(140);
|
|
});
|
|
}
|
|
|
|
function animateBarsInView() {
|
|
const winTop = $(window).scrollTop();
|
|
const winBottom = winTop + $(window).height();
|
|
|
|
$(".bar").each(function () {
|
|
const $bar = $(this);
|
|
if ($bar.data("done")) return;
|
|
|
|
const top = $bar.offset().top;
|
|
if (top < winBottom - 80) {
|
|
const level = parseInt($bar.data("level"), 10) || 0;
|
|
$bar.find(".bar-fill").css("width", Math.max(0, Math.min(level, 100)) + "%");
|
|
$bar.data("done", true);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Scrollspy (active navlink)
|
|
function setActiveNav(id) {
|
|
if (!id || state.activeId === id) return;
|
|
state.activeId = id;
|
|
|
|
$(".navlink").removeClass("is-active");
|
|
$(`.navlink[href="#${id}"]`).addClass("is-active");
|
|
}
|
|
|
|
function initScrollSpy() {
|
|
const ids = ["about", "projects", "stack", "gaming", "contact"];
|
|
const $sections = ids
|
|
.map(id => $("#" + id))
|
|
.filter($el => $el.length);
|
|
|
|
if (!$sections.length) return;
|
|
|
|
const updateActive = () => {
|
|
const y = $(window).scrollTop() + 120;
|
|
let bestId = null;
|
|
|
|
$sections.forEach($el => {
|
|
const top = $el.offset().top;
|
|
const bottom = top + $el.outerHeight();
|
|
if (y >= top && y < bottom) bestId = $el.attr("id");
|
|
});
|
|
|
|
if (!bestId) {
|
|
$sections.forEach($el => {
|
|
if (y >= $el.offset().top - 40) bestId = $el.attr("id");
|
|
});
|
|
}
|
|
|
|
if (bestId) setActiveNav(bestId);
|
|
};
|
|
|
|
updateActive();
|
|
$(window).on("scroll resize", updateActive);
|
|
}
|
|
|
|
// Spotify
|
|
function renderSpotify(payload) {
|
|
const $card = $("#spotifyCard");
|
|
|
|
if (!payload || !payload.ok) {
|
|
$("#spStatus").text("Spotify not configured");
|
|
$(".pulse-bars").hide();
|
|
$card.removeClass("is-playing");
|
|
return;
|
|
}
|
|
|
|
const mode = payload.mode; // playing | recent | offline
|
|
const t = payload.track || {};
|
|
|
|
$("#spTitle").text(t.title || "—");
|
|
$("#spArtist").text(t.artist || "—");
|
|
$("#spLink").attr("href", t.url || "#");
|
|
|
|
if (t.art) $("#spArt").attr("src", t.art).show();
|
|
else $("#spArt").removeAttr("src").hide();
|
|
|
|
if (mode === "playing") {
|
|
$("#spStatus").text("Now Playing");
|
|
$(".pulse-bars").show();
|
|
$card.addClass("is-playing");
|
|
} else if (mode === "recent") {
|
|
$("#spStatus").text("Last Played");
|
|
$(".pulse-bars").hide();
|
|
$card.removeClass("is-playing");
|
|
} else {
|
|
$("#spStatus").text("Offline");
|
|
$(".pulse-bars").hide();
|
|
$card.removeClass("is-playing");
|
|
}
|
|
}
|
|
|
|
function fetchSpotify() {
|
|
if (!state.spEnabled) return;
|
|
|
|
$.ajax({
|
|
url: "/api/spotify.php",
|
|
method: "GET",
|
|
dataType: "json",
|
|
cache: false,
|
|
timeout: 8000
|
|
})
|
|
.done(renderSpotify)
|
|
.fail(function () {
|
|
$("#spStatus").text("Spotify unavailable");
|
|
$(".pulse-bars").hide();
|
|
});
|
|
}
|
|
|
|
// Spotify Widget
|
|
var SpotifyWidget = (function ($) {
|
|
var cfg = { endpoint: '/api/spotify.php' };
|
|
|
|
var $widget, $song, $artist, $status, $link, $visualizer;
|
|
var $artEl; // could be <img> or <div>
|
|
|
|
function init() {
|
|
$widget = $('#spotify-widget');
|
|
$song = $('#spotify-song');
|
|
$artist = $('#spotify-artist');
|
|
$status = $('#spotify-status-text');
|
|
$link = $('#spotify-link');
|
|
$visualizer = $('.eq-visualizer');
|
|
$artEl = $('#spotify-art');
|
|
|
|
// Replace the low-contrast logo with a badge (same idea as your JS)
|
|
var $logoImg = $('.spotify-logo-icon');
|
|
if ($logoImg.length) {
|
|
var $badge = $('<span/>', {
|
|
'class': 'spotify-badge',
|
|
'role': 'img',
|
|
'aria-label': 'Spotify',
|
|
'text': 'Spotify'
|
|
});
|
|
$logoImg.replaceWith($badge);
|
|
}
|
|
|
|
update();
|
|
setInterval(update, 15000);
|
|
}
|
|
|
|
function update() {
|
|
$.ajax({
|
|
url: cfg.endpoint,
|
|
method: 'GET',
|
|
dataType: 'json',
|
|
cache: false
|
|
})
|
|
.done(function (data) {
|
|
if (!data || data.ok !== true) {
|
|
hide();
|
|
return;
|
|
}
|
|
if (data.mode === 'offline') {
|
|
hide();
|
|
return;
|
|
}
|
|
render(data);
|
|
})
|
|
.fail(function (xhr) {
|
|
console.warn('Spotify widget API failed:', xhr.status, xhr.responseText);
|
|
hide();
|
|
});
|
|
}
|
|
|
|
function hide() {
|
|
$widget.addClass('hidden');
|
|
}
|
|
|
|
function render(data) {
|
|
var t = (data.track || {});
|
|
$widget.removeClass('hidden');
|
|
|
|
$song.text(t.title || 'Not Playing');
|
|
$artist.text(t.artist || 'Spotify');
|
|
$link.attr('href', t.url || '#');
|
|
|
|
// album art
|
|
if (t.art) {
|
|
if ($artEl.prop('tagName') === 'IMG') {
|
|
$artEl.attr('src', t.art);
|
|
$artEl.attr('alt', (t.title || 'Track') + ' album art');
|
|
} else {
|
|
$artEl.css({
|
|
backgroundImage: "url('" + t.art + "')",
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center'
|
|
});
|
|
$artEl.find('i[data-feather]').remove();
|
|
}
|
|
}
|
|
|
|
if (data.mode === 'playing') {
|
|
$status.text('Now Playing');
|
|
$visualizer.css('display', 'flex');
|
|
} else {
|
|
$status.text('Last Played');
|
|
$visualizer.css('display', 'none');
|
|
}
|
|
|
|
// marquee if long
|
|
if ((t.title || '').length > 25) {
|
|
$song.css('animation', 'marquee 10s linear infinite');
|
|
} else {
|
|
$song.css('animation', 'none');
|
|
}
|
|
}
|
|
|
|
return { init: init };
|
|
})(jQuery);
|
|
|
|
|
|
// Contact form (AJAX)
|
|
function bindContactForm() {
|
|
const contactEndpoint = (window.CONTACT_ENDPOINT || "/api/contact.php");
|
|
$("#contactForm").on("submit", function (e) {
|
|
e.preventDefault();
|
|
|
|
const $form = $(this);
|
|
const $btn = $form.find("button[type=submit]");
|
|
$btn.prop("disabled", true);
|
|
const payload = $form.serialize();
|
|
|
|
$.ajax({
|
|
url: contactEndpoint,
|
|
method: "POST",
|
|
dataType: "json",
|
|
data: payload,
|
|
timeout: 12000
|
|
})
|
|
.done(handleRes)
|
|
.fail(function () {
|
|
showToast("Network error while sending.");
|
|
})
|
|
.always(function () {
|
|
$btn.prop("disabled", false);
|
|
});
|
|
|
|
function handleRes(res) {
|
|
if (res && res.ok) {
|
|
showToast("Message sent.");
|
|
$form[0].reset();
|
|
} else {
|
|
showToast((res && res.error) ? res.error : "Could not send.");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Init
|
|
$(function () {
|
|
// Smooth scroll
|
|
$(document).on("click", "a[data-scroll], a[href^='#']", function (e) {
|
|
const href = $(this).attr("href");
|
|
if (!href || href === "#") return;
|
|
if (href.startsWith("#")) {
|
|
e.preventDefault();
|
|
smoothScrollTo(href);
|
|
|
|
// update nav immediately on click
|
|
setActiveNav(href.replace("#", ""));
|
|
|
|
// close offcanvas if open
|
|
const $off = $("#mobileNav");
|
|
if ($off.length) bootstrap.Offcanvas.getOrCreateInstance($off[0]).hide();
|
|
}
|
|
});
|
|
|
|
// Project card click
|
|
$(".project-card").on("click", function () {
|
|
openProjectModal($(this).data("project-id"));
|
|
});
|
|
|
|
// Filter chips
|
|
$(".chip").on("click", function () {
|
|
applyProjectFilter($(this).data("filter"));
|
|
});
|
|
|
|
// Bars
|
|
animateBarsInView();
|
|
$(window).on("scroll", animateBarsInView);
|
|
|
|
// Scrollspy active nav animation
|
|
initScrollSpy();
|
|
|
|
// Contact
|
|
bindContactForm();
|
|
|
|
// Spotify polling
|
|
fetchSpotify();
|
|
state.spTimer = setInterval(fetchSpotify, 20000);
|
|
});
|
|
|
|
})(jQuery);
|