lots more stuff

This commit is contained in:
Christian Lawson-Perfect 2024-04-26 08:18:18 +00:00
parent 46c9f0a447
commit c4f250573a
19 changed files with 7619 additions and 149 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
__pycache__
db.sqlite3
gunicorn.conf.py
public/
secret_key.txt
think_data/

View file

@ -23,7 +23,10 @@ class RemixThinkForm(forms.ModelForm):
class RenameThinkForm(forms.ModelForm):
class Meta:
model = Think
fields = ['slug']
fields = ['slug', 'category', 'is_template']
widgets = {
'category': forms.TextInput(attrs={'list': 'categories'})
}
def __init__(self, *args, instance=None, **kwargs):
self.original_root = None if instance is None else instance.root

View file

@ -104,6 +104,7 @@ class MakeHandler(FileSystemEventHandler):
command = ['make'] + config.get('default_make', [])
res = subprocess.run(command, cwd=d, capture_output=True, encoding='utf-8')
with open(d / '.make.log', 'w') as f:
f.write(f"{datetime.now()}\n")
f.write(res.stdout)
f.write(res.stderr)
else:

View file

@ -0,0 +1,22 @@
# Generated by Django 5.0.3 on 2024-03-05 19:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('thinks', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='think',
options={'ordering': ('slug',)},
),
migrations.AddField(
model_name='think',
name='is_template',
field=models.BooleanField(default=False),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2024-04-26 07:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('thinks', '0002_alter_think_options_think_is_template'),
]
operations = [
migrations.AddField(
model_name='think',
name='category',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View file

@ -1,6 +1,7 @@
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.timezone import datetime, make_aware
THINKS_DIR = settings.THINKS_DIR
@ -8,6 +9,9 @@ THINKS_DIR = settings.THINKS_DIR
class Think(models.Model):
slug = models.SlugField()
category = models.CharField(max_length=100, blank=True, null=True)
is_template = models.BooleanField(default=False)
class Meta:
ordering = ('slug',)
@ -43,3 +47,17 @@ class Think(models.Model):
return f.read()
return None
def get_log(self):
log_file = self.file_path('.make.log')
if not log_file.exists():
return ''
with open(log_file) as f:
log = f.read()
return log
@property
def creation_time(self):
return make_aware(datetime.fromtimestamp(self.root.stat().st_ctime))

View file

@ -0,0 +1,7 @@
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});
}

View file

@ -0,0 +1,201 @@
: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;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,31 +1,64 @@
:root {
--spacing: 1em;
--half-spacing: calc(0.5 * var(--spacing));
--double-spacing: calc(2 * var(--spacing));
}
* {
box-sizing: border-box;
}
body > header {
padding: var(--spacing);
& h1 {
margin: 0;
body {
font-family: sans-serif;
& > header {
padding: var(--spacing);
& h1 {
margin: 0;
}
}
}
body.index {
& #thinks-list {
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing);
font-size: 20px;
& > .think {
& .readme {
max-width: 80ch;
white-space: pre-wrap;
& main {
padding: 0 var(--spacing);
& #templates-list {
list-style: none;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--double-spacing);
}
& #thinks-list {
display: flex;
flex-direction: column;
gap: var(--double-spacing);
list-style: none;
padding: 0;
& details {
&[open] > summary {
margin-bottom: var(--spacing);
}
& ul {
display: flex;
flex-direction: column;
gap: var(--spacing);
}
}
& .think {
& time {
font-size: smaller;
}
& .readme {
max-width: 80ch;
white-space: pre-wrap;
}
}
}
}
@ -57,6 +90,19 @@ body.thing-editor {
& #file-tree {
margin: 0;
overflow: auto;
flex-grow: 1;
& > li {
margin-top: var(--half-spacing);
}
& .dir {
font-weight: bold;
}
& .dir + .file {
margin-top: var(--spacing);
}
}
& form {
@ -65,6 +111,13 @@ body.thing-editor {
align-content: start;
align-items: start;
}
& #make-log {
& > pre {
max-width: 20em;
overflow: auto;
}
}
}
& #editor {
@ -89,7 +142,9 @@ body.thing-editor {
& #code-editor {
display: block;
max-width: 50vw;
padding-bottom: 10em;
}
overflow: hidden;
}
& #preview {
@ -101,12 +156,13 @@ body.thing-editor {
border: none;
}
}
overflow: hidden;
}
}
#file-form {
overflow: auto;
/*! max-height: 100%; */
max-height: 100%;
max-width: 100%;
}
@media (max-width: 100ch) {
@ -116,17 +172,33 @@ body.thing-editor {
body.thing-editor {
& main {
grid-template:
"nav" min-content "editor" 40vh "preview";
"nav" min-content "editor" 40vh "preview"
;
overflow: visible;
& 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;
}
}
}
}

