From f9deb0f8507573c68df623f6373ab830cc72515b Mon Sep 17 00:00:00 2001 From: Einar Date: Sun, 10 May 2026 20:20:22 +0200 Subject: [PATCH] Initial commit --- .gitignore | 3 + backend/Dockerfile | 10 + backend/main.py | 196 +++++++++++++++++++ backend/requirements.txt | 4 + docker-compose.yml | 48 +++++ frontend/app.js | 360 ++++++++++++++++++++++++++++++++++ frontend/index.html | 40 ++++ frontend/style.css | 409 +++++++++++++++++++++++++++++++++++++++ nginx/nginx.conf | 30 +++ tailscale/serve.json | 16 ++ 10 files changed, 1116 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css create mode 100644 nginx/nginx.conf create mode 100644 tailscale/serve.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4cb2e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +tailscale/state/ +data/ \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6f242eb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..a11e7a0 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,196 @@ +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional +import aiosqlite +import shutil +import os + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +DB_PATH = "/data/startpage.db" +FAVICON_DIR = "/data/favicons" + +os.makedirs(FAVICON_DIR, exist_ok=True) + +class CategoryIn(BaseModel): + name: str + icon: Optional[str] = None + position: Optional[int] = 0 + +class SubcategoryIn(BaseModel): + category_id: int + name: str + position: Optional[int] = 0 + +class LinkIn(BaseModel): + subcategory_id: int + label: str + url: str + favicon: Optional[str] = None + position: Optional[int] = 0 + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + icon TEXT, + position INTEGER DEFAULT 0 + ) + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS subcategories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + name TEXT NOT NULL, + position INTEGER DEFAULT 0, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE + ) + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subcategory_id INTEGER NOT NULL, + label TEXT NOT NULL, + url TEXT NOT NULL, + favicon TEXT, + position INTEGER DEFAULT 0, + FOREIGN KEY (subcategory_id) REFERENCES subcategories(id) ON DELETE CASCADE + ) + """) + await db.execute("PRAGMA foreign_keys = ON") + await db.commit() + +@app.on_event("startup") +async def startup(): + await init_db() + +@app.get("/categories") +async def get_categories(): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute("SELECT * FROM categories ORDER BY position") as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + +@app.post("/categories") +async def create_category(cat: CategoryIn): + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "INSERT INTO categories (name, icon, position) VALUES (?, ?, ?)", + (cat.name, cat.icon, cat.position) + ) + await db.commit() + return {"id": cursor.lastrowid, **cat.dict()} + +@app.put("/categories/{cat_id}") +async def update_category(cat_id: int, cat: CategoryIn): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE categories SET name=?, icon=?, position=? WHERE id=?", + (cat.name, cat.icon, cat.position, cat_id) + ) + await db.commit() + return {"id": cat_id, **cat.dict()} + +@app.delete("/categories/{cat_id}") +async def delete_category(cat_id: int): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("PRAGMA foreign_keys = ON") + await db.execute("DELETE FROM categories WHERE id=?", (cat_id,)) + await db.commit() + return {"deleted": cat_id} + +@app.get("/subcategories/{cat_id}") +async def get_subcategories(cat_id: int): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", + (cat_id,) + ) as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + +@app.post("/subcategories") +async def create_subcategory(sub: SubcategoryIn): + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)", + (sub.category_id, sub.name, sub.position) + ) + await db.commit() + return {"id": cursor.lastrowid, **sub.dict()} + +@app.put("/subcategories/{sub_id}") +async def update_subcategory(sub_id: int, sub: SubcategoryIn): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE subcategories SET name=?, position=? WHERE id=?", + (sub.name, sub.position, sub_id) + ) + await db.commit() + return {"id": sub_id, **sub.dict()} + +@app.delete("/subcategories/{sub_id}") +async def delete_subcategory(sub_id: int): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("PRAGMA foreign_keys = ON") + await db.execute("DELETE FROM subcategories WHERE id=?", (sub_id,)) + await db.commit() + return {"deleted": sub_id} + +@app.get("/links/{sub_id}") +async def get_links(sub_id: int): + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", + (sub_id,) + ) as cursor: + rows = await cursor.fetchall() + return [dict(row) for row in rows] + +@app.post("/links") +async def create_link(link: LinkIn): + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "INSERT INTO links (subcategory_id, label, url, favicon, position) VALUES (?, ?, ?, ?, ?)", + (link.subcategory_id, link.label, link.url, link.favicon, link.position) + ) + await db.commit() + return {"id": cursor.lastrowid, **link.dict()} + +@app.put("/links/{link_id}") +async def update_link(link_id: int, link: LinkIn): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE links SET label=?, url=?, favicon=?, position=? WHERE id=?", + (link.label, link.url, link.favicon, link.position, link_id) + ) + await db.commit() + return {"id": link_id, **link.dict()} + +@app.delete("/links/{link_id}") +async def delete_link(link_id: int): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("DELETE FROM links WHERE id=?", (link_id,)) + await db.commit() + return {"deleted": link_id} + +@app.post("/favicons/upload") +async def upload_favicon(file: UploadFile = File(...)): + filename = file.filename.replace(" ", "_").lower() + dest = os.path.join(FAVICON_DIR, filename) + with open(dest, "wb") as f: + shutil.copyfileobj(file.file, f) + return {"favicon": filename} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f120f26 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.111.0 +uvicorn==0.29.0 +aiosqlite==0.20.0 +python-multipart==0.0.9 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dbc46cf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + startpage-app: + build: ./backend + container_name: startpage-app + restart: unless-stopped + volumes: + - ./data:/data + networks: + - startpage-net + + startpage-nginx: + image: nginx:alpine + container_name: startpage-nginx + restart: unless-stopped + volumes: + - ./frontend:/usr/share/nginx/html:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./data/favicons:/data/favicons:ro + depends_on: + - startpage-app + networks: + - startpage-net + - ts-startpage-net + + startpage-ts: + image: tailscale/tailscale:latest + container_name: startpage-ts + hostname: vps-startpage-01 + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_SERVE_CONFIG=/config/serve.json + - TS_STATE_DIR=/var/lib/tailscale + volumes: + - ./tailscale/state:/var/lib/tailscale + - ./tailscale/serve.json:/config/serve.json:ro + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + - SYS_MODULE + restart: unless-stopped + networks: + - ts-startpage-net + +networks: + startpage-net: + driver: bridge + ts-startpage-net: + driver: bridge \ No newline at end of file diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..86c3bff --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,360 @@ +const API = '/api'; +let state = { + categories: [], + activeCategoryId: null, + subcategories: [], + activeSubcategoryId: null, + links: [] +}; + +const globeSVG = ` + + + + + +`; + +async function api(method, path, body) { + const opts = { + method, + headers: body ? { 'Content-Type': 'application/json' } : {} + }; + if (body) opts.body = JSON.stringify(body); + const res = await fetch(API + path, opts); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +const get = (path) => api('GET', path); +const post = (path, body) => api('POST', path, body); +const put = (path, body) => api('PUT', path, body); +const del = (path) => api('DELETE', path); + +async function loadCategories() { + state.categories = await get('/categories'); + renderCategoryNav(); + if (state.categories.length > 0) { + await selectCategory( + state.activeCategoryId || state.categories[0].id + ); + } +} + +function renderCategoryNav() { + const nav = document.getElementById('category-nav'); + nav.innerHTML = ''; + state.categories.forEach(cat => { + const btn = document.createElement('button'); + btn.className = 'cat-tab' + (cat.id === state.activeCategoryId ? ' active' : ''); + btn.innerHTML = `${cat.name}`; + btn.onclick = () => selectCategory(cat.id); + btn.oncontextmenu = (e) => { + e.preventDefault(); + openEditCategoryModal(cat); + }; + nav.appendChild(btn); + }); +} + +async function selectCategory(id) { + state.activeCategoryId = id; + state.subcategories = await get('/subcategories/' + id); + state.activeSubcategoryId = null; + renderCategoryNav(); + renderSubcategoryBar(); + if (state.subcategories.length > 0) { + await selectSubcategory(state.subcategories[0].id); + } else { + state.links = []; + renderLinkGrid(); + } +} + +function renderSubcategoryBar() { + const bar = document.getElementById('subcategory-bar'); + bar.innerHTML = ''; + + state.subcategories.forEach(sub => { + const btn = document.createElement('button'); + btn.className = 'sub-tab' + (sub.id === state.activeSubcategoryId ? ' active' : ''); + btn.textContent = sub.name; + btn.onclick = () => selectSubcategory(sub.id); + btn.oncontextmenu = (e) => { + e.preventDefault(); + openEditSubcategoryModal(sub); + }; + bar.appendChild(btn); + }); + + const addBtn = document.createElement('button'); + addBtn.className = 'btn-add-sub'; + addBtn.innerHTML = ' Add subcategory'; + addBtn.onclick = () => openAddSubcategoryModal(); + bar.appendChild(addBtn); +} + +async function selectSubcategory(id) { + state.activeSubcategoryId = id; + state.links = await get('/links/' + id); + renderSubcategoryBar(); + renderLinkGrid(); +} + +function renderLinkGrid() { + const grid = document.getElementById('link-grid'); + grid.innerHTML = ''; + + state.links.forEach(link => { + const a = document.createElement('a'); + a.className = 'link-card'; + a.href = link.url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + + const favicon = document.createElement('div'); + favicon.className = 'link-favicon'; + if (link.favicon) { + const img = document.createElement('img'); + img.src = '/favicons/' + link.favicon; + img.width = 20; + img.height = 20; + img.onerror = () => { favicon.innerHTML = globeSVG; }; + favicon.appendChild(img); + } else { + favicon.innerHTML = globeSVG; + } + + const label = document.createElement('span'); + label.className = 'link-label'; + label.textContent = link.label; + + const actions = document.createElement('div'); + actions.className = 'link-actions'; + actions.innerHTML = ` + + + `; + + a.appendChild(favicon); + a.appendChild(label); + a.appendChild(actions); + grid.appendChild(a); + }); + + const addCard = document.createElement('button'); + addCard.className = 'add-link-card'; + addCard.innerHTML = ' Add link'; + addCard.onclick = () => openAddLinkModal(); + grid.appendChild(addCard); +} + +function openModal(title, bodyHTML, onConfirm) { + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-body').innerHTML = bodyHTML + ` + + `; + document.getElementById('modal-overlay').classList.remove('hidden'); + document.getElementById('modal-cancel').onclick = closeModal; + document.getElementById('modal-close').onclick = closeModal; + document.getElementById('modal-confirm').onclick = onConfirm; +} + +function closeModal() { + document.getElementById('modal-overlay').classList.add('hidden'); +} + +function openAddCategoryModal() { + openModal('Add category', ` +
+ + +
+
+ + +
+ `, async () => { + const name = document.getElementById('f-name').value.trim(); + const icon = document.getElementById('f-icon').value.trim(); + if (!name) return; + await post('/categories', { name, icon, position: state.categories.length }); + closeModal(); + await loadCategories(); + }); +} + +function openEditCategoryModal(cat) { + openModal('Edit category', ` +
+ + +
+
+ + +
+ + `, async () => { + const name = document.getElementById('f-name').value.trim(); + const icon = document.getElementById('f-icon').value.trim(); + if (!name) return; + await put('/categories/' + cat.id, { name, icon, position: cat.position }); + closeModal(); + await loadCategories(); + }); +} + +function openAddSubcategoryModal() { + openModal('Add subcategory', ` +
+ + +
+ `, async () => { + const name = document.getElementById('f-name').value.trim(); + if (!name) return; + await post('/subcategories', { + category_id: state.activeCategoryId, + name, + position: state.subcategories.length + }); + closeModal(); + await selectCategory(state.activeCategoryId); + }); +} + +function openEditSubcategoryModal(sub) { + openModal('Edit subcategory', ` +
+ + +
+ + `, async () => { + const name = document.getElementById('f-name').value.trim(); + if (!name) return; + await put('/subcategories/' + sub.id, { + category_id: state.activeCategoryId, + name, + position: sub.position + }); + closeModal(); + await selectCategory(state.activeCategoryId); + }); +} + +function openAddLinkModal() { + openModal('Add link', ` +
+ + +
+
+ + +
+
+ + +
+ `, async () => { + const label = document.getElementById('f-label').value.trim(); + const url = document.getElementById('f-url').value.trim(); + const favicon = document.getElementById('f-favicon').value.trim(); + if (!label || !url) return; + await post('/links', { + subcategory_id: state.activeSubcategoryId, + label, + url, + favicon: favicon || null, + position: state.links.length + }); + closeModal(); + await selectSubcategory(state.activeSubcategoryId); + }); +} + +function openEditLinkModal(link, e) { + e.preventDefault(); + e.stopPropagation(); + openModal('Edit link', ` +
+ + +
+
+ + +
+
+ + +
+ + `, async () => { + const label = document.getElementById('f-label').value.trim(); + const url = document.getElementById('f-url').value.trim(); + const favicon = document.getElementById('f-favicon').value.trim(); + if (!label || !url) return; + await put('/links/' + link.id, { + subcategory_id: state.activeSubcategoryId, + label, + url, + favicon: favicon || null, + position: link.position + }); + closeModal(); + await selectSubcategory(state.activeSubcategoryId); + }); +} + +async function deleteCategory(id) { + closeModal(); + await del('/categories/' + id); + state.activeCategoryId = null; + await loadCategories(); +} + +async function deleteSubcategory(id) { + closeModal(); + await del('/subcategories/' + id); + await selectCategory(state.activeCategoryId); +} + +async function deleteLink(id, e) { + e.preventDefault(); + e.stopPropagation(); + await del('/links/' + id); + await selectSubcategory(state.activeSubcategoryId); +} + +document.getElementById('search-form').addEventListener('submit', (e) => { + e.preventDefault(); + const q = document.getElementById('search-input').value.trim(); + if (q) window.open('https://kagi.com/search?q=' + encodeURIComponent(q), '_blank'); +}); + +document.getElementById('add-category-btn').onclick = openAddCategoryModal; + +document.getElementById('modal-overlay').addEventListener('click', (e) => { + if (e.target === document.getElementById('modal-overlay')) closeModal(); +}); + +loadCategories(); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0c03289 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,40 @@ + + + + + + Start + + + + +
+
+
+ + + +
+ +
+ + +
+
+
+ + + + + + \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..06dff24 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,409 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #111111; + --surface: #1a1a1a; + --surface2: #222222; + --border: #2a2a2a; + --accent: #e8722a; + --accent-dim: #c45e20; + --text: #ffffff; + --text-muted: #888888; + --radius: 8px; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + min-height: 100vh; +} + +#app { + max-width: 1400px; + margin: 0 auto; +} + +#topbar { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +#search-form { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0 12px; + height: 40px; + flex: 1; + max-width: 600px; +} + +#search-form i { + color: var(--accent); + font-size: 18px; +} + +#search-form input { + background: none; + border: none; + outline: none; + color: var(--text); + font-size: 14px; + flex: 1; +} + +#search-form input::placeholder { + color: var(--text-muted); +} + +#search-form button { + background: var(--accent); + color: var(--text); + border: none; + border-radius: 6px; + padding: 5px 14px; + font-size: 13px; + cursor: pointer; +} + +#search-form button:hover { + background: var(--accent-dim); +} + +.btn-ghost { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.btn-ghost:hover { + border-color: var(--accent); + color: var(--accent); +} + +#category-nav { + display: flex; + align-items: center; + gap: 4px; + padding: 0.75rem 1.5rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.cat-tab { + background: none; + border: none; + border-radius: var(--radius); + color: var(--text-muted); + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + transition: color 0.15s, background 0.15s; +} + +.cat-tab:hover { + background: var(--surface2); + color: var(--text); +} + +.cat-tab.active { + background: var(--surface2); + color: var(--accent); + border-bottom: 2px solid var(--accent); +} + +.cat-tab i { + font-size: 16px; +} + +#subcategory-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 0.5rem 1.5rem; + background: var(--bg); + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.sub-tab { + background: none; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--text-muted); + padding: 4px 12px; + font-size: 13px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} + +.sub-tab:hover { + color: var(--text); + border-color: var(--border); +} + +.sub-tab.active { + color: var(--accent); + border-color: var(--accent); +} + +.btn-add-sub { + background: none; + border: 1px dashed var(--border); + border-radius: var(--radius); + color: var(--text-muted); + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} + +.btn-add-sub:hover { + border-color: var(--accent); + color: var(--accent); +} + +#link-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + padding: 1.5rem; + align-content: start; +} + +.link-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 14px; + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: var(--text); + position: relative; + transition: border-color 0.15s, background 0.15s; +} + +.link-card:hover { + border-color: var(--accent); + background: var(--surface2); +} + +.link-favicon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.link-label { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.link-actions { + display: none; + align-items: center; + gap: 4px; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); +} + +.link-card:hover .link-actions { + display: flex; +} + +.link-action-btn { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 13px; +} + +.link-action-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.add-link-card { + background: none; + border: 1px dashed var(--border); + border-radius: var(--radius); + padding: 10px 14px; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + cursor: pointer; + font-size: 13px; + transition: border-color 0.15s, color 0.15s; +} + +.add-link-card:hover { + border-color: var(--accent); + color: var(--accent); +} + +#modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +#modal-overlay.hidden { + display: none; +} + +#modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 420px; + max-width: 90vw; +} + +#modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +#modal-title { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +#modal-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 18px; + display: flex; + align-items: center; +} + +#modal-close:hover { + color: var(--text); +} + +#modal-body { + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field label { + font-size: 12px; + color: var(--text-muted); +} + +.field input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; + padding: 8px 10px; + outline: none; +} + +.field input:focus { + border-color: var(--accent); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 0.5rem; +} + +.btn-primary { + background: var(--accent); + border: none; + border-radius: var(--radius); + color: var(--text); + padding: 7px 18px; + font-size: 13px; + cursor: pointer; +} + +.btn-primary:hover { + background: var(--accent-dim); +} + +.btn-secondary { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-muted); + padding: 7px 18px; + font-size: 13px; + cursor: pointer; +} + +.btn-secondary:hover { + border-color: var(--text-muted); + color: var(--text); +} + +.globe-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..9252262 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,30 @@ +events {} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://startpage-app:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /favicons/ { + alias /data/favicons/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + } +} \ No newline at end of file diff --git a/tailscale/serve.json b/tailscale/serve.json new file mode 100644 index 0000000..46b8914 --- /dev/null +++ b/tailscale/serve.json @@ -0,0 +1,16 @@ +{ + "TCP": { + "443": { + "HTTPS": true + } + }, + "Web": { + "vps-startpage-01.warthog-rockhopper.ts.net:443": { + "Handlers": { + "/": { + "Proxy": "http://startpage-nginx:80" + } + } + } + } +} \ No newline at end of file