lots of changes

Added a creation_time field to thinks - using the modified time on the
  filesystem wasn't reliable.

Styled the login page.

The index shows the most recent thinks at the top.

Allow authentication by an Authorization header, with a token specified
in settings.py.

Lots of improvements to the editor, including showing the log, and a
form to install Elm packages when editing .elm files.

The Makefile for a project is automatically run each time a file is saved.
This commit is contained in:
Christian Lawson-Perfect 2025-02-07 07:02:35 +00:00
parent 3d5c0c6c73
commit 500eb38774
13 changed files with 1474 additions and 396 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ gunicorn.conf.py
public/ public/
secret_key.txt secret_key.txt
think_data/ think_data/
Makefile

View file

@ -68,7 +68,7 @@ class SaveFileForm(forms.ModelForm):
path.parent.mkdir(exist_ok=True, parents=True) path.parent.mkdir(exist_ok=True, parents=True)
with open(path, 'w') as f: with open(path, 'w') as f:
f.write(content) f.write(content.replace('\r\n','\n'))
return super().save(commit) return super().save(commit)

72
thinks/make.py Normal file
View file

@ -0,0 +1,72 @@
from datetime import datetime
from filelock import FileLock, Timeout
import subprocess
import yaml
class ThingMaker():
gap = 2
extensions = None
config = {
'path': '.',
'default_make': [],
'extensions': ['.js'],
}
def __init__(self, think):
self.think = think
self.load_config()
def load_config(self,):
config_path = self.think.root / '.watchmakerc'
if config_path.exists():
with open(config_path) as f:
self.config.update(yaml.load(f.read(),Loader=yaml.SafeLoader))
self.extensions = self.config.get('extensions')
def make(self, file_changed):
if file_changed.is_dir():
return {"error": f"{file_changed} is directory"}
root = self.think.root
t = datetime.now()
src_path = (root / file_changed).resolve()
if not src_path.is_relative_to(root.resolve()):
return {"error": f"{src_path} is not relative to root {root}"}
if src_path.name == '.watchmakerc' or self.extensions is None or src_path.suffix in self.extensions:
lock_path = str(root / '.make.lock')
lock = FileLock(lock_path, timeout=5)
try:
with lock:
return self.run(src_path)
except Timeout:
return {"error": "Timed out"}
def run(self, p):
root = self.think.root
if (root / 'Makefile').exists():
command = ['make'] + self.config.get('default_make', [])
res = subprocess.run(command, cwd=root, capture_output=True, encoding='utf-8')
with open(root / '.make.log', 'w') as f:
f.write(f"{datetime.now()}\n")
f.write(res.stdout)
f.write(res.stderr)
return {
'stdout': res.stdout,
'stderr': res.stderr,
}
else:
return {"error": "No make"}

View file

@ -0,0 +1,20 @@
# Generated by Django 5.0.3 on 2024-12-07 12:03
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('thinks', '0003_think_category'),
]
operations = [
migrations.AddField(
model_name='think',
name='creation_time',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2024, 12, 7, 12, 3, 5, 567875, tzinfo=datetime.timezone.utc)),
preserve_default=False,
),
]

View file

@ -10,6 +10,7 @@ class Think(models.Model):
slug = models.SlugField() slug = models.SlugField()
category = models.CharField(max_length=100, blank=True, null=True) category = models.CharField(max_length=100, blank=True, null=True)
creation_time = models.DateTimeField(auto_now_add=True)
is_template = models.BooleanField(default=False) is_template = models.BooleanField(default=False)
@ -58,6 +59,11 @@ class Think(models.Model):
return log return log
@property def as_json(self):
def creation_time(self): return {
return make_aware(datetime.fromtimestamp(self.root.stat().st_ctime)) 'slug': self.slug,
'category': self.category,
'absolute_url': self.get_absolute_url(),
'readme': self.get_readme(),
'creation_time': self.creation_time,
}

View file

@ -1,7 +1,19 @@
import './code-editor.mjs'; import './code-editor.mjs';
export default async function init_app() { export default async function init_app() {
const flags = JSON.parse(document.getElementById('think-editor-data').textContent); const flags = JSON.parse(document.getElementById('think-editor-data').textContent);
flags.csrf_token = document.getElementById('csrftoken')?.textContent || ''; flags.csrf_token = document.getElementById('csrftoken')?.textContent || '';
const app = Elm.App.init({node: document.body, flags}); const app = Elm.App.init({node: document.body, flags});
}
app.ports.reload_preview.subscribe(() => {
console.log('reload preview');
const iframe = document.getElementById('preview-frame');
if(iframe) {
const src = iframe.src;
iframe.src = "";
setTimeout(() => {
iframe.src = src;
},10);
}
})
}

