Add per-user disk quotas, auto-eviction, and Keep flag

Bounds each user's disk footprint and makes throwaway artifacts self-clean:

- Per-user quota (default 1 GB) stored on a new UserProfile model,
  adjustable per user in Django admin. Artifact.size_bytes records each
  artifact's on-disk footprint (primary file + markdown sidecar) at
  ingest so usage is a cheap SUM.
- Automatic eviction: on each post, oldest unkept artifacts are deleted
  (files and rows) until the new one fits under quota. If the quota is
  full of kept artifacts, the post is rejected with HTTP 413 rather than
  evicting anything protected.
- Keep flag (Artifact.is_kept): a Keep button in the preview control bar
  toggles protection via POST /a/{id}/keep; kept items are never evicted.
  Kept rows show a green "K" badge in the listing.
- Details panel: a Details toggle in the control bar expands a panel
  under it with full source, type, created time, size, and tags.
- Settings -> Storage: usage bar (green/amber/red), used/quota summary,
  artifact and kept counts; superusers also see total allocated quota vs
  free disk with an oversubscription warning.
- Type indicator in the listing is now a small grey pill instead of
  plain text.

Storage accounting/eviction logic lives in artifacts/storage.py.
Migrations 0009 (fields + UserProfile) and 0010 (backfill sizes and
create profiles for existing users). Fresh installs no-op the backfill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ritte 2026-07-24 10:07:28 +02:00
parent 37cd96fa7a
commit e0f1cce626
12 changed files with 517 additions and 12 deletions

View file

@ -1,10 +1,16 @@
from django.contrib import admin
from .models import Artifact
from .models import Artifact, UserProfile
@admin.register(Artifact)
class ArtifactAdmin(admin.ModelAdmin):
list_display = ("id", "title", "owner", "source", "source_type", "source_name", "kind", "day", "created_at")
list_filter = ("owner", "source", "source_type", "kind", "day")
list_display = ("id", "title", "owner", "source", "kind", "is_kept", "size_bytes", "day", "created_at")
list_filter = ("owner", "source", "source_type", "kind", "is_kept", "day")
search_fields = ("id", "title", "source", "source_name", "source_id")
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "quota_bytes")
search_fields = ("user__username",)

View file

@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-07-24 07:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0008_artifact_read_at'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='artifact',
name='is_kept',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='artifact',
name='size_bytes',
field=models.BigIntegerField(default=0),
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quota_bytes', models.BigIntegerField(default=1073741824)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,43 @@
from pathlib import Path
from django.conf import settings
from django.db import migrations
def backfill(apps, schema_editor):
Artifact = apps.get_model("artifacts", "Artifact")
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("artifacts", "UserProfile")
root = Path(settings.ARTIFACTS_ROOT)
for artifact in Artifact.objects.all():
total = 0
primary = root / artifact.path
if primary.exists():
total += primary.stat().st_size
if artifact.kind == "markdown":
sidecar = primary.with_suffix(".md")
if sidecar.exists():
total += sidecar.stat().st_size
if artifact.size_bytes != total:
artifact.size_bytes = total
artifact.save(update_fields=["size_bytes"])
default_quota = 1024 * 1024 * 1024 # 1 GB
for user in User.objects.all():
UserProfile.objects.get_or_create(user=user, defaults={"quota_bytes": default_quota})
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0009_quota_and_keep"),
]
operations = [
migrations.RunPython(backfill, noop_reverse),
]

View file

@ -48,6 +48,8 @@ class Artifact(models.Model):
source_id = models.CharField(max_length=120, blank=True)
metadata = models.JSONField(default=dict, blank=True)
read_at = models.DateTimeField(null=True, blank=True)
is_kept = models.BooleanField(default=False)
size_bytes = models.BigIntegerField(default=0)
@property
def is_unread(self):
@ -66,3 +68,13 @@ class TagColor(models.Model):
def __str__(self):
return f"{self.name} ({self.color})"
class UserProfile(models.Model):
DEFAULT_QUOTA_BYTES = 1024 * 1024 * 1024 # 1 GB
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
quota_bytes = models.BigIntegerField(default=DEFAULT_QUOTA_BYTES)
def __str__(self):
return f"{self.user} quota={self.quota_bytes}"

92
artifacts/storage.py Normal file
View file

