This commit is contained in:
opera
2026-03-18 17:27:43 +01:00
commit e13df2948a
26 changed files with 1668 additions and 0 deletions

33
frontend/cont.html Normal file
View File

@@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Home — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div>
<div class="brand">Fax, no printer — <span style="font-weight:400">NoPorn</span></div>
<div class="meta">
This page is server-side! It will work if you have JS disabled.
</div>
</div>
<div class="nav">
<a href="/">Home</a>
<a href="/login">Log in</a>
<a href="/register">Register</a>
<a href="/publish/post" class="button">Publish</a>
</div>
</div>
<div id="message"></div>
<div class="card">
<!--ReplaceWithContent-->
</div>
</div>
</body>
</html>

61
frontend/index.html Normal file
View File

@@ -0,0 +1,61 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Home — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div>
<div class="brand">Fax, no printer — <span style="font-weight:400">NoPorn</span></div>
<div class="meta">We don't allow porn! Go to another site for that.</div>
<div class="meta">
What you can do here:<br>
- Post Stuff<br>
- Search<br>
- Explore<br>
- Comment
</div>
</div>
<div class="nav">
<a href="/">Home</a>
<a href="/login">Log in</a>
<a href="/register">Register</a>
<a href="/publish/post" class="button">Publish</a>
</div>
</div>
<div id="message"></div>
<div class="card">
<h3>Open a post by ID 🔎</h3>
<form id="open-post-form" onsubmit="event.preventDefault(); location.href='/post/'+document.getElementById('open-id').value.trim();">
<div class="form-row">
<label for="open-id">Post ID</label>
<input id="open-id" type="text" placeholder="e.g. 1234-abcd" />
</div>
<button class="button" type="submit">Open post</button>
</form>
</div>
<div class="card">
<h3>Quick links</h3>
<ul>
<li><a href="/publish/post">Create / Publish a post</a></li>
<li><a href="/login">Log in</a> — to attach session and publish.</li>
<li>
<a href="/ssr/">
Server-side pages
</a> — for miscellaneous stuff
</li>
</ul>
</div>
</div>
<script src="/static/main.js"></script>
</body>
</html>

38
frontend/login.html Normal file
View File

@@ -0,0 +1,38 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Login — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">NoPorn — Login</div>
<div class="nav"><a href="/">Home</a> <a href="/register">Register</a></div>
</div>
<div id="message"></div>
<div class="card">
<form id="login-form">
<div class="form-row">
<label>Username</label>
<input name="username" type="text" required />
</div>
<div class="form-row">
<label>Password</label>
<input name="password" type="password" required />
</div>
<div class="form-row">
<button class="button" type="submit">Log in</button>
</div>
</form>
</div>
</div>
<script src="/static/main.js"></script>
</body>
</html>

24
frontend/post.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Post — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">Post</div>
<div class="nav"><a href="/">Home</a></div>
</div>
<div id="message"></div>
<div id="post-container"></div>
</div>
<script src="/static/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Publish Post — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">Publish a post</div>
<div class="nav">
<a href="/">Home</a>
<a href="#" id="logout-btn" class="button" style="background:#b23">Logout</a>
</div>
</div>
<div id="message"></div>
<div class="card">
<form id="publish-form" enctype="multipart/form-data">
<div class="form-row">
<label>Title</label>
<input name="title" type="text" required />
</div>
<div class="form-row">
<label>Tags — comma separated</label>
<input name="tags" type="text" placeholder="cats, summer, vacation" />
</div>
<div class="form-row">
<label>File</label>
<input name="file" type="file" accept="image/*,video/*,application/pdf" required />
</div>
<div class="form-row">
<button class="button" type="submit">Publish</button>
</div>
</form>
<div class="meta">Note — this page requires you to be logged in. If you are redirected to login, sign in and come back. ✨</div>
</div>
</div>
<script src="/static/main.js"></script>
</body>
</html>

