Initial commit
This commit is contained in:
383
public/js/app.js
Normal file
383
public/js/app.js
Normal file
@@ -0,0 +1,383 @@
|
||||
(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);
|
||||
Reference in New Issue
Block a user