@ -0,0 +1,92 @@
"""Disk-usage accounting, per-user quota, and automatic eviction.
An artifact's on-disk footprint is the primary stored file plus, for markdown,
the preserved `.md` sidecar. We record it on the row (`size_bytes`) at ingest so
usage is a cheap SUM rather than a stat of every file.
"""
import shutil
from django.conf import settings
from django.db.models import Sum
from .models import Artifact, UserProfile
class QuotaExceeded(Exception):
"""Raised when a post cannot fit even after evicting every unkept artifact."""
def artifact_disk_size(relative_path, kind):
"""Bytes an artifact occupies on disk, including the markdown sidecar."""
root = settings.ARTIFACTS_ROOT
total = 0
primary = root / relative_path
if primary.exists():
total += primary.stat().st_size
if kind == Artifact.MARKDOWN:
sidecar = primary.with_suffix(".md")
if sidecar.exists():
total += sidecar.stat().st_size
return total
def delete_artifact_files(artifact):
"""Remove an artifact's files from disk (primary + markdown sidecar)."""
root = settings.ARTIFACTS_ROOT
primary = root / artifact.path
primary.unlink(missing_ok=True)
if artifact.kind == Artifact.MARKDOWN:
primary.with_suffix(".md").unlink(missing_ok=True)
def user_usage_bytes(user):
return (
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total")
or 0
)
def user_quota_bytes(user):
profile, _ = UserProfile.objects.get_or_create(user=user)
return profile.quota_bytes
def make_room_for(user, incoming_bytes):
"""Evict oldest unkept artifacts until `incoming_bytes` fits under quota.
Returns the list of evicted artifacts (already deleted). Raises QuotaExceeded
if even removing every unkept artifact leaves no room.
"""
quota = user_quota_bytes(user)
if incoming_bytes > quota:
raise QuotaExceeded("Artifact is larger than the entire quota.")
used = user_usage_bytes(user)
if used + incoming_bytes <= quota:
return []
evicted = []
unkept = (
Artifact.objects.filter(owner=user, is_kept=False).order_by("created_at")
)
for artifact in unkept:
if used + incoming_bytes <= quota:
break
used -= artifact.size_bytes
delete_artifact_files(artifact)
artifact.delete()
evicted.append(artifact)
if used + incoming_bytes > quota:
raise QuotaExceeded("Quota is full of kept artifacts; free space to post.")
return evicted
def disk_free_bytes():
"""Free space on the volume that holds the artifact store."""
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
def total_allocated_bytes():
return UserProfile.objects.aggregate(total=Sum("quota_bytes")).get("total") or 0

View file

