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/
secret_key.txt
think_data/
Makefile

View file

@ -68,7 +68,7 @@ class SaveFileForm(forms.ModelForm):
path.parent.mkdir(exist_ok=True, parents=True)
with open(path, 'w') as f:
f.write(content)
f.write(content.replace('\r\n','\n'))
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()
category = models.CharField(max_length=100, blank=True, null=True)
creation_time = models.DateTimeField(auto_now_add=True)
is_template = models.BooleanField(default=False)
@ -58,6 +59,11 @@ class Think(models.Model):
return log
@property
def creation_time(self):
return make_aware(datetime.fromtimestamp(self.root.stat().st_ctime))
def as_json(self):
return {
'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';
export default async function init_app() {
const flags = JSON.parse(document.getElementById('think-editor-data').textContent);
flags.csrf_token = document.getElementById('csrftoken')?.textContent || '';
const app = Elm.App.init({node: document.body, flags});
}
import './code-editor.mjs';
export default async function init_app() {
const flags = JSON.parse(document.getElementById('think-editor-data').textContent);
flags.csrf_token = document.getElementById('csrftoken')?.textContent || '';
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 {
--spacing: 1em;
--half-spacing: calc(0.5 * var(--spacing));
--double-spacing: calc(2 * var(--spacing));
--editor-size: 50%;
}
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
margin: 0;
padding: var(--half-spacing);
& > header {
padding: var(--spacing);
& h1 {
margin: 0;
}
}
}
header {
& #think-controls {
display: flex;
gap: var(--spacing);
margin: 0;
}
}
#editor-size-input + output {
width: 5em;
}
.file-path {
font-family: monospace;
}
.think-editor {
display: flex;
gap: var(--spacing);
height: 100%;
& > #main-nav > nav {
display: flex;
flex-direction: column;
gap: var(--spacing);
& #file-tree {
margin: 0;
overflow: auto;
flex-grow: 1;
& > li {
margin-top: var(--half-spacing);
& > a {
text-decoration: none;
}
}
& .dir {
font-weight: bold;
}
& .dir + .file {
margin-top: var(--spacing);
}
}
& form {
display: grid;
grid-template-columns: 1fr 6em;
align-content: start;
align-items: start;
}
& #make-log {
& > pre {
max-width: 20em;
overflow: auto;
}
}
}
& #log[open] {
width: 80ch;
overflow: auto;
}
& #editor {
overflow: hidden;
flex-grow: 1;
flex-basis: var(--editor-size);
max-height: 85vh;
& #editor-controls {
display: flex;
gap: var(--spacing);
justify-content: space-between;
& > details {
text-align: right;
& > summary {
user-select: none;
}
& button {
margin: var(--half-spacing) 0;
}
}
}
& #code-editor {
display: block;
max-width: 50vw;
padding-bottom: 10em;
}
}
& #preview {
display: flex;
flex-direction: column;
&[open] {
flex-grow: 1;
flex-shrink: 1;
flex-basis: calc(100% - var(--editor-size));
}
& > summary {
text-align: right;
}
& > iframe {
width: 100%;
height: 100%;
border: none;
}
&[closed] > iframe {
display: none;
}
}
overflow: hidden;
}
#file-form {
overflow: auto;
max-height: 100%;
max-width: 100%;
}
@media (max-width: 100ch) {
html {
font-size: min(3vw, 16px);
}
.think-editor {
flex-direction: column;
overflow: visible;
& > * ~ * {
border-top: medium solid #888;
margin-top: var(--spacing);
padding-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;
}
}
:root {
--spacing: 1em;
--half-spacing: calc(0.5 * var(--spacing));
--double-spacing: calc(2 * var(--spacing));
--editor-size: 50%;
}
* {
box-sizing: border-box;
}
body {
color-scheme: light dark;
font-family: sans-serif;
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
margin: 0;
padding: var(--half-spacing);
& > header {
padding: var(--spacing);
& h1 {
margin: 0;
}
}
}
@media (prefers-color-scheme: dark) {
body {
background: black;
color: white;
}
}
header {
& #think-controls {
display: flex;
gap: var(--spacing);
margin: 0;
}
}
#editor-size-input + output {
width: 5em;
}
.file-path {
font-family: monospace;
}
.think-editor {
display: grid;
gap: var(--spacing);
height: 100%;
overflow: hidden;
--col-1-width: auto;
grid-template:
"nav editor preview" min-content
"log editor preview" 1fr
/ var(--col-1-width) var(--editor-size) var(--preview-size)
;
&:has(#main-nav[open], #log[open]) {
--col-1-width: 20em;
}
& > #main-nav {
grid-area: nav;
}
& > #log {
grid-area: log;
width: 100%;
overflow: auto;
}
& .dragging {
background: red;
}
& summary {
background: #eee;
}
& > #main-nav > nav {
display: flex;
flex-direction: column;
gap: var(--spacing);
& #file-tree {
margin: 0;
overflow: auto;
flex-grow: 1;
& > li {
margin-top: var(--half-spacing);
& > a {
text-decoration: none;
}
}
& .dir {
font-weight: bold;
}
& .dir + .file {
margin-top: var(--spacing);
}
}
& form {
display: grid;
grid-template-columns: 1fr 6em;
align-content: start;
align-items: start;
}
& #make-log {
& > pre {
max-width: 20em;
overflow: auto;
}
}
}
& #editor {
overflow: hidden;
flex-grow: 1;
flex-basis: var(--editor-size);
max-height: 85vh;
grid-area: editor;
& #editor-controls {
display: flex;
gap: var(--spacing);
justify-content: space-between;
& > details {
text-align: right;
& > summary {
user-select: none;
}
& button {
margin: var(--half-spacing) 0;
}
}
}
& #code-editor {
display: block;
max-width: 50vw;
padding-bottom: 10em;
}
}
& #preview {
display: flex;
flex-direction: column;
grid-area: preview;
&[open] {
flex-grow: 1;
flex-shrink: 1;
flex-basis: calc(100% - var(--editor-size));
}
& > summary {
text-align: right;
}
& > iframe {
width: 100%;
height: calc(100% - 3em);
border: none;
}
&[closed] > iframe {
display: none;
}
}
}
#file-form {
overflow: auto;
max-height: 100%;
max-width: 100%;
}
@media (max-width: 100ch) {
html {
font-size: min(3vw, 16px);
}
.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 {
font-size: 20px;
@ -32,7 +70,7 @@ body.index {
gap: var(--double-spacing);
}
& #thinks-list {
& .thinks-list {
display: flex;
flex-direction: column;
gap: var(--double-spacing);

View file

@ -3,7 +3,7 @@
{% block body_class %}login {{block.super}}{% endblock %}
{% block header %}
<h1>Login</h1>
<h1>Log in</h1>
{% endblock %}
{% block main %}
@ -15,9 +15,9 @@
{% if next %}
{% if user.is_authenticated %}
<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 %}
<p>Please login to see this page.</p>
<p>Please log in to see this page.</p>
{% endif %}
{% endif %}

View file

@ -20,14 +20,32 @@
</ul>
</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">
<h2>Thinks</h2>
<p><a href="{% url 'new_think' %}">New think</a></p>
{% regroup thinks by category as categories %}
<ul id="thinks-list">
<ul class="thinks-list">
{% for category in categories %}
<li>
<details {% if not category.grouper %}open{% endif %}>
<details open>
<summary>{% if category.grouper %}{{category.grouper}}{% else %}Uncategorised{% endif %}</summary>
<ul>
{% for think in category.list %}

View file

@ -1,19 +1,30 @@
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.shortcuts import render, redirect
from django.views import generic
from django.urls import reverse
from itertools import groupby
import json
from pathlib import Path
import shutil
import shlex
import subprocess
from . import forms
from .make import ThingMaker
from .models import Think
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):
model = Think
context_object_name = 'think'
@ -26,10 +37,19 @@ class IndexView(ThinkMixin, generic.ListView):
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()))
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):
template_name = 'thinks/new.html'
form_class = forms.CreateThinkForm
@ -82,17 +102,23 @@ class ThinkView(ThinkMixin, generic.DetailView):
else:
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:
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():
with open(path) as f:
content = f.read()
else:
content = ''
context['think_editor_data'] = {
data = context['think_editor_data'] = {
'preview_url': think.get_static_url(),
'slug': think.slug,
'files': files,
@ -102,6 +128,10 @@ class ThinkView(ThinkMixin, generic.DetailView):
'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
class RenameThinkView(ThinkMixin, generic.UpdateView):
@ -138,7 +168,13 @@ class SaveFileView(ThinkMixin, generic.UpdateView):
def form_valid(self, form):
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):
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_STATIC_URL = 'https://{slug}.think.somethingorotherwhatever.com'
API_TOKEN = ''