37
frontend/register.html Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Register — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">NoPorn — Register</div>
<div class="nav"><a href="/">Home</a> <a href="/login">Login</a></div>
</div>
<div id="message"></div>
<div class="card">
<form id="register-form">
<div class="form-row">
<label>Username</label>
<input name="username" type="text" required />
</div>
<div class="form-row">
<label>Password</label>
<input name="password" type="password" required />
</div>
<div class="form-row">
<button class="button" type="submit">Register</button>
</div>
</form>
</div>
</div>
<script src="/static/main.js"></script>
</body>
</html>

250
frontend/static/main.js Normal file
View File

@@ -0,0 +1,250 @@
// main.js - shared helpers and page handlers
const API = {
login: '/api/login',
register: '/api/register',
logout: '/api/logout',
publish: '/api/publish/post',
create: '/api/create_post',
getPost: (id) => `/api/post/${id}`,
getPostContent: (id) => `/api/post_content/${id}`,
getUser: (id) => `/api/getUser/${id}`
};
function fetchJSON(url, opts = {}) {
opts.credentials = 'include'; // include session cookie
opts.headers = opts.headers || {};
// if sending JSON set header, else for FormData don't set
if (opts.body && !(opts.body instanceof FormData) && !opts.headers['Content-Type']) {
opts.headers['Content-Type'] = 'application/json';
}
return fetch(url, opts).then(async res => {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const j = await res.json();
if (!res.ok) throw j;
return j;
} else {
if (!res.ok) {
const txt = await res.text();
throw { error: txt || 'Request failed', status: res.status };
}
return res;
}
});
}
function fetchFORM(url, opts = {}) { // Force form submission
opts.credentials = 'include'; // include session cookie
opts.headers = opts.headers || {};
// if sending JSON set header, else for FormData don't set
if (opts.body && !(opts.body instanceof FormData) && !opts.headers['Content-Type']) {
opts.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
return fetch(url, opts).then(async res => {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const j = await res.json();
if (!res.ok) throw j;
return j;
} else {
if (!res.ok) {
const txt = await res.text();
throw { error: txt || 'Request failed', status: res.status };
}
return res;
}
});
}
function showMessage(container, msg, ok = true) {
container.innerHTML = `<div class="message ${ok ? 'ok' : 'error'}">${escapeHtml(msg)}</div>`;
setTimeout(()=> {
// optionally fade out after 6s
// container.innerHTML='';
}, 6000);
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c=>({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
/* -------- Page helpers -------- */
async function handleLoginForm(form, messageContainer) {
const data = new FormData(form);
const payload = new URLSearchParams();
for (const [k,v] of data.entries()) payload.append(k,v);
try {
const res = await fetchFORM(API.login, { method:'POST', body: payload });
showMessage(messageContainer, res.message || 'Logged in!', true);
// redirect to publish page after short success
setTimeout(()=> window.location = '/', 600);
} catch(err) {
showMessage(messageContainer, err.error || JSON.stringify(err), false);
}
}
async function handleRegisterForm(form, messageContainer) {
const data = new FormData(form);
const payload = new URLSearchParams();
for (const [k,v] of data.entries()) payload.append(k,v);
try {
const res = await fetchFORM(API.register, { method:'POST', body: payload });
showMessage(messageContainer, res.message || 'Registered!', true);
setTimeout(()=> window.location = '/', 600);
} catch(err) {
showMessage(messageContainer, err.error || JSON.stringify(err), false);
}
}
async function handlePublishForm(form, messageContainer) {
const fd = new FormData();
const title = form.querySelector('[name=title]').value.trim();
const tagsRaw = form.querySelector('[name=tags]').value.trim();
const fileEl = form.querySelector('[name=file]');
if (!title) return showMessage(messageContainer, 'Title required', false);
if (!fileEl.files || fileEl.files.length === 0) return showMessage(messageContainer, 'Pick a file', false);
fd.append('title', title);
// split tags by comma and append each tag as separate 'tags' field so backend getlist works
const tags = tagsRaw ? tagsRaw.split(',').map(t=>t.trim()).filter(Boolean) : [];
for (const t of tags) fd.append('tags', t);
fd.append('file', fileEl.files[0]);
try {
// use publish endpoint — it requires session cookie
const res = await fetchFORM(API.publish, { method:'POST', body: fd });
showMessage(messageContainer, res.message || 'Published!', true);
// Redirect to new post page
if (res.post_id) setTimeout(()=> window.location = `/post/${res.post_id}`, 600);
} catch(err) {
showMessage(messageContainer, err.error || JSON.stringify(err), false);
}
}
async function handleLogout(button, msgContainer) {
try {
const res = await fetchJSON(API.logout, { method:'POST' });
showMessage(msgContainer, res.message || 'Logged out', true);
setTimeout(()=> window.location = '/', 500);
} catch(err) {
showMessage(msgContainer, err.error || 'Logout failed', false);
}
}
/* -------- Pages: simple renderers -------- */
async function renderPostPage() {
const message = document.getElementById('message');
const id = window.location.pathname.split('/').pop();
try {
const meta = await fetchJSON(API.getPost(id));
const container = document.getElementById('post-container');
container.innerHTML = `
<div class="card">
<h2>${escapeHtml(meta.title)}</h2>
<div class="meta">Post ID — ${escapeHtml(meta.id)} • Created: ${escapeHtml(meta.date_created || '')}</div>
<div id="tags" class="tags"></div>
<div id="media"></div>
<div id="creator" class="meta" style="margin-top:10px"></div>
</div>
`;
// tags
const tagsWrap = document.getElementById('tags');
(meta.tags||[]).forEach(t=> {
const el = document.createElement('div'); el.className='tag'; el.textContent = t; tagsWrap.appendChild(el);
});
// show content depending on content_type
const media = document.getElementById('media');
const ctype = (meta.content_type||'').toLowerCase();
const contentUrl = API.getPostContent(meta.id);
const imageExts = ['png','jpg','jpeg','gif','webp','bmp','tiff'];
const videoExts = ['mp4','webm','mov','avi','mkv'];
if (imageExts.includes(ctype)) {
media.innerHTML = `<img class="preview" src="${contentUrl}" alt="image">`;
} else if (videoExts.includes(ctype)) {
media.innerHTML = `<video class="preview" controls src="${contentUrl}">Your browser can't play this video.</video>`;
}
else if (ctype === 'pdf') { // embed pdf
media.innerHTML = `<iframe style="height:80vh;width:60%" src="${contentUrl}"></iframe>`;
}
else if (ctype === 'txt') {
// fetch text content and display in <pre>
try {
const res = await fetchJSON(contentUrl);
const txt = await res.text();
media.innerHTML = `<pre style="white-space:pre-wrap;word-break:break-all;">${escapeHtml(txt)}</pre>`;
} catch(e) {
media.innerHTML = `<div class="message error">Could not load text content</div>`;
}
} else {
// fallback - provide download link
media.innerHTML = `<a class="button" href="${contentUrl}">Download content</a>`;
}
// fetch creator username
if (meta.creator_id) {
try {
const u = await fetchJSON(API.getUser(meta.creator_id));
document.getElementById('creator').innerHTML = `By <strong>${escapeHtml(u.username)}</strong> — <a href="/user/${u.id}">View profile</a>`;
} catch(e){
document.getElementById('creator').innerHTML = `By user ${escapeHtml(meta.creator_id)}`;
}
}
} catch(err) {
showMessage(message, err.error || 'Could not load post', false);
console.error(err);
}
}
async function renderUserPage() {
const uid = window.location.pathname.split('/').pop();
const container = document.getElementById('user-container');
try {
const u = await fetchJSON(API.getUser(uid));
container.innerHTML = `
<div class="card">
<h2>${escapeHtml(u.username)}</h2>
<div class="meta">User ID — ${escapeHtml(u.id)}</div>
<div style="margin-top:12px">Profiles are currently incapable of showing posts. Please search for their name at <a href="/ssr/post">/ssr/post</a></div>
</div>
`;
} catch(err){
container.innerHTML = `<div class="card"><div class="message error">User not found</div></div>`;
}
}
/* Optional: attach simple behaviors if present on page */
document.addEventListener('DOMContentLoaded', ()=> {
// attach simple form bindings if forms exist
const loginForm = document.getElementById('login-form');
if (loginForm) {
const msg = document.getElementById('message');
loginForm.addEventListener('submit', (e)=>{ e.preventDefault(); handleLoginForm(loginForm, msg); });
}
const registerForm = document.getElementById('register-form');
if (registerForm) {
const msg = document.getElementById('message');
registerForm.addEventListener('submit', (e)=>{ e.preventDefault(); handleRegisterForm(registerForm, msg); });
}
const publishForm = document.getElementById('publish-form');
if (publishForm) {
const msg = document.getElementById('message');
publishForm.addEventListener('submit', (e)=>{ e.preventDefault(); handlePublishForm(publishForm, msg); });
}
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
const msg = document.getElementById('message');
logoutBtn.addEventListener('click', ()=> handleLogout(logoutBtn, msg));
}
// page-specific render hooks
if (document.getElementById('post-container')) renderPostPage();
if (document.getElementById('user-container')) renderUserPage();
});

83
frontend/static/style.css Normal file
View File

@@ -0,0 +1,83 @@
:root{
--max-width:900px;
--accent:#1565c0;
--accent-low:#0b3566;
--muted:#999;
--card-bg:#303030;
--page-bg:#202020;
--radius:10px;
--pad:18px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;
background:var(--page-bg);
color:#fff;
-webkit-font-smoothing:antialiased;
}
.container{
max-width:var(--max-width);
margin:28px auto;
padding:12px;
}
.header{
display:flex;
justify-content:space-between;
align-items:center;
gap:12px;
margin-bottom:18px;
}
.brand{font-weight:700; font-size:1.3rem; color:var(--accent)}
a{margin-left:10px; text-decoration:none; color:var(--accent)}
.card{
background:var(--card-bg);
border-radius:var(--radius);
padding:var(--pad);
box-shadow:0 6px 18px rgba(20,30,60,0.06);
margin-bottom:14px;
}
.form-row{margin-bottom:12px}
label{display:block; font-weight:600; margin-bottom:6px}
input[type="text"], input[type="password"], textarea, input[type="file"]{
width:100%;
padding:10px;
border-radius:8px;
border:1px solid #e0e6ef;
font-size:0.975rem;
}
.button{
display:inline-block;
padding:10px 14px;
border-radius:8px;
background:var(--accent);
color:white !important;
text-decoration:none;
border:none;
cursor:pointer;
font-weight:600;
}
.message{padding:8px 10px; border-radius:8px; margin-bottom:10px}
.message.error{background:#ffecec; color:#900}
.message.ok{background:#e8fff0; color:#064}
.meta{color:var(--muted); font-size:0.95rem}
.tags{display:flex; gap:8px; flex-wrap:wrap; margin-top:8px}
.tag{background:#eef6ff;color:var(--accent);padding:6px 8px;border-radius:999px;font-weight:600;font-size:0.85rem}
.preview{max-width:100%; border-radius:8px; margin-top:12px}
.footer{color:var(--muted); font-size:0.9rem; margin-top:22px}
/* small responsive */
@media (max-width:520px){
.header{flex-direction:column; align-items:flex-start; gap:8px}
.brand{font-size:1.1rem}
}

24
frontend/user.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>User — NoPorn</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<div class="header">
<div class="brand">User profile</div>
<div class="nav"><a href="/">Home</a></div>
</div>
<div id="message"></div>
<div id="user-container"></div>
</div>
<script src="/static/main.js"></script>
</body>
</html>