first commit
This commit is contained in:
commit
52ab7aa331
33 changed files with 33447 additions and 0 deletions
13
codemirror-element/Makefile
Normal file
13
codemirror-element/Makefile
Normal file
|
@ -0,0 +1,13 @@
|
|||
NODE_BIN=node_modules/.bin
|
||||
|
||||
DEST=../thinks/static/thinks
|
||||
|
||||
$(DEST)/code-editor.mjs: code-editor.bundle.mjs
|
||||
cp $< $@
|
||||
|
||||
code-editor.terser.mjs: code-editor.bundle.mjs
|
||||
$(NODE_BIN)/terser --compress --mangle -- $< > $@
|
||||
|
||||
code-editor.bundle.mjs: code-editor.mjs
|
||||
$(NODE_BIN)/rollup $< -f es -p @rollup/plugin-node-resolve -o $@
|
||||
|
73
codemirror-element/code-editor.mjs
Normal file
73
codemirror-element/code-editor.mjs
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {basicSetup} from "codemirror";
|
||||
import {EditorView, keymap} from "@codemirror/view";
|
||||
import {EditorState} from "@codemirror/state";
|
||||
import {python} from "@codemirror/lang-python";
|
||||
import {r} from "codemirror-lang-r";
|
||||
import {vim} from "@replit/codemirror-vim";
|
||||
import {indentWithTab} from "@codemirror/commands";
|
||||
|
||||
window.EditorView = EditorView;
|
||||
|
||||
const languages = {
|
||||
'python': python,
|
||||
'r': r
|
||||
}
|
||||
|
||||
export function codemirror_editor(language, options) {
|
||||
const language_plugin = languages[language];
|
||||
|
||||
options = Object.assign({
|
||||
extensions: [
|
||||
vim(),
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
EditorView.updateListener.of(update => {
|
||||
if(!options?.onChange || update.changes.desc.empty) {
|
||||
return;
|
||||
}
|
||||
options.onChange(update);
|
||||
})
|
||||
]
|
||||
}, options);
|
||||
|
||||
let editor = new EditorView(options);
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
|
||||
export class CodeEditorElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.language = this.getAttribute('language') || '';
|
||||
const shadowRoot = this.attachShadow({mode: 'open'});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.init_editor();
|
||||
}
|
||||
|
||||
init_editor() {
|
||||
const code = this.textContent;
|
||||
const code_tag = this.shadowRoot;
|
||||
|
||||
this.codeMirror = codemirror_editor(
|
||||
this.language,
|
||||
{
|
||||
doc: code,
|
||||
parent: code_tag,
|
||||
root: this.shadowRoot,
|
||||
onChange: update => this.onChange(update)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onChange() {
|
||||
const code = this.codeMirror.state.doc.toString();
|
||||
this.value = code;
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("code-editor", CodeEditorElement);
|
20
codemirror-element/package.json
Normal file
20
codemirror-element/package.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "codemirror-element",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.1.2",
|
||||
"@replit/codemirror-vim": "^6.2.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"codemirror-lang-r": "^0.1.0-2",
|
||||
"rollup": "^3.20.6",
|
||||
"terser": "^5.17.1"
|
||||
}
|
||||
}
|
22
manage.py
Executable file
22
manage.py
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'thinkserver.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
django
|
||||
pyyaml
|
||||
watchdog
|
0
thinks/__init__.py
Normal file
0
thinks/__init__.py
Normal file
3
thinks/admin.py
Normal file
3
thinks/admin.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
6
thinks/apps.py
Normal file
6
thinks/apps.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ThinksConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'thinks'
|
126
thinks/forms.py
Normal file
126
thinks/forms.py
Normal file
|
@ -0,0 +1,126 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
|
||||
from .models import Think
|
||||
|
||||
class CreateThinkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = ['slug']
|
||||
|
||||
def save(self, commit=True):
|
||||
print("SAVE", commit)
|
||||
instance = super().save(commit)
|
||||
if commit:
|
||||
instance.root.mkdir(exist_ok=True,parents=True)
|
||||
return instance
|
||||
|
||||
class RemixThinkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = []
|
||||
|
||||
class RenameThinkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = ['slug']
|
||||
|
||||
def __init__(self, *args, instance=None, **kwargs):
|
||||
self.original_root = None if instance is None else instance.root
|
||||
super().__init__(*args, instance=instance, **kwargs)
|
||||
|
||||
def clean_slug(self):
|
||||
slug = self.cleaned_data['slug']
|
||||
root = settings.THINKS_DIR / slug
|
||||
if root.exists() and slug != self.instance.slug:
|
||||
raise forms.ValidationError("A think with the slug {slug} already exists")
|
||||
|
||||
return slug
|
||||
|
||||
def save(self, commit=True):
|
||||
slug = self.cleaned_data['slug']
|
||||
root = settings.THINKS_DIR / slug
|
||||
|
||||
self.original_root.rename(root)
|
||||
|
||||
instance = super().save(commit)
|
||||
return instance
|
||||
|
||||
class SaveFileForm(forms.ModelForm):
|
||||
path = forms.CharField()
|
||||
content = forms.CharField(required=False, widget=forms.Textarea)
|
||||
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = []
|
||||
|
||||
def clean_path(self):
|
||||
return self.instance.file_path(self.cleaned_data['path'])
|
||||
|
||||
def save(self, commit=False):
|
||||
path = self.cleaned_data['path']
|
||||
content = self.cleaned_data['content']
|
||||
|
||||
if not path.parent.exists():
|
||||
path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
with open(path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
return super().save(commit)
|
||||
|
||||
class RenameFileForm(forms.ModelForm):
|
||||
path = forms.CharField()
|
||||
newpath = forms.CharField()
|
||||
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = []
|
||||
|
||||
def clean_path(self):
|
||||
path = self.instance.file_path(self.cleaned_data['path'])
|
||||
return path
|
||||
|
||||
def clean_newpath(self):
|
||||
path = self.instance.file_path(self.cleaned_data['newpath'])
|
||||
if path.exists():
|
||||
raise forms.ValidationError("The file {path} already exists")
|
||||
return path
|
||||
|
||||
def save(self, commit=False):
|
||||
oldpath = self.cleaned_data['path']
|
||||
newpath = self.cleaned_data['newpath']
|
||||
|
||||
print(oldpath,">",newpath)
|
||||
|
||||
oldpath.rename(newpath)
|
||||
|
||||
return super().save(commit)
|
||||
|
||||
class DeleteFileForm(forms.ModelForm):
|
||||
path = forms.CharField()
|
||||
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = []
|
||||
|
||||
def clean_path(self):
|
||||
path = self.instance.file_path(self.cleaned_data['path'])
|
||||
if path == self.instance.root:
|
||||
raise forms.ValidationError("Can't delete the think's root")
|
||||
|
||||
return path
|
||||
|
||||
def save(self, commit=False):
|
||||
path = self.cleaned_data['path']
|
||||
|
||||
path.unlink()
|
||||
|
||||
return super().save(commit)
|
||||
|
||||
class RunCommandForm(forms.ModelForm):
|
||||
command = forms.CharField()
|
||||
|
||||
class Meta:
|
||||
model = Think
|
||||
fields = []
|
0
thinks/management/__init__.py
Normal file
0
thinks/management/__init__.py
Normal file
0
thinks/management/commands/__init__.py
Normal file
0
thinks/management/commands/__init__.py
Normal file
135
thinks/management/commands/watchmake.py
Normal file
135
thinks/management/commands/watchmake.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
"""
|
||||
Requires the ``watchdog`` package.
|
||||
|
||||
Watch the current directory and its children for changes to files, and run ``make`` when certain files are changed.
|
||||
|
||||
Can be configured by a .watchmakerc file, containing settings in YAML format:
|
||||
|
||||
path = <the path to watch for changes> (default: .)
|
||||
default_make: <a list of `make` targets to run> (default: empty list, so the first target in the Makefile)
|
||||
extensions: <a list of file extensions that should trigger a ``make`` run> (default: '.js')
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
from datetime import datetime
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
class MakeHandler(FileSystemEventHandler):
|
||||
|
||||
last_time = None
|
||||
gap = 2
|
||||
|
||||
extensions = None
|
||||
|
||||
default_config = {
|
||||
'path': '.',
|
||||
'default_make': [],
|
||||
'extensions': ['.js'],
|
||||
}
|
||||
|
||||
def __init__(self, root):
|
||||
super().__init__()
|
||||
|
||||
self.configs = {}
|
||||
|
||||
self.root = root.resolve()
|
||||
|
||||
def get_think_root(self, d):
|
||||
while d.parent != self.root:
|
||||
d = d.parent
|
||||
|
||||
return d
|
||||
|
||||
def get_config(self, p):
|
||||
d = self.get_think_root(p)
|
||||
|
||||
if d not in self.configs:
|
||||
self.load_config(d)
|
||||
|
||||
return self.configs[d]
|
||||
|
||||
def load_config(self, d):
|
||||
print(f"Fetch config for {d}")
|
||||
|
||||
config_path = d / '.watchmakerc'
|
||||
|
||||
config = deepcopy(self.default_config)
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
config.update(yaml.load(f.read(),Loader=yaml.SafeLoader))
|
||||
|
||||
self.configs[d] = config
|
||||
|
||||
def on_modified(self, event):
|
||||
root = self.root
|
||||
|
||||
if event.is_directory:
|
||||
return
|
||||
|
||||
t = datetime.now()
|
||||
|
||||
src_path = Path(event.src_path).resolve()
|
||||
|
||||
if self.extensions is None or src_path.suffix in self.extensions:
|
||||
if not src_path.is_relative_to(root.resolve()):
|
||||
return
|
||||
|
||||
print('{} modified at {}'.format(root / src_path.relative_to(root.resolve()),t))
|
||||
|
||||
if src_path.name == '.watchmakerc' or (self.last_time is None or (t-self.last_time).seconds > self.gap):
|
||||
self.run(src_path)
|
||||
|
||||
self.last_time = t
|
||||
|
||||
def run(self, p):
|
||||
d = self.get_think_root(p)
|
||||
print(f"Run for {d}")
|
||||
print(p.name)
|
||||
if p.name == '.watchmakerc':
|
||||
self.load_config(d)
|
||||
config = self.get_config(p)
|
||||
print(config)
|
||||
|
||||
if (d / 'Makefile').exists():
|
||||
print("Making")
|
||||
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(res.stdout)
|
||||
f.write(res.stderr)
|
||||
else:
|
||||
print("No make")
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.rootpath = settings.THINKS_DIR
|
||||
|
||||
self.run()
|
||||
|
||||
def run(self,targets=None):
|
||||
print(f"Watching {self.rootpath}")
|
||||
|
||||
event_handler = MakeHandler(self.rootpath)
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(event_handler,str(self.rootpath),recursive=True)
|
||||
observer.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
observer.stop()
|
||||
|
||||
observer.join()
|
||||
|
21
thinks/migrations/0001_initial.py
Normal file
21
thinks/migrations/0001_initial.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 5.0.2 on 2024-03-04 14:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Think',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField()),
|
||||
],
|
||||
),
|
||||
]
|
0
thinks/migrations/__init__.py
Normal file
0
thinks/migrations/__init__.py
Normal file
45
thinks/models.py
Normal file
45
thinks/models.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
THINKS_DIR = settings.THINKS_DIR
|
||||
|
||||
# Create your models here.
|
||||
class Think(models.Model):
|
||||
|
||||
slug = models.SlugField()
|
||||
|
||||
class Meta:
|
||||
ordering = ('slug',)
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
return (THINKS_DIR / self.slug).resolve()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('think', kwargs={'slug': self.slug})
|
||||
|
||||
def get_static_url(self):
|
||||
return settings.THINKS_STATIC_URL.format(slug=self.slug)
|
||||
|
||||
def files(self):
|
||||
for f in self.root.iterdir():
|
||||
yield f.relative_to(self.root)
|
||||
|
||||
def file_path(self, relpath):
|
||||
if relpath is None:
|
||||
return None
|
||||
path = (self.root / relpath).resolve()
|
||||
if not path.is_relative_to(self.root):
|
||||
raise Exception(f"Bad path {path}")
|
||||
return path
|
||||
|
||||
def get_readme(self):
|
||||
readme_path = self.file_path('README')
|
||||
for suffix in ['', '.md', '.rst', '.txt']:
|
||||
p = readme_path.with_suffix(suffix)
|
||||
if p.exists():
|
||||
with open(p) as f:
|
||||
return f.read()
|
||||
|
||||
return None
|
6
thinks/random_slug.py
Normal file
6
thinks/random_slug.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from random import choice
|
||||
|
||||
words = 'avocado biscuit chocolate doughnut eclaire fudge goulash haddock icing juice koala lemon melon nut'.split(' ')
|
||||
|
||||
def random_slug():
|
||||
return '-'.join(choice(words) for i in range(3))
|
32273
thinks/static/thinks/code-editor.mjs
Normal file
32273
thinks/static/thinks/code-editor.mjs
Normal file
File diff suppressed because it is too large
Load diff
119
thinks/static/thinks/thinks.css
Normal file
119
thinks/static/thinks/thinks.css
Normal file
|
@ -0,0 +1,119 @@
|
|||
:root {
|
||||
--spacing: 1em;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body > header {
|
||||
padding: var(--spacing);
|
||||
& h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body.index {
|
||||
& #thinks-list {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing);
|
||||
|
||||
& > .think {
|
||||
& .readme {
|
||||
max-width: 80ch;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body.thing-editor {
|
||||
margin: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template:
|
||||
"header" auto
|
||||
"main" 1fr
|
||||
;
|
||||
gap: var(--spacing);
|
||||
/*! overflow: hidden; */
|
||||
|
||||
& main {
|
||||
display: grid;
|
||||
gap: var(--spacing);
|
||||
grid-template: "nav editor preview" / auto 3fr 2fr;
|
||||
height: 100%;
|
||||
|
||||
& > nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing);
|
||||
|
||||
& #file-tree {
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
& form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 6em;
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
& #editor {
|
||||
& #editor-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
& #code-editor {
|
||||
display: block;
|
||||
max-width: 50vw;
|
||||
}
|
||||
}
|
||||
|
||||
& #preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
& > iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#file-form {
|
||||
overflow: auto;
|
||||
/*! max-height: 100%; */
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 100ch) {
|
||||
html {
|
||||
font-size: min(3vw, 16px);
|
||||
}
|
||||
body.thing-editor {
|
||||
& main {
|
||||
grid-template:
|
||||
"nav" min-content "editor" 40vh "preview";
|
||||
|
||||
& nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
& form {
|
||||
flex-grow: 1;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
28
thinks/templates/thinks/base.html
Normal file
28
thinks/templates/thinks/base.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>{% block title %}Thinks{% endblock %}</title>
|
||||
|
||||
{% block stylesheets %}
|
||||
<link rel="stylesheet" href="{% static "thinks/thinks.css" %}">
|
||||
{% endblock stylesheets %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{% static "thinks/code-editor.mjs" %}"></script>
|
||||
{% endblock scripts %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
{% block body %}
|
||||
<header>
|
||||
{% block header %}{% endblock %}
|
||||
</header>
|
||||
<main>
|
||||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
{% endblock body %}
|
||||
</body>
|
||||
</html>
|
15
thinks/templates/thinks/delete.html
Normal file
15
thinks/templates/thinks/delete.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "thinks/base.html" %}
|
||||
|
||||
{% block header %}
|
||||
<h1>Delete {{think.slug}}</h1>
|
||||
{% endblock header %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<form method="post" >
|
||||
{{form}}
|
||||
{% csrf_token %}
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
|
||||
{% endblock main %}
|
24
thinks/templates/thinks/index.html
Normal file
24
thinks/templates/thinks/index.html
Normal file
|
@ -0,0 +1,24 @@
|
|||
{% extends "thinks/base.html" %}
|
||||
|
||||
{% block body_class %}index{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<h1>Thinks</h1>
|
||||
{% 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>
|
||||
{% endblock main %}
|
15
thinks/templates/thinks/new.html
Normal file
15
thinks/templates/thinks/new.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "thinks/base.html" %}
|
||||
|
||||
{% block header %}
|
||||
<h1>New think</h1>
|
||||
{% endblock header %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<form method="post" >
|
||||
{{form}}
|
||||
{% csrf_token %}
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
{% endblock main %}
|
15
thinks/templates/thinks/remix.html
Normal file
15
thinks/templates/thinks/remix.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "thinks/base.html" %}
|
||||
|
||||
{% block header %}
|
||||
<h1>Remix {{think.slug}}</h1>
|
||||
{% endblock header %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<form method="post" >
|
||||
{{form}}
|
||||
{% csrf_token %}
|
||||
<button type="submit">Remix</button>
|
||||
</form>
|
||||
|
||||
{% endblock main %}
|
15
thinks/templates/thinks/rename.html
Normal file
15
thinks/templates/thinks/rename.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends "thinks/base.html" %}
|
||||
|
||||
{% block header %}
|
||||
<h1>{{think.slug}}</h1>
|
||||
{% endblock header %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<form method="post" >
|
||||
{{form}}
|
||||
{% csrf_token %}
|
||||
<button type="submit">Rename</button>
|
||||
</form>
|
||||
|
||||
{% endblock main %}
|
106
thinks/templates/thinks/think.html
Normal file
106
thinks/templates/thinks/think.html
Normal file
|
@ -0,0 +1,106 @@
|
|||
{% extends "thinks/base.html" %}
|
||||
|
||||
{% block body_class %}thing-editor {{block.super}}{% endblock %}
|
||||
|
||||
{% 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>
|
||||
{% 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 %}
|
3
thinks/tests.py
Normal file
3
thinks/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
18
thinks/urls.py
Normal file
18
thinks/urls.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from .views import *
|
||||
|
||||
urlpatterns = [
|
||||
path('', IndexView.as_view(), name='index'),
|
||||
path('think/<slug:slug>', 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'),
|
||||
path('think/<slug:slug>/save-file', SaveFileView.as_view(), name='save_file'),
|
||||
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('new', CreateThinkView.as_view(), name='new_think'),
|
||||
path('new/<slug:slug>', RemixThinkView.as_view(), name='remix_think'),
|
||||
]
|
||||
|
159
thinks/views.py
Normal file
159
thinks/views.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
from django.conf import settings
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views import generic
|
||||
from django.urls import reverse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from . import forms
|
||||
from .models import Think
|
||||
from .random_slug import random_slug
|
||||
|
||||
class ThinkMixin:
|
||||
model = Think
|
||||
context_object_name = 'think'
|
||||
|
||||
class IndexView(ThinkMixin, generic.ListView):
|
||||
template_name = 'thinks/index.html'
|
||||
|
||||
class CreateThinkView(ThinkMixin, generic.CreateView):
|
||||
template_name = 'thinks/new.html'
|
||||
form_class = forms.CreateThinkForm
|
||||
|
||||
class RemixThinkView(ThinkMixin, generic.UpdateView):
|
||||
template_name = 'thinks/remix.html'
|
||||
form_class = forms.RemixThinkForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if 'referer' in request.headers:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
return self.do_remix()
|
||||
|
||||
def form_valid(self, form):
|
||||
return self.do_remix()
|
||||
|
||||
def do_remix(self):
|
||||
slug = self.kwargs['slug']
|
||||
nslug = random_slug()
|
||||
while (settings.THINKS_DIR / nslug).exists():
|
||||
nslug = random_slug()
|
||||
source = Think.objects.get(slug=slug)
|
||||
think = Think.objects.create(slug=nslug)
|
||||
shutil.copytree(source.root, think.root)
|
||||
|
||||
return redirect(think.get_absolute_url())
|
||||
|
||||
|
||||
class ThinkView(ThinkMixin, generic.DetailView):
|
||||
template_name = 'thinks/think.html'
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
|
||||
think = self.object
|
||||
|
||||
strpath = self.request.GET.get('path')
|
||||
|
||||
path = think.file_path(strpath)
|
||||
|
||||
relpath = path.relative_to(think.root) if path is not None else None
|
||||
|
||||
if path is None:
|
||||
directory = think.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
|
||||
|
||||
if path is not None and path.is_file():
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
else:
|
||||
content = ''
|
||||
|
||||
context['content'] = content
|
||||
|
||||
context['file_form'] = forms.SaveFileForm(instance=think, initial={'content': content, 'path': relpath})
|
||||
|
||||
return context
|
||||
|
||||
class RenameThinkView(ThinkMixin, generic.UpdateView):
|
||||
form_class = forms.RenameThinkForm
|
||||
template_name = 'thinks/rename.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return self.object.get_absolute_url()
|
||||
|
||||
class DeleteThinkView(ThinkMixin, generic.DeleteView):
|
||||
template_name = 'thinks/delete.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('index')
|
||||
|
||||
class ReadFileView(ThinkMixin, generic.DetailView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
think = self.get_object()
|
||||
relpath = self.kwargs['path']
|
||||
path = think.root / relpath
|
||||
print(path)
|
||||
|
||||
|
||||
class SaveFileView(ThinkMixin, generic.UpdateView):
|
||||
form_class = forms.SaveFileForm
|
||||
template_name = 'thinks/save_file.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
self.path = form.cleaned_data['path'].relative_to(self.object.root)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return self.object.get_absolute_url()+'?path='+str(self.path)
|
||||
|
||||
class RenameFileView(ThinkMixin, generic.UpdateView):
|
||||
form_class = forms.RenameFileForm
|
||||
template_name = 'thinks/rename_file.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
self.path = form.cleaned_data['newpath'].relative_to(self.object.root)
|
||||
print(form)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return self.object.get_absolute_url()+'?path='+str(self.path)
|
||||
|
||||
class DeleteFileView(ThinkMixin, generic.UpdateView):
|
||||
form_class = forms.DeleteFileForm
|
||||
template_name = 'thinks/delete_file.html'
|
||||
|
||||
def form_valid(self, form):
|
||||
self.path = form.cleaned_data['path'].relative_to(self.object.root)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return self.object.get_absolute_url()+'?path='+str(self.path.parent)
|
||||
|
||||
class RunCommandView(ThinkMixin, generic.UpdateView):
|
||||
form_class = forms.RunCommandForm
|
||||
|
||||
def form_valid(self, form):
|
||||
think = self.object
|
||||
command = form.cleaned_data['command']
|
||||
res = subprocess.run(
|
||||
command.split(' '),
|
||||
cwd=think.root,
|
||||
encoding='utf8',
|
||||
capture_output=True
|
||||
)
|
||||
return JsonResponse({'stdout': res.stdout, 'stderr': res.stderr})
|
0
thinkserver/__init__.py
Normal file
0
thinkserver/__init__.py
Normal file
16
thinkserver/asgi.py
Normal file
16
thinkserver/asgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for thinkserver project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'thinkserver.settings')
|
||||
|
||||
application = get_asgi_application()
|
129
thinkserver/settings.py
Normal file
129
thinkserver/settings.py
Normal file
|
@ -0,0 +1,129 @@
|
|||
"""
|
||||
Django settings for thinkserver project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.0.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# 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#@'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'thinks',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'thinkserver.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'thinkserver.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
||||
|
||||
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}.thinks.localhost'
|
23
thinkserver/urls.py
Normal file
23
thinkserver/urls.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
"""
|
||||
URL configuration for thinkserver project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.0/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path("", include('thinks.urls')),
|
||||
]
|
16
thinkserver/wsgi.py
Normal file
16
thinkserver/wsgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for thinkserver project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'thinkserver.settings')
|
||||
|
||||
application = get_wsgi_application()
|
Loading…
Reference in a new issue