@ -7,6 +7,7 @@ urlpatterns = [
path("", views.feed, name="feed"),
path("feed/older", views.feed_older, name="feed-older"),
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
@ -14,4 +15,5 @@ urlpatterns = [
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
path("settings/storage/", views.settings_storage, name="settings-storage"),
]

View file

@ -13,7 +13,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.db.models import Count, Q
from django.db.models import Count, Q, Sum
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import slugify
@ -31,6 +31,16 @@ from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm
from .models import Artifact, TagColor
from .storage import (
QuotaExceeded,
artifact_disk_size,
delete_artifact_files,
disk_free_bytes,
make_room_for,
total_allocated_bytes,
user_quota_bytes,
user_usage_bytes,
)
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
@ -96,6 +106,8 @@ class ArtifactListCreateView(APIView):
return source_error
artifact = create_text_artifact(data, request, user)
if isinstance(artifact, Response):
return artifact
apply_tag_colors(data)
return Response(serialize_artifact(artifact, request), status=201)
@ -294,6 +306,15 @@ def mark_artifact_read(request, artifact_id):
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
@require_POST
def toggle_artifact_keep(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
artifact.is_kept = not artifact.is_kept
artifact.save(update_fields=["is_kept"])
return JsonResponse({"id": artifact.id, "is_kept": artifact.is_kept})
@login_required
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
@ -377,6 +398,15 @@ def create_text_artifact(data, request, user):
stored_path = directory / f"{artifact_id}.html"
(settings.ARTIFACTS_ROOT / stored_path).write_text(rendered, encoding="utf-8")
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
try:
make_room_for(user, size_bytes)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
if kind == Artifact.MARKDOWN:
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
return Artifact.objects.create(
id=artifact_id,
owner=user,
@ -389,6 +419,7 @@ def create_text_artifact(data, request, user):
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
@ -422,6 +453,13 @@ def create_binary_artifact(data, user):
for chunk in uploaded.chunks():
target.write(chunk)
size_bytes = artifact_disk_size(stored_path.as_posix(), kind)
try:
make_room_for(user, size_bytes)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
return Artifact.objects.create(
id=artifact_id,
owner=user,
@ -434,6 +472,7 @@ def create_binary_artifact(data, user):
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
size_bytes=size_bytes,
**source_fields(data, source),
)
@ -630,6 +669,8 @@ def serialize_artifact(artifact, request):
"source_id": artifact.source_id,
"metadata": artifact.metadata,
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
"is_kept": artifact.is_kept,
"size_bytes": artifact.size_bytes,
}
@ -696,6 +737,32 @@ def settings_demo_data(request):
)
@login_required
def settings_storage(request):
used = user_usage_bytes(request.user)
quota = user_quota_bytes(request.user)
kept_bytes = (
Artifact.objects.filter(owner=request.user, is_kept=True)
.aggregate(total=Sum("size_bytes"))
.get("total")
or 0
)
context = {
"active_section": "storage",
"used_bytes": used,
"quota_bytes": quota,
"kept_bytes": kept_bytes,
"percent_used": min(100, round(used / quota * 100)) if quota else 0,
"artifact_count": Artifact.objects.filter(owner=request.user).count(),
"kept_count": Artifact.objects.filter(owner=request.user, is_kept=True).count(),
}
if request.user.is_superuser:
context["is_superuser"] = True
context["total_allocated"] = total_allocated_bytes()
context["disk_free"] = disk_free_bytes()
return render(request, "artifacts/settings_storage.html", context)
def raw_unavailable_html(artifact):
return f"""<!doctype html>
<html>

View file

@ -248,26 +248,37 @@ h1 {
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
/* Source sidebar: scrollbar hidden until hover (short list, rarely scrolls). */
.source-list {
scrollbar-color: transparent transparent;
scrollbar-width: none;
}
.source-sidebar:hover .source-list,
.artifact-sidebar:hover .artifact-list {
.source-sidebar:hover .source-list {
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
scrollbar-width: thin;
}
.source-list::-webkit-scrollbar,
.artifact-list::-webkit-scrollbar {
.source-list::-webkit-scrollbar {
width: 0;
}
.source-sidebar:hover .source-list::-webkit-scrollbar,
.artifact-sidebar:hover .artifact-list::-webkit-scrollbar {
.source-sidebar:hover .source-list::-webkit-scrollbar {
width: 6px;
}
/* Artifact list: scrollbar always visible so it doesn't jump on hover. */
.artifact-list {
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
scrollbar-width: thin;
}
.artifact-list::-webkit-scrollbar {
width: 8px;
}
.source-list::-webkit-scrollbar-thumb,
.artifact-list::-webkit-scrollbar-thumb {
background: rgba(61, 69, 82, 0.35);
@ -538,11 +549,32 @@ h1 {
}
.artifact-kind-text {
background: var(--day-tile-bg);
border-radius: 4px;
color: var(--muted);
font-size: 0.68rem;
padding: 0.1rem 0.34rem;
white-space: nowrap;
}
.kept-badge {
align-items: center;
background: #eaf7ed;
border-radius: 4px;
color: #28583a;
display: inline-flex;
font-size: 0.66rem;
font-weight: 700;
justify-content: center;
line-height: 1;
padding: 0.14rem 0.3rem;
white-space: nowrap;
}
.kept-badge[hidden] {
display: none;
}
.artifact-source {
align-items: center;
color: var(--muted);
@ -849,3 +881,85 @@ kbd {
padding: 0.4rem 0.6rem;
word-break: break-all;
}
.keep-button.kept {
background: #eaf7ed;
border-color: #9bd4ab;
color: #28583a;
}
.details-panel {
background: var(--surface-alt);
border-bottom: 1px solid var(--grid-line);
flex: 0 0 auto;
max-height: 40%;
overflow-y: auto;
padding: 0.7rem 1rem;
}
.details-grid {
display: grid;
gap: 0.25rem 1rem;
grid-template-columns: max-content 1fr;
margin: 0;
}
.details-grid dt {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.details-grid dd {
font-size: 0.82rem;
margin: 0;
word-break: break-word;
}
.usage-bar {
background: var(--day-tile-bg);
border-radius: 999px;
height: 0.75rem;
margin: 0.5rem 0;
overflow: hidden;
}
.usage-bar-fill {
background: #5aa552;
border-radius: 999px;
height: 100%;
transition: width 0.3s ease;
}
.usage-bar-fill--warn {
background: #d98b2b;
}
.usage-bar-fill--full {
background: #c14f4f;
}
.usage-summary {
font-size: 0.9rem;
}
.storage-facts {
color: var(--text);
font-size: 0.85rem;
line-height: 1.7;
padding-left: 1.1rem;
}
.storage-note {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.5;
}
.storage-warning {
background: #fdecea;
border-radius: 8px;
color: #a3312a;
font-size: 0.85rem;
padding: 0.6rem 0.9rem;
}

View file

@ -5,17 +5,25 @@
<span class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% for artifact in group.artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %}"
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %} {% if artifact.is_kept %}kept{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-source="{{ artifact.source }}"
data-source-type="{{ artifact.source_type }}"
data-source-name="{{ artifact.source_name }}"
data-created="{{ artifact.created_at|date:'M j Y, H:i' }}"
data-size="{{ artifact.size_bytes|filesizeformat }}"
data-kept="{{ artifact.is_kept|yesno:'1,0' }}"
data-tags="{{ artifact.tags|join:', ' }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
<span class="kept-badge" data-kept-badge title="Kept — never auto-deleted"{% if not artifact.is_kept %} hidden{% endif %}>K</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}

View file

@ -67,12 +67,15 @@
<a class="{% if selected_view == 'raw' %}selected{% endif %}{% if not selected_artifact %} disabled{% endif %}" href="{% if selected_artifact %}{{ raw_url }}{% else %}#{% endif %}" data-view="raw" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Raw</a>
</nav>
<div class="artifact-action-links">
<button type="button" class="keep-button {% if selected_artifact.is_kept %}kept{% endif %}" data-keep-button data-artifact-id="{% if selected_artifact %}{{ selected_artifact.id }}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>{% if selected_artifact.is_kept %}✓ Kept{% else %}Keep{% endif %}</button>
<button type="button" data-details-toggle {% if not selected_artifact %}disabled{% endif %}>Details</button>
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" target="_blank" rel="noopener" {% if not selected_artifact %}aria-disabled="true"{% endif %}>Open</a>
<a class="{% if not selected_artifact %}disabled{% endif %}" href="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% else %}#{% endif %}" download {% if not selected_artifact %}aria-disabled="true"{% endif %}>Download</a>
<button type="button" data-copy-url="{% if selected_artifact %}{% url 'artifact' selected_artifact.id %}{% endif %}" {% if not selected_artifact %}disabled{% endif %}>Copy Link</button>
<button type="button" disabled>Send</button>
</div>
</header>
<div class="details-panel" hidden data-details-panel></div>
<div class="preview-body">
{% if selected_artifact %}
<div class="preview-frame-wrap">
@ -218,6 +221,35 @@
copyButton.disabled = false;
copyButton.dataset.copyUrl = row.dataset.renderedUrl;
}
syncKeepButton(row.dataset.kept === "1");
const keepButton = actions.querySelector("[data-keep-button]");
if (keepButton) keepButton.dataset.artifactId = row.dataset.artifactId;
actions.querySelector("[data-details-toggle]").disabled = false;
renderDetails(row);
};
const syncKeepButton = (isKept) => {
const keepButton = document.querySelector("[data-keep-button]");
if (!keepButton) return;
keepButton.disabled = false;
keepButton.classList.toggle("kept", isKept);
keepButton.textContent = isKept ? "✓ Kept" : "Keep";
};
const renderDetails = (row) => {
const panel = document.querySelector("[data-details-panel]");
if (!panel) return;
const d = row.dataset;
const tags = d.tags ? d.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
const rows = [
["Source", `${escapeHtml(d.source)} (${escapeHtml(d.sourceType)})`],
["Source name", escapeHtml(d.sourceName || "—")],
["Type", escapeHtml(d.kind)],
["Created", escapeHtml(d.created)],
["Size", escapeHtml(d.size)],
["Tags", tags.length ? tags.map(escapeHtml).join(", ") : "—"],
];
panel.innerHTML = `<dl class="details-grid">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join("")}</dl>`;
};
const disableActions = () => {
@ -240,6 +272,15 @@
copyButton.disabled = true;
copyButton.dataset.copyUrl = "";
}
const keepButton = actions.querySelector("[data-keep-button]");
if (keepButton) {
keepButton.disabled = true;
keepButton.classList.remove("kept");
keepButton.textContent = "Keep";
}
actions.querySelector("[data-details-toggle]").disabled = true;
const panel = document.querySelector("[data-details-panel]");
if (panel) panel.hidden = true;
};
const markRead = async (row) => {
@ -440,6 +481,34 @@
observeSentinel();
// Populate the details panel for a server-preselected artifact so the first
// "Details" click shows content immediately.
const preselected = selectedRow();
if (preselected) renderDetails(preselected);
const toggleKeep = async (button) => {
const artifactId = button.dataset.artifactId;
if (!artifactId) return;
button.disabled = true;
try {
const response = await fetch(`/a/${artifactId}/keep`, {
method: "POST",
headers: {"X-CSRFToken": csrfToken},
});
const data = await response.json();
syncKeepButton(data.is_kept);
const row = document.querySelector(`.artifact-row[data-artifact-id="${artifactId}"]`);
if (row) {
row.dataset.kept = data.is_kept ? "1" : "0";
row.classList.toggle("kept", data.is_kept);
const badge = row.querySelector("[data-kept-badge]");
if (badge) badge.hidden = !data.is_kept;
}
} catch {
button.disabled = false;
}
};
previewPane.addEventListener("click", (event) => {
const copyButton = event.target.closest("[data-copy-url]");
if (copyButton) {
@ -448,6 +517,24 @@
return;
}
const keepButton = event.target.closest("[data-keep-button]");
if (keepButton && !keepButton.disabled) {
event.preventDefault();
toggleKeep(keepButton);
return;
}
const detailsToggle = event.target.closest("[data-details-toggle]");
if (detailsToggle && !detailsToggle.disabled) {
event.preventDefault();
const panel = document.querySelector("[data-details-panel]");
if (panel) {
panel.hidden = !panel.hidden;
detailsToggle.classList.toggle("selected", !panel.hidden);
}
return;
}
const toggle = event.target.closest("[data-view]");
if (!toggle) return;
if (toggle.classList.contains("disabled")) {

View file

@ -13,6 +13,7 @@
<nav class="settings-nav">
<a class="settings-nav-back" href="{% url 'feed' %}">&larr; Back to Feed</a>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'storage' %}selected{% endif %}" href="{% url 'settings-storage' %}">Storage</a>
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
</nav>
<section class="settings-panel">

View file

@ -0,0 +1,39 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Storage</h2>
<div class="usage-bar" aria-label="Storage used">
<div class="usage-bar-fill {% if percent_used >= 90 %}usage-bar-fill--full{% elif percent_used >= 75 %}usage-bar-fill--warn{% endif %}" style="width: {{ percent_used }}%"></div>
</div>
<p class="usage-summary">
<strong>{{ used_bytes|filesizeformat }}</strong> of {{ quota_bytes|filesizeformat }} used ({{ percent_used }}%)
</p>
<ul class="storage-facts">
<li>{{ artifact_count }} artifact{{ artifact_count|pluralize }} total</li>
<li>{{ kept_count }} kept ({{ kept_bytes|filesizeformat }}) &mdash; never auto-deleted</li>
</ul>
<p class="storage-note">
When your quota fills, the oldest <strong>unkept</strong> artifacts are deleted
automatically to make room for new ones. Click <strong>Keep</strong> on an artifact
to protect it. If the quota is full of kept artifacts, new posts are rejected until
you free space.
</p>
{% if is_superuser %}
<h3>Admin</h3>
<ul class="storage-facts">
<li>Total quota allocated across all users: <strong>{{ total_allocated|filesizeformat }}</strong></li>
<li>Free space on the artifact disk: <strong>{{ disk_free|filesizeformat }}</strong></li>
</ul>
{% if total_allocated > disk_free %}
<p class="storage-warning">
⚠ Allocated quota exceeds free disk space. Grow the volume before raising quotas,
or usage could fill the actual disk.
</p>
{% endif %}
<p class="storage-note">Per-user quotas are set in the <a href="/admin/artifacts/userprofile/">Django admin</a>.</p>
{% endif %}
{% endblock %}