Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,40 @@
# SP-Digital-LLC
This repository holds links and details for SP Digital LLC apps, sites, software, I.P. etc...
# SP Digital LLC — Project Hub

A centralised portfolio for all SP Digital LLC projects: ideas, works in progress, and shipped apps.

## 🌐 Live Site

Hosted on **GitHub Pages** → `https://driver727-pixel.github.io/SP-Digital-LLC/`

## 📁 Structure

| File | Purpose |
|------|---------|
| `index.html` | Main portfolio page |
| `styles.css` | Dark-themed stylesheet |
| `projects.js` | Project data (edit to add permanent entries) |
| `app.js` | Rendering logic + password-protected admin panel |

## ✅ Current Projects

| Project | Status | URL |
|---------|--------|-----|
| SeePrice | Finished | https://seeprice.replit.app |
| Mad Food Loop | Finished | https://www.madfoodloop.com |
| CraftLingua | Finished | https://craftlingua.app |
| LibraryScout | Finished | https://libraryscout.info |

## ➕ Adding a New Project

**Via the UI (temporary — saved to browser localStorage):**
1. Click the **+** button in the bottom-right corner of the site.
2. Enter the admin password when prompted.
3. Fill in the project form and click **Save Project**.

**Permanently (recommended):**
Open `projects.js` and add a new entry to the `SP_PROJECTS` array following the existing format.

## 🔐 Admin Password

The admin password for the **+** button is `spdigital2024`.
To change it, generate a SHA-256 hash of your new password and update `ADMIN_HASH` in `app.js`.
236 changes: 236 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
/* ============================================================
SP Digital LLC — App Logic
============================================================
SECURITY NOTE: This is a lightweight, client-side guard designed
to prevent casual unauthorised use of the Add Project form on a
personal static site. The password is hashed with SHA-256 so it
is never stored or transmitted in plain text; however, because
this runs entirely in the browser, a determined attacker who
obtains the hash could attempt an offline dictionary attack.
For a fully secure admin panel, use a backend authentication
system. For a personal portfolio this tradeoff is acceptable.

Admin password: spdigital2024

To change the password, run in a browser console:
crypto.subtle.digest('SHA-256', new TextEncoder().encode('yourNewPassword'))
.then(b => console.log([...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('')))
Then paste the result into ADMIN_HASH below.
============================================================ */

const ADMIN_HASH =
"0839c27dfda3d9fda3b8ea5ce6eed2a27befaa57b9f2450014c5e21cce762518";

const STORAGE_KEY = "sp_projects_extra";

/* ── Helpers ──────────────────────────────────────────────────────────────── */

async function sha256(text) {
const buf = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text)
);
return [...new Uint8Array(buf)]
.map((x) => x.toString(16).padStart(2, "0"))
.join("");
}

function loadExtra() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
} catch {
return [];
}
}

function saveExtra(projects) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
}

function allProjects() {
return [...SP_PROJECTS, ...loadExtra()];
}

function byStatus(status) {
return allProjects().filter((p) => p.status === status);
}

/* ── Rendering ────────────────────────────────────────────────────────────── */

function renderCard(project) {
const hasLink = project.url && project.url.trim() !== "";
const tagsHtml = (project.tags || [])
.map((t) => `<span class="tag">${escHtml(t)}</span>`)
.join("");

const footerHtml = hasLink
? `<a class="btn-visit" href="${escAttr(project.url)}" target="_blank" rel="noopener noreferrer">
Visit ↗
</a>`
: "";

return `
<article class="card">
<div class="card-icon" aria-hidden="true">${escHtml(project.icon || "🔷")}</div>
<div>
<div class="card-name">${escHtml(project.name)}</div>
<div class="card-tagline">${escHtml(project.tagline || "")}</div>
</div>
<p class="card-desc">${escHtml(project.description || "")}</p>
<div class="card-tags">${tagsHtml}</div>
${footerHtml ? `<div class="card-footer">${footerHtml}</div>` : ""}
</article>`;
}

function renderSection(status) {
const projects = byStatus(status);
const containerId = `grid-${status}`;
const el = document.getElementById(containerId);
if (!el) return;
if (projects.length === 0) {
el.innerHTML = `<p class="empty">No projects in this category yet.</p>`;
} else {
el.innerHTML = projects.map(renderCard).join("");
}
}

function renderAll() {
renderSection("finished");
renderSection("ongoing");
renderSection("idea");
}

/* ── Escape helpers ───────────────────────────────────────────────────────── */

function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

function escAttr(str) {
// Only allow http/https URLs
const s = String(str).trim();
if (!/^https?:\/\//i.test(s)) return "#";
return escHtml(s);
}

/* ── Modal helpers ────────────────────────────────────────────────────────── */

function openModal(id) {
document.getElementById(id).classList.add("open");
}

function closeModal(id) {
document.getElementById(id).classList.remove("open");
}

/* ── Auth flow ────────────────────────────────────────────────────────────── */

async function handleAuth(e) {
e.preventDefault();
const pw = document.getElementById("auth-password").value;
const hash = await sha256(pw);
if (hash === ADMIN_HASH) {
closeModal("auth-modal");
document.getElementById("auth-password").value = "";
document.getElementById("auth-error").textContent = "";
openModal("add-modal");
} else {
document.getElementById("auth-error").textContent =
"Incorrect password. Try again.";
}
}

/* ── Add-project flow ─────────────────────────────────────────────────────── */

function handleAdd(e) {
e.preventDefault();
const name = document.getElementById("add-name").value.trim();
const tagline = document.getElementById("add-tagline").value.trim();
const description = document.getElementById("add-desc").value.trim();
const url = document.getElementById("add-url").value.trim();
const status = document.getElementById("add-status").value;
const tagsRaw = document.getElementById("add-tags").value.trim();
const icon = document.getElementById("add-icon").value.trim() || "🔷";

const tags = tagsRaw
? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean)
: [];

const project = {
id: `custom-${Date.now()}`,
status,
name,
tagline,
description,
url: url || null,
tags,
icon,
};

const extras = loadExtra();
extras.push(project);
saveExtra(extras);

renderAll();
closeModal("add-modal");
e.target.reset();

// Scroll to the relevant section
const section = document.getElementById(`section-${status}`);
if (section) section.scrollIntoView({ behavior: "smooth" });
}

/* ── Sticky nav active state ─────────────────────────────────────────────── */

function updateActiveNav() {
const sections = ["finished", "ongoing", "idea"];
let current = sections[0];
sections.forEach((id) => {
const el = document.getElementById(`section-${id}`);
if (el && window.scrollY >= el.offsetTop - 120) current = id;
});
document.querySelectorAll("nav a").forEach((a) => {
a.classList.toggle("active", a.getAttribute("href") === `#section-${current}`);
});
}

/* ── Bootstrap ────────────────────────────────────────────────────────────── */

document.addEventListener("DOMContentLoaded", () => {
// Initial render
renderAll();

// FAB → open auth modal
document.getElementById("fab").addEventListener("click", () => {
document.getElementById("auth-password").value = "";
document.getElementById("auth-error").textContent = "";
openModal("auth-modal");
});

// Auth form submit
document.getElementById("auth-form").addEventListener("submit", handleAuth);

// Add-project form submit
document.getElementById("add-form").addEventListener("submit", handleAdd);

// Close buttons
document.querySelectorAll("[data-close]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn.dataset.close));
});

// Close on backdrop click
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});

// Nav active state
window.addEventListener("scroll", updateActiveNav, { passive: true });
updateActiveNav();
});
Loading