View file

@ -0,0 +1,31 @@
{% extends "thinks/base.html" %}
{% block body_class %}login {{block.super}}{% endblock %}
{% block header %}
<h1>Login</h1>
{% endblock %}
{% block main %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
{% 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>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{form}}
<button type="submit">Log in</button>
<input type="hidden" name="next" value="{{ next }}">
</form>
{% endblock %}

View file

@ -8,7 +8,7 @@
<title>{% block title %}Thinks{% endblock %}</title>
{% block stylesheets %}
<link rel="stylesheet" href="{% static "thinks/thinks.css" %}">
<link rel="stylesheet" href="{% static "thinks/thinks.css" %}?{% now "U" %}">
{% endblock stylesheets %}
{% block scripts %}

View file

@ -7,18 +7,45 @@
{% endblock header %}
{% block main %}
<ul id="thinks-list">
{% for think in object_list %}
<li class="think">
<a href="{% url 'think' think.slug %}">{{think.slug}}</a>
{% with readme=think.get_readme %}
{% if readme %}
<pre class="readme">{{readme}}
</pre>
{% endif %}
{% endwith %}
</li>
{% endfor %}
</ul>
<a href="{% url 'new_think' %}">New think</a>
<section id="templates">
<h2>Templates</h2>
<ul id="templates-list">
{% for think in templates %}
<li class="think">
<a class="remix" href="{% url 'remix_think' think.slug %}">{{think.slug}}</a>
<br>
<small>(<a href="{% url 'think' think.slug %}">edit</a>)</small>
</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">
{% for category in categories %}
<li>
<details {% if not category.grouper %}open{% endif %}>
<summary>{% if category.grouper %}{{category.grouper}}{% else %}Uncategorised{% endif %}</summary>
<ul>
{% for think in category.list %}
<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>
</details>
</li>
{% endfor %}
</ul>
</dl>
</section>
{% endblock main %}

View file

@ -0,0 +1,30 @@
{% extends "thinks/base.html" %}
{% load static %}
{% block title %}{{path}} - {{think.slug}} - {{block.super}}{% endblock title %}
{% block body_class %}thing-editor {{block.super}}{% endblock %}
{% block scripts %}
<script src="{% static "thinks/think-editor.js" %}"></script>
{{think_editor_data|json_script:"think-editor-data"}}
<script id="csrftoken" type="text/plain">{{csrf_token}}</script>
<script type="module"">
import init_app from "{% static "thinks/load-think-editor.mjs" %}";
init_app();
</script>
{% endblock scripts %}
{% block stylesheets %}
<link rel="stylesheet" href="{% static "thinks/think-editor.css" %}?{% now "U" %}">
{% endblock stylesheets %}
{% block header %}
<a href="/">thinks</a>
<h1>{{think.slug}}</h1>
{% endblock header %}
{% block main %}
{% endblock main %}

View file

@ -8,6 +8,11 @@
<form method="post" >
{{form}}
<datalist id="categories">
{% for category in categories %}
<option value="{{category}}"></option>
{% endfor %}
</datalist>
{% csrf_token %}
<button type="submit">Rename</button>
</form>

View file

@ -1,106 +1,30 @@
{% extends "thinks/base.html" %}
{% load static %}
{% block title %}{{path}} - {{think.slug}} - {{block.super}}{% endblock title %}
{% block body_class %}thing-editor {{block.super}}{% endblock %}
{% block scripts %}
<script src="{% static "thinks/think-editor.js" %}"></script>
{{think_editor_data|json_script:"think-editor-data"}}
<script id="csrftoken" type="text/plain">{{csrf_token}}</script>
<script type="module"">
import init_app from "{% static "thinks/load-think-editor.mjs" %}";
init_app();
</script>
{% endblock scripts %}
{% block stylesheets %}
<link rel="stylesheet" href="{% static "thinks/think-editor.css" %}?{% now "U" %}">
{% endblock stylesheets %}
{% block header %}
<a href="/">thinks</a>
<h1>{{think.slug}}</h1>
<a href="{% url 'rename_think' slug=think.slug %}">Rename</a>
<a href="{% url 'delete_think' slug=think.slug %}">Delete</a>
<a href="{% url 'remix_think' slug=think.slug %}">Remix</a>
<a href="/">thinks</a>
<h1>{{think.slug}}</h1>
{% endblock header %}
{% block main %}
<nav>
<a target="preview" href="{{think.get_static_url}}">Preview</a>
<ul id="file-tree">
{% for name, path in files %}
<li><a href="?path={{path}}">{{name}}</a></li>
{% endfor %}
</ul>
<form id="new-file-form" method="post" action="{% url 'save_file' slug=think.slug %}" enctype="multipart/form-data">
<input aria-labelledby="new-file-button" id="new_file_path" type="text" name="path">
<button id="new-file-button" type="submit">New file</button>
{% csrf_token %}
</form>
<form method="post" action="{% url 'run_command' slug=think.slug %}">
<input aria-labelledby="run-command-button" name="command">
{% csrf_token %}
<button id="run-command-button" type="submit">Run</button>
</form>
</nav>
<section id="editor">
{% if path is not None and not path.is_dir %}
<nav id="editor-controls">
{{path}}
<details>
<summary>actions</summary>
<form method="post" action="{% url 'delete_file' slug=think.slug %}">
{% csrf_token %}
<input type="hidden" name="path" value="{{path}}">
<button type="submit">Delete</button>
</form>
<form method="post" action="{% url 'rename_file' slug=think.slug %}">
{% csrf_token %}
<input type="hidden" name="path" value="{{path}}">
<input type="text" name="newpath" value="{{path}}">
<button type="submit">Rename</button>
</form>
</details>
</nav>
<form id="file-form" method="post" action="{% url 'save_file' slug=think.slug %}" enctype="multipart/form-data">
{{file_form.path.as_hidden}}
{{file_form.content.as_hidden}}
<code-editor id="code-editor">{{content}}</code-editor>
{% csrf_token %}
</form>
{% endif %}
</section>
<section id="preview">
<button type="button" id="reload-preview">Reload</button>
<iframe id="preview-frame" src="{{think.get_static_url}}"></iframe>
</section>
<script>
const file_form = document.getElementById('file-form');
const preview_frame = document.getElementById('preview-frame');
const content_input = document.getElementById('code-editor');
function debounce(fn) {
let last = null;
return function() {
if(last) {
clearTimeout(last);
}
last = setTimeout(fn, 2000);
}
}
function reload_preview() {
preview_frame.src = preview_frame.src;
}
if(content_input) {
const save_content = debounce(async() => {
const value = content_input.value;
console.log('save', value);
file_form.querySelector('[name="content"]').value = value;
await fetch(file_form.action, {method: 'POST', body: new FormData(file_form)});
reload_preview();
});
content_input.addEventListener('change', save_content);
}
console.log('hey');
document.getElementById('reload-preview').addEventListener('click', () => {
reload_preview();
})
</script>
{% endblock main %}

View file

@ -4,7 +4,7 @@ from .views import *
urlpatterns = [
path('', IndexView.as_view(), name='index'),
path('think/<slug:slug>', ThinkView.as_view(), name='think'),
path('think/<slug:slug>/edit', ThinkView.as_view(), name='think'),
path('think/<slug:slug>/rename', RenameThinkView.as_view(), name='rename_think'),
path('think/<slug:slug>/delete', DeleteThinkView.as_view(), name='delete_think'),
path('think/<slug:slug>/file/<path:path>', ReadFileView.as_view(), name='read_file'),
@ -12,6 +12,7 @@ urlpatterns = [
path('think/<slug:slug>/rename-file', RenameFileView.as_view(), name='rename_file'),
path('think/<slug:slug>/delete-file', DeleteFileView.as_view(), name='delete_file'),
path('think/<slug:slug>/run-command', RunCommandView.as_view(), name='run_command'),
path('think/<slug:slug>/log', LogView.as_view(), name='log'),
path('new', CreateThinkView.as_view(), name='new_think'),
path('new/<slug:slug>', RemixThinkView.as_view(), name='remix_think'),
]

View file

@ -4,8 +4,10 @@ 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
from pathlib import Path
import shutil
import shlex
import subprocess
from . import forms
@ -19,6 +21,15 @@ class ThinkMixin(LoginRequiredMixin):
class IndexView(ThinkMixin, generic.ListView):
template_name = 'thinks/index.html'
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['templates'] = Think.objects.filter(is_template=True)
context['thinks'] = sorted(Think.objects.filter(is_template=False), key=lambda t: (t.category if t.category else '', -t.creation_time.timestamp()))
return context
class CreateThinkView(ThinkMixin, generic.CreateView):
template_name = 'thinks/new.html'
form_class = forms.CreateThinkForm
@ -49,34 +60,31 @@ class RemixThinkView(ThinkMixin, generic.UpdateView):
class ThinkView(ThinkMixin, generic.DetailView):
template_name = 'thinks/think.html'
template_name = "thinks/think.html"
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
think = self.object
root = think.root
strpath = self.request.GET.get('path')
path = think.file_path(strpath)
relpath = path.relative_to(think.root) if path is not None else None
relpath = path.relative_to(root) if path is not None else None
if path is None:
directory = think.root
directory = root
elif path.is_dir():
directory = path
else:
directory = path.parent
files = [(p.name, p.relative_to(think.root)) for p in directory.iterdir()]
if directory != think.root:
files.insert(0, ('..', directory.parent.relative_to(think.root)))
context['files'] = files
context['directory'] = directory
context['path'] = relpath
files = [{'name': p.name, 'path': str(p.relative_to(root)), 'is_dir': p.is_dir()} for p in directory.iterdir()]
if directory != root:
files.insert(0, {'name': '..', 'path': str(directory.parent.relative_to(root)), 'is_dir': True})
if path is not None and path.is_file():
with open(path) as f:
@ -84,9 +92,15 @@ class ThinkView(ThinkMixin, generic.DetailView):
else:
content = ''
context['content'] = content
context['file_form'] = forms.SaveFileForm(instance=think, initial={'content': content, 'path': relpath})
context['think_editor_data'] = {
'preview_url': think.get_static_url(),
'slug': think.slug,
'files': files,
'file_path': str(relpath),
'file_content': content,
'is_dir': path is None or path.is_dir(),
'no_preview': self.request.GET.get('no-preview') is not None,
}
return context
@ -97,6 +111,13 @@ class RenameThinkView(ThinkMixin, generic.UpdateView):
def get_success_url(self):
return self.object.get_absolute_url()
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['categories'] = sorted(Think.objects.exclude(category=None).order_by('category').values_list('category',flat=True).distinct())
return context
class DeleteThinkView(ThinkMixin, generic.DeleteView):
template_name = 'thinks/delete.html'
@ -152,9 +173,17 @@ class RunCommandView(ThinkMixin, generic.UpdateView):
think = self.object
command = form.cleaned_data['command']
res = subprocess.run(
command.split(' '),
['bash','-c',command],
cwd=think.root,
encoding='utf8',
capture_output=True
)
return JsonResponse({'stdout': res.stdout, 'stderr': res.stderr})
class LogView(ThinkMixin, generic.DetailView):
template_name = 'thinks/think.html'
def get(self, *args, **kwargs):
think = self.get_object()
return HttpResponse(think.get_log(), content_type='text/plain; charset=utf-8')

View file

@ -20,7 +20,8 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-s8dq=40yx#rhlq@j$#i3^naz9&yx6tut#ikr80uu!r(1ze@3#@'
with open('/srv/think.somethingorotherwhatever.com/secret_key.txt') as f:
SECRET_KEY = f.read()
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@ -127,4 +128,4 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
THINKS_DIR = Path('think_data')
THINKS_DIR.mkdir(parents=True, exist_ok=True)
THINKS_STATIC_URL = 'http://{slug}.think.somethingorotherwhatever.com'
THINKS_STATIC_URL = 'https://{slug}.think.somethingorotherwhatever.com'