View file

@ -1,201 +1,237 @@
:root { :root {
--spacing: 1em; --spacing: 1em;
--half-spacing: calc(0.5 * var(--spacing)); --half-spacing: calc(0.5 * var(--spacing));
--double-spacing: calc(2 * var(--spacing)); --double-spacing: calc(2 * var(--spacing));
--editor-size: 50%; --editor-size: 50%;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: sans-serif; color-scheme: light dark;
font-family: sans-serif;
display: grid;
grid-template-rows: auto 1fr; display: grid;
min-height: 100vh; grid-template-rows: auto 1fr;
margin: 0; min-height: 100vh;
padding: var(--half-spacing); margin: 0;
padding: var(--half-spacing);
& > header {
padding: var(--spacing); & > header {
& h1 { padding: var(--spacing);
margin: 0; & h1 {
} margin: 0;
} }
}
}
}
header {
& #think-controls { @media (prefers-color-scheme: dark) {
display: flex; body {
gap: var(--spacing); background: black;
margin: 0; color: white;
} }
} }
#editor-size-input + output { header {
width: 5em; & #think-controls {
} display: flex;
gap: var(--spacing);
.file-path { margin: 0;
font-family: monospace; }
} }
#editor-size-input + output {
.think-editor { width: 5em;
display: flex; }
gap: var(--spacing);
height: 100%; .file-path {
font-family: monospace;
& > #main-nav > nav { }
display: flex;
flex-direction: column;
gap: var(--spacing); .think-editor {
display: grid;
& #file-tree { gap: var(--spacing);
margin: 0; height: 100%;
overflow: auto; overflow: hidden;
flex-grow: 1; --col-1-width: auto;
grid-template:
& > li { "nav editor preview" min-content
margin-top: var(--half-spacing); "log editor preview" 1fr
/ var(--col-1-width) var(--editor-size) var(--preview-size)
& > a { ;
text-decoration: none;
} &:has(#main-nav[open], #log[open]) {
} --col-1-width: 20em;
}
& .dir {
font-weight: bold; & > #main-nav {
} grid-area: nav;
}
& .dir + .file { & > #log {
margin-top: var(--spacing); grid-area: log;
} width: 100%;
} overflow: auto;
}
& form {
display: grid; & .dragging {
grid-template-columns: 1fr 6em; background: red;
align-content: start; }
align-items: start;
} & summary {
background: #eee;
& #make-log { }
& > pre {
max-width: 20em; & > #main-nav > nav {
overflow: auto; display: flex;
} flex-direction: column;
} gap: var(--spacing);
}
& #file-tree {
& #log[open] { margin: 0;
width: 80ch; overflow: auto;
overflow: auto; flex-grow: 1;
}
& > li {
& #editor { margin-top: var(--half-spacing);
overflow: hidden;
flex-grow: 1; & > a {
flex-basis: var(--editor-size); text-decoration: none;
max-height: 85vh; }
}
& #editor-controls {
display: flex; & .dir {
gap: var(--spacing); font-weight: bold;
justify-content: space-between; }
& > details { & .dir + .file {
text-align: right; margin-top: var(--spacing);
}
& > summary { }
user-select: none;
} & form {
display: grid;
& button { grid-template-columns: 1fr 6em;
margin: var(--half-spacing) 0; align-content: start;
} align-items: start;
} }
}
& #make-log {
& #code-editor { & > pre {
display: block; max-width: 20em;
max-width: 50vw; overflow: auto;
padding-bottom: 10em; }
} }
} }
& #preview { & #editor {
display: flex; overflow: hidden;
flex-direction: column; flex-grow: 1;
flex-basis: var(--editor-size);
&[open] { max-height: 85vh;
flex-grow: 1; grid-area: editor;
flex-shrink: 1;
flex-basis: calc(100% - var(--editor-size)); & #editor-controls {
} display: flex;
gap: var(--spacing);
& > summary { justify-content: space-between;
text-align: right;
} & > details {
text-align: right;
& > iframe {
width: 100%; & > summary {
height: 100%; user-select: none;
border: none; }
}
& button {
&[closed] > iframe { margin: var(--half-spacing) 0;
display: none; }
} }
} }
overflow: hidden;
} & #code-editor {
display: block;
#file-form { max-width: 50vw;
overflow: auto; padding-bottom: 10em;
max-height: 100%; }
max-width: 100%; }
}
@media (max-width: 100ch) { & #preview {
html { display: flex;
font-size: min(3vw, 16px); flex-direction: column;
} grid-area: preview;
.think-editor {
flex-direction: column; &[open] {
overflow: visible; flex-grow: 1;
flex-shrink: 1;
& > * ~ * { flex-basis: calc(100% - var(--editor-size));
border-top: medium solid #888; }
margin-top: var(--spacing);
padding-top: var(--spacing); & > summary {
} text-align: right;
}
& nav {
flex-direction: row; & > iframe {
flex-wrap: wrap; width: 100%;
height: calc(100% - 3em);
& form { border: none;
flex-grow: 1; }
}
&[closed] > iframe {
& #file-tree { display: none;
max-height: 7em; }
} }
} }
& #editor { #file-form {
overflow: auto; overflow: auto;
& #code-editor { max-height: 100%;
max-width: none; max-width: 100%;
} }
} @media (max-width: 100ch) {
html {
& #preview { font-size: min(3vw, 16px);
height: 100vh; }
} .think-editor {
} overflow: visible;
grid-template:
"nav"
"log"
"editor"
"preview"
;
& > * ~ * {
border-top: medium solid #888;
margin-top: var(--spacing);
}
& nav {
flex-direction: row;
flex-wrap: wrap;
& form {
flex-grow: 1;
}
& #file-tree {
max-height: 7em;
}
}
& #editor {
overflow: auto;
& #code-editor {
max-width: none;
}
}
& #preview {
height: 100vh;
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -18,6 +18,44 @@ body {
} }
} }
body.login {
display: grid;
align-content: center;
justify-content: center;
height: 100svh;
margin: 0;
padding: var(--spacing);
& header {
text-align: center;
}
& form {
display: grid;
gap: var(--spacing);
& div {
display: grid;
grid-template-columns: 1fr 1fr;
grid-auto-flow: row;
gap: var(--spacing);
align-items: center;
& label {
grid-column: 1;
justify-self: end;
}
& input {
grid-column: 2;
}
}
grid-template-rows: 2em 2em 2em;
}
}
body.index { body.index {
font-size: 20px; font-size: 20px;
@ -32,7 +70,7 @@ body.index {
gap: var(--double-spacing); gap: var(--double-spacing);
} }
& #thinks-list { & .thinks-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--double-spacing); gap: var(--double-spacing);

View file

@ -3,7 +3,7 @@
{% block body_class %}login {{block.super}}{% endblock %} {% block body_class %}login {{block.super}}{% endblock %}
{% block header %} {% block header %}
<h1>Login</h1> <h1>Log in</h1>
{% endblock %} {% endblock %}
{% block main %} {% block main %}
@ -15,9 +15,9 @@
{% if next %} {% if next %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed, <p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p> please log in with an account that has access.</p>
{% else %} {% else %}
<p>Please login to see this page.</p> <p>Please log in to see this page.</p>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -20,14 +20,32 @@
</ul> </ul>
</section> </section>
<section id="recent">
<h2>Recent</h2>
<ul class="thinks-list">
{% for think in recent_thinks %}
<li class="think">
<a href="{% url 'think' think.slug %}">{{think.slug}}</a>
<time datetime="{{think.creation_time|date:"c"}}">{{think.creation_time}}</time>
{% with readme=think.get_readme %}
{% if readme %}
<pre class="readme">{{readme|safe}}</pre>
{% endif %}
{% endwith %}
</li>
{% endfor %}
</ul>
</section>
<section id="thinks"> <section id="thinks">
<h2>Thinks</h2> <h2>Thinks</h2>
<p><a href="{% url 'new_think' %}">New think</a></p> <p><a href="{% url 'new_think' %}">New think</a></p>
{% regroup thinks by category as categories %} {% regroup thinks by category as categories %}
<ul id="thinks-list"> <ul class="thinks-list">
{% for category in categories %} {% for category in categories %}
<li> <li>
<details {% if not category.grouper %}open{% endif %}> <details open>
<summary>{% if category.grouper %}{{category.grouper}}{% else %}Uncategorised{% endif %}</summary> <summary>{% if category.grouper %}{{category.grouper}}{% else %}Uncategorised{% endif %}</summary>
<ul> <ul>
{% for think in category.list %} {% for think in category.list %}

View file

@ -1,19 +1,30 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import AccessMixin
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.views import generic from django.views import generic
from django.urls import reverse from django.urls import reverse
from itertools import groupby from itertools import groupby
import json
from pathlib import Path from pathlib import Path
import shutil import shutil
import shlex import shlex
import subprocess import subprocess
from . import forms from . import forms
from .make import ThingMaker
from .models import Think from .models import Think
from .random_slug import random_slug from .random_slug import random_slug
class LoginRequiredMixin(AccessMixin):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
token = request.META.get('HTTP_AUTHORIZATION')
if token != f'Bearer {settings.API_TOKEN}':
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
class ThinkMixin(LoginRequiredMixin): class ThinkMixin(LoginRequiredMixin):
model = Think model = Think
context_object_name = 'think' context_object_name = 'think'
@ -26,10 +37,19 @@ class IndexView(ThinkMixin, generic.ListView):
context['templates'] = Think.objects.filter(is_template=True) context['templates'] = Think.objects.filter(is_template=True)
context['recent_thinks'] = Think.objects.filter(is_template=False).order_by('-creation_time')[:3]
context['thinks'] = sorted(Think.objects.filter(is_template=False), key=lambda t: (t.category if t.category else '', -t.creation_time.timestamp())) context['thinks'] = sorted(Think.objects.filter(is_template=False), key=lambda t: (t.category if t.category else '', -t.creation_time.timestamp()))
return context return context
def get(self, request, *args, **kwargs):
if request.accepts('application/json') and not request.accepts('text/html'):
self.object_list = self.get_queryset()
context = self.get_context_data()
return JsonResponse({'templates': [t.as_json() for t in context['templates']], 'thinks': [t.as_json() for t in context['thinks']]})
return super().get(request, *args, **kwargs)
class CreateThinkView(ThinkMixin, generic.CreateView): class CreateThinkView(ThinkMixin, generic.CreateView):
template_name = 'thinks/new.html' template_name = 'thinks/new.html'
form_class = forms.CreateThinkForm form_class = forms.CreateThinkForm
@ -82,17 +102,23 @@ class ThinkView(ThinkMixin, generic.DetailView):
else: else:
directory = path.parent directory = path.parent
files = [{'name': p.name, 'path': str(p.relative_to(root)), 'is_dir': p.is_dir()} for p in directory.iterdir()] if directory.exists():
files = [{'name': p.name, 'path': str(p.relative_to(root)), 'is_dir': p.is_dir()} for p in directory.iterdir()]
else:
files = []
if directory != root: if directory != root:
files.insert(0, {'name': '..', 'path': str(directory.parent.relative_to(root)), 'is_dir': True}) files.insert(0, {'name': '..', 'path': str(directory.parent.relative_to(root)), 'is_dir': True})
files = sorted(files, key=lambda x: x['name'].lower())
if path is not None and path.is_file(): if path is not None and path.is_file():
with open(path) as f: with open(path) as f:
content = f.read() content = f.read()
else: else:
content = '' content = ''
context['think_editor_data'] = { data = context['think_editor_data'] = {
'preview_url': think.get_static_url(), 'preview_url': think.get_static_url(),
'slug': think.slug, 'slug': think.slug,
'files': files, 'files': files,
@ -102,6 +128,10 @@ class ThinkView(ThinkMixin, generic.DetailView):
'no_preview': self.request.GET.get('no-preview') is not None, 'no_preview': self.request.GET.get('no-preview') is not None,
} }
if path is not None and path.suffix == '.elm':
with open('public/elm-packages.json') as f:
data['elm_packages'] = json.load(f)
return context return context
class RenameThinkView(ThinkMixin, generic.UpdateView): class RenameThinkView(ThinkMixin, generic.UpdateView):
@ -138,7 +168,13 @@ class SaveFileView(ThinkMixin, generic.UpdateView):
def form_valid(self, form): def form_valid(self, form):
self.path = form.cleaned_data['path'].relative_to(self.object.root) self.path = form.cleaned_data['path'].relative_to(self.object.root)
return super().form_valid(form)
thing = form.save()
maker = ThingMaker(thing)
result = maker.make(self.path)
return JsonResponse(result or {"error": "not built"})
def get_success_url(self): def get_success_url(self):
return self.object.get_absolute_url()+'?path='+str(self.path) return self.object.get_absolute_url()+'?path='+str(self.path)

View file

@ -129,3 +129,5 @@ THINKS_DIR = Path('think_data')
THINKS_DIR.mkdir(parents=True, exist_ok=True) THINKS_DIR.mkdir(parents=True, exist_ok=True)
THINKS_STATIC_URL = 'https://{slug}.think.somethingorotherwhatever.com' THINKS_STATIC_URL = 'https://{slug}.think.somethingorotherwhatever.com'
API_TOKEN = ''