forked from retoor/devplacepy
chore: add docker infrastructure, gists feature, attachment system, and admin CLI tools
- Add .dockerignore, Dockerfile, and docker compose targets to Makefile for containerized deployment - Implement gists router with CRUD operations, database schema, and polymorphic comment/vote reuse - Create attachment upload system with thumbnail generation, MIME detection, and storage path management - Add attachments_prune CLI command to clean orphaned attachment records and files - Introduce rate limiting middleware with 60 requests per minute window - Add custom 404 and 500 error handlers with SEO-optimized template responses - Extend database initialization with gists and attachments indexes plus upload site settings defaults - Update load_comments to include attachment mapping for comment resources - Register gists and uploads routers in main application and update AGENTS.md documentation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<link rel="stylesheet" href="/static/css/sidebar.css">
|
||||
{% endblock %}
|
||||
@@ -16,18 +17,18 @@
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">Type</div>
|
||||
<div class="sidebar-nav">
|
||||
<a href="/projects?tab={{ current_tab }}" class="sidebar-link {% if not project_type %}active{% endif %}">All</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=game" class="sidebar-link {% if project_type == 'game' %}active{% endif %}">Game</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=software" class="sidebar-link {% if project_type == 'software' %}active{% endif %}">Software</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=mobile_app" class="sidebar-link {% if project_type == 'mobile_app' %}active{% endif %}">Mobile App</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=website" class="sidebar-link {% if project_type == 'website' %}active{% endif %}">Website</a>
|
||||
<a href="/projects?tab={{ current_tab }}" class="sidebar-link {% if not project_type %}active{% endif %}"><span class="icon">📋</span>All</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=game" class="sidebar-link {% if project_type == 'game' %}active{% endif %}"><span class="icon">🎮</span>Game</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=software" class="sidebar-link {% if project_type == 'software' %}active{% endif %}"><span class="icon">💻</span>Software</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=mobile_app" class="sidebar-link {% if project_type == 'mobile_app' %}active{% endif %}"><span class="icon">📱</span>Mobile App</a>
|
||||
<a href="/projects?tab={{ current_tab }}&type=website" class="sidebar-link {% if project_type == 'website' %}active{% endif %}"><span class="icon">🌐</span>Website</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-heading">My Projects</div>
|
||||
<a href="/projects?user_uid={{ user['uid'] }}" class="btn btn-secondary btn-sm" style="width: 100%; justify-content: center;">View My Projects</a>
|
||||
<a href="/projects?user_uid={{ user['uid'] }}" class="btn btn-secondary btn-sm full-width justify-center"><span class="icon">👤</span>View My Projects</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
@@ -42,21 +43,21 @@
|
||||
</div>
|
||||
|
||||
<div class="projects-tabs">
|
||||
<a href="/projects?tab=recent" class="projects-tab {% if current_tab == 'recent' or not current_tab %}active{% endif %}">Recently Released</a>
|
||||
<a href="/projects?tab=popular" class="projects-tab {% if current_tab == 'popular' %}active{% endif %}">Most Popular</a>
|
||||
<a href="/projects?tab=new" class="projects-tab {% if current_tab == 'new' %}active{% endif %}">New This Week</a>
|
||||
<a href="/projects?tab=recent" class="projects-tab {% if current_tab == 'recent' or not current_tab %}active{% endif %}"><span class="icon">🕐</span>Recently Released</a>
|
||||
<a href="/projects?tab=popular" class="projects-tab {% if current_tab == 'popular' %}active{% endif %}"><span class="icon">🔥</span>Most Popular</a>
|
||||
<a href="/projects?tab=new" class="projects-tab {% if current_tab == 'new' %}active{% endif %}"><span class="icon">🆕</span>New This Week</a>
|
||||
{% if user %}
|
||||
<a href="/projects?user_uid={{ user['uid'] }}" class="projects-tab {% if request.query_params.get('user_uid') %}active{% endif %}">My Projects</a>
|
||||
<a href="/projects?user_uid={{ user['uid'] }}" class="projects-tab {% if request.query_params.get('user_uid') %}active{% endif %}"><span class="icon">👤</span>My Projects</a>
|
||||
{% endif %}
|
||||
<button class="btn-ghost btn-icon" style="margin-left: auto;">⚙</button>
|
||||
<button class="btn-ghost btn-icon projects-settings-btn">⚙</button>
|
||||
</div>
|
||||
|
||||
<div class="projects-grid">
|
||||
{% for project in projects %}
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}" class="project-card fade-in" style="display: block; text-decoration: none; color: inherit;">
|
||||
<a href="/projects/{{ project['slug'] or project['uid'] }}" class="project-card fade-in">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ project['title'] }}</h3>
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-card-star">☆</button>
|
||||
</form>
|
||||
@@ -74,8 +75,8 @@
|
||||
|
||||
<div class="project-card-desc rendered-content" data-render>{{ project.get('description', '')[:200] }}</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span class="badge" style="background: var(--border); color: var(--text-muted); text-transform: capitalize;">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
||||
<div class="flex-between">
|
||||
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
||||
{% if project.get('platforms') %}
|
||||
<div class="project-card-platforms">
|
||||
{% for p in project['platforms'].split(',') %}
|
||||
@@ -86,34 +87,34 @@
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty-state" style="grid-column: 1 / -1;">No projects found. Create one!</div>
|
||||
<div class="empty-state empty-state-full">No projects found. Create one!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<button class="feed-fab" id="create-project-btn" title="Add Project" style="position: fixed; bottom: 2rem; right: 2rem; width: 56px; height: 56px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 1.5rem; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-lg); border: none; cursor: pointer; z-index: 100;">+</button>
|
||||
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
|
||||
|
||||
<div class="modal-overlay" id="create-project-modal">
|
||||
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 700;">Create Project</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">×</button>
|
||||
<div class="card modal-card">
|
||||
<div class="modal-header">
|
||||
<h3>Create Project</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon modal-close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/projects/create">
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" name="title" required maxlength="200" placeholder="Project name">
|
||||
</div>
|
||||
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" required maxlength="5000" placeholder="Describe your project" style="min-height: 100px;"></textarea>
|
||||
<textarea id="description" name="description" required maxlength="5000" placeholder="Describe your project" class="min-h-100" data-mention></textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem;">
|
||||
<div class="grid-2col">
|
||||
<div class="auth-field">
|
||||
<label for="release_date">Release Date</label>
|
||||
<input type="date" id="release_date" name="release_date">
|
||||
@@ -124,43 +125,45 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label>Type</label>
|
||||
<div style="display: flex; gap: 0.375rem; flex-wrap: wrap;">
|
||||
<div class="flex-wrap-gap">
|
||||
{% for t in [('game', 'Game'), ('game_asset', 'Game Asset'), ('software', 'Software'), ('mobile_app', 'Mobile App'), ('website', 'Website')] %}
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover);">
|
||||
<input type="radio" name="project_type" value="{{ t[0] }}" {% if t[0] == 'software' %}checked{% endif %} style="width: auto; accent-color: var(--accent);">
|
||||
<label class="topic-label">
|
||||
<input type="radio" name="project_type" value="{{ t[0] }}" {% if t[0] == 'software' %}checked{% endif %} class="topic-radio">
|
||||
{{ t[1] }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label>Status</label>
|
||||
<div style="display: flex; gap: 0.375rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover);">
|
||||
<input type="radio" name="status" value="In Development" checked style="width: auto; accent-color: var(--accent);"> In Development
|
||||
<div class="flex-wrap-gap">
|
||||
<label class="topic-label">
|
||||
<input type="radio" name="status" value="In Development" checked class="topic-radio"> In Development
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover);">
|
||||
<input type="radio" name="status" value="Released" style="width: auto; accent-color: var(--accent);"> Released
|
||||
<label class="topic-label">
|
||||
<input type="radio" name="status" value="Released" class="topic-radio"> Released
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-field" style="margin-bottom: 1rem;">
|
||||
<div class="auth-field auth-field-gap-last">
|
||||
<label>Platforms</label>
|
||||
<div id="platforms-tags" style="display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.5rem;"></div>
|
||||
<input type="text" id="platforms-input" placeholder="Add platform (press Enter)" style="font-size: 0.8125rem;">
|
||||
<div id="platforms-tags" class="flex-wrap-gap-bottom"></div>
|
||||
<input type="text" id="platforms-input" class="input-sm" placeholder="Add platform (press Enter)">
|
||||
<input type="hidden" id="platforms" name="platforms" value="">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.5rem;">
|
||||
<div class="platform-presets-wrap">
|
||||
{% for p in ['PC', 'Windows', 'Mac', 'Linux', 'iOS', 'Android', 'Web', 'Console'] %}
|
||||
<button type="button" class="platform-preset" data-platform="{{ p }}" style="padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; font-weight: 600; background: var(--border); color: var(--text-muted); cursor: pointer; border: none;">{{ p }}</button>
|
||||
<button type="button" class="platform-preset" data-platform="{{ p }}">{{ p }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
{% include "_attachment_form.html" %}
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Project</button>
|
||||
</div>
|
||||
@@ -168,74 +171,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user %}
|
||||
<script>
|
||||
document.getElementById("create-project-btn").addEventListener("click", function() {
|
||||
document.getElementById("create-project-modal").classList.add("visible");
|
||||
});
|
||||
document.querySelectorAll("#create-project-modal .modal-close").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
document.getElementById("create-project-modal").classList.remove("visible");
|
||||
});
|
||||
});
|
||||
document.getElementById("create-project-modal").addEventListener("click", function(e) {
|
||||
if (e.target === this) this.classList.remove("visible");
|
||||
});
|
||||
|
||||
(function() {
|
||||
var platformsInput = document.getElementById("platforms-input");
|
||||
var hiddenInput = document.getElementById("platforms");
|
||||
var tagsContainer = document.getElementById("platforms-tags");
|
||||
|
||||
function addPlatform(val) {
|
||||
val = val.trim();
|
||||
if (!val) return;
|
||||
var tag = document.createElement("span");
|
||||
tag.className = "platform-tag";
|
||||
tag.textContent = val;
|
||||
var remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = "x";
|
||||
remove.style.marginLeft = "4px";
|
||||
remove.style.fontSize = "0.75rem";
|
||||
remove.style.padding = "0";
|
||||
remove.style.background = "none";
|
||||
remove.style.border = "none";
|
||||
remove.style.color = "inherit";
|
||||
remove.style.cursor = "pointer";
|
||||
remove.addEventListener("click", function() {
|
||||
tag.remove();
|
||||
updatePlatforms();
|
||||
});
|
||||
tag.appendChild(remove);
|
||||
tagsContainer.appendChild(tag);
|
||||
platformsInput.value = "";
|
||||
updatePlatforms();
|
||||
}
|
||||
|
||||
function updatePlatforms() {
|
||||
var tags = tagsContainer.querySelectorAll(".platform-tag");
|
||||
var values = [];
|
||||
tags.forEach(function(t) {
|
||||
values.push(t.textContent.replace("x", "").trim());
|
||||
});
|
||||
hiddenInput.value = values.join(",");
|
||||
}
|
||||
|
||||
platformsInput.addEventListener("keydown", function(e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addPlatform(platformsInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll(".platform-preset").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
addPlatform(this.dataset.platform);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user