1
0

vault backup: 2025-12-26 02:09:22
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s

This commit is contained in:
2025-12-26 02:09:22 +01:00
parent 3fddadfe50
commit 50366b9b9c
288 changed files with 58893 additions and 750 deletions

0
stroma/file/__init__.py Normal file
View File

View File

@@ -0,0 +1,4 @@
from .file_admin import FileAdmin
__all__ = ['FileAdmin']

View File

@@ -0,0 +1,88 @@
from django.contrib import admin
from django.utils.html import format_html
from file.models import File
@admin.register(File)
class FileAdmin(admin.ModelAdmin):
"""Admin interface for Files"""
list_display = ['id', 'name_with_icon', 'path_display', 'mime_type_display', 'parent_display', 'children_count', 'created_at']
list_filter = ['mime_type', 'created_at', 'user']
search_fields = ['name', 'path', 'mime_type', 'text']
readonly_fields = ['created_at', 'updated_at', 'text_preview']
fieldsets = [
('File Info', {
'fields': ['name', 'path', 'mime_type', 'parent', 'user']
}),
('Content', {
'fields': ['text_preview', 'external_url'],
'classes': ['collapse']
}),
('Metadata', {
'fields': ['metadata'],
'classes': ['collapse']
}),
('Timestamps', {
'fields': ['created_at', 'updated_at'],
'classes': ['collapse']
}),
]
def name_with_icon(self, obj):
"""Show name with icon based on mime type"""
icon = '📁' if obj.mime_type == 'application/x-folder' else '📄'
if obj.mime_type.startswith('text/markdown'):
icon = '📝'
elif obj.mime_type.startswith('application/pdf'):
icon = '📕'
elif obj.mime_type.startswith('video/'):
icon = '🎥'
return format_html('{} <strong>{}</strong>', icon, obj.name)
name_with_icon.short_description = 'Name'
def path_display(self, obj):
"""Show path with folder/file distinction"""
if obj.path:
parts = obj.path.split('/')
if len(parts) > 1:
folder_path = '/'.join(parts[:-1])
return format_html('<span style="color: #666;">{}/</span>{}', folder_path, parts[-1])
return obj.path or '-'
path_display.short_description = 'Path'
def mime_type_display(self, obj):
"""Show mime type with color coding"""
color = '#999'
if obj.mime_type == 'application/x-folder':
color = '#3b82f6'
elif obj.mime_type.startswith('text/'):
color = '#10b981'
elif obj.mime_type.startswith('application/pdf'):
color = '#ef4444'
return format_html('<span style="color: {};">{}</span>', color, obj.mime_type)
mime_type_display.short_description = 'MIME Type'
def parent_display(self, obj):
"""Show parent file"""
if obj.parent:
icon = '📁' if obj.parent.mime_type == 'application/x-folder' else '📄'
return format_html('{} {}', icon, obj.parent.name)
return '-'
parent_display.short_description = 'Parent'
def children_count(self, obj):
"""Show number of child files"""
count = obj.children.count()
if count > 0:
return format_html('<span style="color: #3b82f6; font-weight: bold;">{}</span>', count)
return '-'
children_count.short_description = '# Children'
def text_preview(self, obj):
"""Show text content preview"""
if obj.text:
preview = obj.text[:200] + '...' if len(obj.text) > 200 else obj.text
return format_html('<pre style="white-space: pre-wrap;">{}</pre>', preview)
return '-'
text_preview.short_description = 'Text Content'

6
stroma/file/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class FileConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'file'

3249
stroma/file/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "stroma-file-editor",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"sass": "^1.83.0",
"tailwindcss": "^4.1.18",
"vite": "^6.0.0"
},
"dependencies": {
"@tiptap/core": "^2.10.3",
"@tiptap/extension-bubble-menu": "^2.10.3",
"@tiptap/extension-placeholder": "^2.10.3",
"@tiptap/pm": "^2.10.3",
"@tiptap/starter-kit": "^2.10.3",
"remixicon": "^4.5.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,130 @@
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from 'tiptap-markdown';
import Placeholder from '@tiptap/extension-placeholder';
import BubbleMenu from '@tiptap/extension-bubble-menu';
import './styles.css';
import 'remixicon/fonts/remixicon.css';
// DOM Elements
const saveBtn = document.getElementById('save-btn');
const editorContainer = document.getElementById('editor-container');
const bubbleMenuEl = document.getElementById('bubble-menu');
// Editor Instance
let editor;
let currentFileId = null;
// CSRF Helper
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Initialize Editor (only once)
function initEditor() {
// Bubble Menu Buttons
const btnBold = document.getElementById('btn-bold');
const btnItalic = document.getElementById('btn-italic');
const btnStrike = document.getElementById('btn-strike');
const btnCode = document.getElementById('btn-code');
editor = new Editor({
element: document.querySelector('#editor'),
extensions: [
StarterKit,
Markdown,
Placeholder.configure({
placeholder: "Type '/' for commands...",
}),
BubbleMenu.configure({
element: bubbleMenuEl,
tippyOptions: { duration: 100 },
}),
],
content: '',
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg max-w-none focus:outline-none',
},
},
onSelectionUpdate({ editor }) {
btnBold?.classList.toggle('is-active', editor.isActive('bold'));
btnItalic?.classList.toggle('is-active', editor.isActive('italic'));
btnStrike?.classList.toggle('is-active', editor.isActive('strike'));
btnCode?.classList.toggle('is-active', editor.isActive('code'));
}
});
// Bubble Menu Listeners
if (btnBold) btnBold.addEventListener('click', () => editor.chain().focus().toggleBold().run());
if (btnItalic) btnItalic.addEventListener('click', () => editor.chain().focus().toggleItalic().run());
if (btnStrike) btnStrike.addEventListener('click', () => editor.chain().focus().toggleStrike().run());
if (btnCode) btnCode.addEventListener('click', () => editor.chain().focus().toggleCode().run());
// Container Focus
if (editorContainer) {
editorContainer.addEventListener('click', () => {
if (editor && !editor.isFocused) {
editor.chain().focus().run();
}
});
}
// Expose to global scope for SPA usage
window.editorInstance = editor;
window.setEditorFileId = (fileId) => { currentFileId = fileId; };
}
// Save Content
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
if (!editor || !currentFileId) return;
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const markdownContent = editor.storage.markdown.getMarkdown();
const saveUrl = `/file/content/${currentFileId}/save/`;
try {
const response = await fetch(saveUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({ content: markdownContent })
});
if (response.ok) {
saveBtn.textContent = 'Saved!';
setTimeout(() => {
saveBtn.textContent = 'Save Changes';
saveBtn.disabled = false;
}, 1000);
} else {
throw new Error('Save failed');
}
} catch (err) {
alert('Failed to save file');
saveBtn.textContent = 'Save Changes';
saveBtn.disabled = false;
}
});
}
// Initialize editor on load
if (document.querySelector('#editor')) {
initEditor();
}

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import 'remixicon/fonts/remixicon.css';

View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../templates/**/*.html",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
root: 'src',
base: '/static/file/editor_dist/',
build: {
outDir: '../../static/file/editor_dist',
emptyOutDir: true,
manifest: false, // simplified for now
rollupOptions: {
input: path.resolve(__dirname, 'src/editor.js'),
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`
}
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});

View File

@@ -0,0 +1,37 @@
# Generated by Django 6.0 on 2025-12-25 13:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('quiz', '0013_delete_file'),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Display name or filename', max_length=500)),
('path', models.CharField(blank=True, help_text='Path relative to content root', max_length=1000)),
('mime_type', models.CharField(help_text='MIME type of the entity (e.g. application/pdf, application/x-folder)', max_length=100)),
('file_content', models.FileField(blank=True, help_text='Uploaded file content', null=True, upload_to='uploads/')),
('text', models.TextField(blank=True, help_text='Text content, OCR, or embedded query')),
('external_url', models.URLField(blank=True, help_text='External link (e.g. YouTube)')),
('metadata', models.JSONField(blank=True, default=dict, help_text='Frontmatter (created_at, user, etc.)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('parent', models.ForeignKey(blank=True, help_text='Parent folder or parent document (for sidecars/sub-entries)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='file.file')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='quiz.quizuser')),
],
options={
'verbose_name': 'File',
'verbose_name_plural': 'Files',
},
),
]

View File

View File

@@ -0,0 +1,4 @@
from .file_model import File
__all__ = ['File']

View File

@@ -0,0 +1,39 @@
from django.db import models
class File(models.Model):
name = models.CharField(max_length=500, help_text="Display name or filename")
path = models.CharField(max_length=1000, blank=True, help_text="Path relative to content root")
mime_type = models.CharField(max_length=100, help_text="MIME type of the entity (e.g. application/pdf, application/x-folder)")
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children',
help_text="Parent folder or parent document (for sidecars/sub-entries)"
)
# File storage
file_content = models.FileField(upload_to='uploads/', null=True, blank=True, help_text="Uploaded file content")
# Content storage
text = models.TextField(blank=True, help_text="Text content, OCR, or embedded query")
external_url = models.URLField(blank=True, help_text="External link (e.g. YouTube)")
# Metadata
metadata = models.JSONField(default=dict, blank=True, help_text="Frontmatter (created_at, user, etc.)")
# Ownership and house-keeping
user = models.ForeignKey('quiz.QuizUser', on_delete=models.CASCADE, related_name='files')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "File"
verbose_name_plural = "Files"
def __str__(self):
return f"[{self.mime_type}] {self.name}"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stroma File Explorer</title>
{% load static %}
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="{% static 'file/editor_dist/assets/editor.css' %}">
<style>
body {
margin: 0;
height: 100vh;
overflow: hidden;
}
.folder-icon::before {
content: '';
display: inline-block;
margin-right: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
transition: transform 0.2s ease;
}
.folder-icon.open::before {
transform: rotate(90deg);
}
.tree-item {
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.15s;
}
.tree-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.tree-item.selected {
background-color: rgba(59, 130, 246, 0.1);
}
.tree-children {
padding-left: 1rem;
display: none;
}
.tree-children.open {
display: block;
}
/* Editor Styles */
.ProseMirror {
outline: none;
padding: 1rem 2rem;
min-height: 100%;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Bubble Menu Styles */
.bubble-menu {
background-color: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
padding: 0.25rem;
gap: 0.25rem;
}
.bubble-menu button {
border: none;
background: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
color: #4a5568;
}
.bubble-menu button:hover,
.bubble-menu button.is-active {
background-color: #edf2f7;
color: #2d3748;
}
</style>
</head>
<body class="bg-gray-50">
<div class="flex h-full">
<!-- Sidebar -->
<div class="w-64 bg-white border-r border-gray-200 overflow-y-auto">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-800">Explorer</h2>
<a href="/upload/" class="text-blue-500 text-xs hover:underline">Upload</a>
</div>
<div class="p-2" id="file-tree">
<div class="p-4 text-gray-400 text-sm">Loading structure...</div>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 flex flex-col bg-white">
<!-- Empty State -->
<div id="empty-state" class="flex flex-col items-center justify-center h-full text-gray-400">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z">
</path>
</svg>
<p>Select a file to view or edit</p>
</div>
<!-- Markdown Editor -->
<div id="markdown-container" class="flex-1 flex-col hidden">
<!-- Header -->
<div class="bg-white border-b border-gray-200 px-4 py-3 flex justify-between items-center shrink-0 z-10">
<h3 id="markdown-title" class="text-sm font-medium text-gray-700 truncate mr-4"></h3>
<button id="save-btn"
class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 px-4 rounded transition">
Save Changes
</button>
</div>
<!-- Bubble Menu -->
<div id="bubble-menu" class="bubble-menu hidden">
<button id="btn-bold" class="font-bold">B</button>
<button id="btn-italic" class="italic">i</button>
<button id="btn-strike" class="line-through">S</button>
<button id="btn-code" class="font-mono">&lt;&gt;</button>
</div>
<!-- Editor Container -->
<div class="flex-1 overflow-auto relative cursor-text" id="editor-container">
<div id="editor" class="prose prose-sm sm:prose lg:prose-lg max-w-none h-full"></div>
</div>
</div>
<!-- PDF Viewer -->
<div id="pdf-container" class="flex-1 hidden">
<iframe id="pdf-iframe" class="w-full h-full border-none"></iframe>
</div>
</div>
</div>
<script>
const fileTree = document.getElementById('file-tree');
const emptyState = document.getElementById('empty-state');
const markdownContainer = document.getElementById('markdown-container');
const pdfContainer = document.getElementById('pdf-container');
const pdfIframe = document.getElementById('pdf-iframe');
const markdownTitle = document.getElementById('markdown-title');
let selectedFileId = null;
let selectedElement = null;
let currentFileType = null;
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
async function loadTree() {
try {
const response = await fetch('{% url "file:tree_api" %}');
const structure = await response.json();
fileTree.innerHTML = '';
structure.forEach(item => createTreeItem(item, fileTree));
} catch (err) {
fileTree.innerHTML = '<div class="p-4 text-red-500 text-sm">Failed to load structure</div>';
}
}
function createTreeItem(item, parentElement) {
const itemDiv = document.createElement('div');
if (item.type === 'folder') {
const folderDiv = document.createElement('div');
folderDiv.className = 'tree-item folder-icon flex items-center';
folderDiv.innerHTML = `<span class="truncate">${item.name}</span>`;
const childrenDiv = document.createElement('div');
childrenDiv.className = 'tree-children';
folderDiv.addEventListener('click', (e) => {
e.stopPropagation();
folderDiv.classList.toggle('open');
childrenDiv.classList.toggle('open');
});
itemDiv.appendChild(folderDiv);
itemDiv.appendChild(childrenDiv);
item.children.forEach(child => createTreeItem(child, childrenDiv));
} else {
const fileDiv = document.createElement('div');
fileDiv.className = 'tree-item truncate';
fileDiv.textContent = item.name;
fileDiv.dataset.fileId = item.id;
fileDiv.dataset.filePath = item.path;
fileDiv.dataset.fileName = item.name;
fileDiv.dataset.mimeType = item.mime_type;
fileDiv.addEventListener('click', async (e) => {
e.stopPropagation();
if (selectedElement) selectedElement.classList.remove('selected');
fileDiv.classList.add('selected');
selectedElement = fileDiv;
loadFile(item.id, item.name, item.mime_type, item.path);
});
itemDiv.appendChild(fileDiv);
}
parentElement.appendChild(itemDiv);
}
async function loadFile(id, name, mimeType, path) {
selectedFileId = id;
// Update URL hash with the file path, replacing spaces with underscores
const hashPath = path.replace(/ /g, '_');
window.location.hash = `#${hashPath}`;
// Hide all containers
emptyState.classList.add('hidden');
markdownContainer.classList.add('hidden');
pdfContainer.classList.add('hidden');
if (mimeType === 'application/pdf') {
// Show PDF viewer
currentFileType = 'pdf';
pdfIframe.src = `{% url "file:pdf_viewer" 0 %}`.replace('0', id);
pdfContainer.classList.remove('hidden');
} else {
// Show markdown editor and load content
currentFileType = 'markdown';
markdownTitle.textContent = name;
markdownContainer.classList.remove('hidden');
// Set current file ID for editor
if (window.setEditorFileId) {
window.setEditorFileId(id);
}
// Load content via API
try {
const response = await fetch(`{% url "file:get_content" 0 %}`.replace('0', id));
const data = await response.json();
// Update editor content if editor is initialized
if (window.editorInstance) {
window.editorInstance.commands.setContent(data.content || '');
}
} catch (err) {
console.error('Failed to load file content:', err);
}
}
}
function loadFileFromHash() {
const hash = window.location.hash.substring(1); // Remove #
if (hash) {
// Convert underscores back to spaces to match the actual file path
const filePath = hash.replace(/_/g, ' ');
// Find the file in the tree and load it
const fileElement = findFileElement(filePath);
if (fileElement) {
// Expand all parent folders
expandParentFolders(fileElement);
// Click the file to load it
fileElement.click();
}
}
}
function findFileElement(filePath) {
// Search through all file tree items
const allItems = fileTree.querySelectorAll('.tree-item');
for (let item of allItems) {
// Skip folder items
if (item.classList.contains('folder-icon')) continue;
// Check if this item has the matching file path
if (item.dataset.filePath === filePath) {
return item;
}
}
return null;
}
function expandParentFolders(element) {
let parent = element.parentElement;
while (parent && parent !== fileTree) {
if (parent.classList.contains('tree-children')) {
parent.classList.add('open');
// Find the folder div and add open class to it
const folderDiv = parent.previousElementSibling;
if (folderDiv && folderDiv.classList.contains('folder-icon')) {
folderDiv.classList.add('open');
}
}
parent = parent.parentElement;
}
}
// Initialize
loadTree().then(() => {
// After tree is loaded, check for hash and load file if present
loadFileFromHash();
});
// Listen for hash changes (e.g., browser back/forward)
window.addEventListener('hashchange', loadFileFromHash);
</script>
<!-- Load editor module after DOM is ready -->
<script type="module" src="{% static 'file/editor_dist/assets/editor.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,92 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Editor</title>
<link rel="stylesheet" href="{% static 'file/editor_dist/assets/editor.css' %}">
<style>
body {
margin: 0;
height: 100vh;
overflow: auto;
background-color: #ffffff;
}
/* Editor Styles */
.ProseMirror {
outline: none;
padding: 1rem 2rem;
min-height: 100%;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Basic Menu Styles (will be enhanced) */
.bubble-menu {
background-color: #fff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
padding: 0.25rem;
gap: 0.25rem;
}
.bubble-menu button {
border: none;
background: none;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
color: #4a5568;
}
.bubble-menu button:hover,
.bubble-menu button.is-active {
background-color: #edf2f7;
color: #2d3748;
}
</style>
</head>
<body class="flex flex-col h-full">
<!-- Configuration -->
<div id="editor-config" data-get-url="{{ get_content_url }}" data-save-url="{{ save_content_url }}" class="hidden">
</div>
<!-- Header -->
<div class="bg-white border-b border-gray-200 px-4 py-3 flex justify-between items-center shrink-0 z-10">
<h3 class="text-sm font-medium text-gray-700 truncate mr-4">{{ file_name }}</h3>
<button id="save-btn"
class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 px-4 rounded transition">
Save Changes
</button>
</div>
<!-- Bubble Menu -->
<div id="bubble-menu" class="bubble-menu hidden">
<button id="btn-bold" class="font-bold">B</button>
<button id="btn-italic" class="italic">i</button>
<button id="btn-strike" class="line-through">S</button>
<button id="btn-code" class="font-mono">&lt;&gt;</button>
</div>
<!-- Editor Container -->
<div class="flex-1 overflow-auto relative cursor-text" id="editor-container">
<div id="editor" class="prose prose-sm sm:prose lg:prose-lg max-w-none h-full"></div>
</div>
<!-- Modules -->
<script type="module" src="{% static 'file/editor_dist/assets/editor.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDF Viewer - {{ file_name }}</title>
<style>
body,
html {
margin: 0;
padding: 0;
height: 100%;
overflow: auto;
background-color: #525659;
}
</style>
</head>
<body>
<embed src="{{ pdf_url }}" type="application/pdf" width="100%" height="100%" title="Embedded PDF Viewer" />
</body>
</html>

View File

@@ -0,0 +1,455 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload Files</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 40px;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
h1 {
color: #2d3748;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #718096;
margin-bottom: 30px;
font-size: 14px;
}
.upload-section {
margin-bottom: 25px;
}
.upload-actions {
margin-top: 20px;
display: flex;
gap: 15px;
justify-content: center;
align-items: center;
}
.upload-btn-select {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.upload-btn-select:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.upload-zone {
border: 2px dashed #cbd5e0;
border-radius: 12px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f7fafc;
}
.upload-zone:hover {
border-color: #667eea;
background: #edf2f7;
}
.upload-zone.dragover {
border-color: #667eea;
background: #e6f2ff;
}
.upload-icon {
font-size: 48px;
margin-bottom: 15px;
color: #a0aec0;
}
.upload-text {
color: #4a5568;
font-size: 16px;
margin-bottom: 8px;
}
.upload-hint {
color: #a0aec0;
font-size: 13px;
}
input[type="file"] {
display: none;
}
.file-list {
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
}
.file-item {
background: #f7fafc;
padding: 12px 15px;
border-radius: 8px;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.file-name {
color: #2d3748;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-path {
color: #a0aec0;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
color: #718096;
font-size: 12px;
margin-left: 10px;
}
.upload-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 30px;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
width: 100%;
margin-top: 20px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.upload-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
}
.upload-btn:active {
transform: translateY(0);
}
.upload-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.progress-container {
margin-top: 20px;
display: none;
}
.progress-bar {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
margin-top: 10px;
color: #4a5568;
font-size: 14px;
}
.success-message {
background: #c6f6d5;
color: #22543d;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
animation: slideIn 0.3s ease;
}
.error-message {
background: #fed7d7;
color: #742a2a;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
animation: slideIn 0.3s ease;
}
</style>
</head>
<body>
<div class="container">
<h1>📁 Upload Content</h1>
<p class="subtitle">Drag & drop files or folders, or use the buttons below</p>
<div class="upload-section">
<div class="upload-zone" id="uploadZone">
<div class="upload-icon">📤</div>
<div class="upload-text">Drag & drop here</div>
<div class="upload-hint">or click to browse:</div>
<div class="upload-actions">
<button type="button" class="upload-btn-select" id="selectFilesBtn">Select Files</button>
<button type="button" class="upload-btn-select" id="selectFolderBtn">Select Folder</button>
</div>
</div>
<input type="file" id="fileInput" multiple>
<input type="file" id="folderInput" webkitdirectory directory>
</div>
<div class="file-list" id="fileList"></div>
<div class="progress-container" id="progressContainer">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">Uploading...</div>
</div>
<div class="success-message" id="successMessage">
✓ Files uploaded successfully!
</div>
<div class="error-message" id="errorMessage"></div>
</div>
<script>
const fileInput = document.getElementById('fileInput');
const folderInput = document.getElementById('folderInput');
const uploadZone = document.getElementById('uploadZone');
const selectFilesBtn = document.getElementById('selectFilesBtn');
const selectFolderBtn = document.getElementById('selectFolderBtn');
const fileList = document.getElementById('fileList');
const fileCount = document.getElementById('fileCount');
const progressContainer = document.getElementById('progressContainer');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const successMessage = document.getElementById('successMessage');
const errorMessage = document.getElementById('errorMessage');
let selectedFiles = [];
// Manual selection buttons
selectFilesBtn.addEventListener('click', () => fileInput.click());
selectFolderBtn.addEventListener('click', () => folderInput.click());
// Upload zone click (default to file selection)
uploadZone.addEventListener('click', (e) => {
// Only trigger if clicking the zone itself, not the buttons
if (e.target.tagName !== 'BUTTON') {
fileInput.click();
}
});
// File selection
fileInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
});
folderInput.addEventListener('change', (e) => {
addFiles(Array.from(e.target.files));
});
// Drag and drop
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
addFiles(files);
});
function addFiles(files) {
selectedFiles = [...selectedFiles, ...files];
renderFileList();
if (selectedFiles.length > 0) {
startUpload();
}
}
function renderFileList() {
fileList.innerHTML = '';
selectedFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'file-item';
const info = document.createElement('div');
info.className = 'file-info';
const name = document.createElement('div');
name.className = 'file-name';
name.textContent = file.name;
const path = document.createElement('div');
path.className = 'file-path';
path.textContent = file.webkitRelativePath || file.name;
info.appendChild(name);
if (file.webkitRelativePath) {
info.appendChild(path);
}
const size = document.createElement('div');
size.className = 'file-size';
size.textContent = formatFileSize(file.size);
item.appendChild(info);
item.appendChild(size);
fileList.appendChild(item);
});
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async function startUpload() {
if (selectedFiles.length === 0) return;
progressContainer.style.display = 'block';
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
const formData = new FormData();
selectedFiles.forEach((file, index) => {
formData.append('files', file);
if (file.webkitRelativePath) {
formData.append(`path_${index}`, file.webkitRelativePath);
}
});
try {
const response = await fetch('/file/upload/api/', {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
});
if (response.ok) {
const result = await response.json();
progressFill.style.width = '100%';
progressText.textContent = `Uploaded ${result.count} file(s) successfully!`;
setTimeout(() => {
successMessage.style.display = 'block';
progressContainer.style.display = 'none';
selectedFiles = [];
renderFileList();
progressFill.style.width = '0%';
}, 500);
} else {
throw new Error('Upload failed');
}
} catch (error) {
errorMessage.textContent = '✗ Upload failed: ' + error.message;
errorMessage.style.display = 'block';
progressContainer.style.display = 'none';
}
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
</script>
</body>
</html>

21
stroma/file/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from file.views import (
upload_files_page, upload_files_api, explorer_view, pdf_viewer_page,
markdown_editor_page, get_file_tree, get_file_content, save_file_content,
serve_pdf_api
)
app_name = 'file'
urlpatterns = [
path('upload/', upload_files_page, name='upload_page'),
path('upload/api/', upload_files_api, name='upload_api'),
path('explorer/', explorer_view, name='explorer'),
path('viewer/pdf/<int:file_id>/', pdf_viewer_page, name='pdf_viewer'),
path('viewer/markdown/<int:file_id>/', markdown_editor_page, name='markdown_editor'),
path('tree/', get_file_tree, name='tree_api'),
path('content/<int:file_id>/', get_file_content, name='get_content'),
path('content/<int:file_id>/save/', save_file_content, name='save_content'),
path('pdf/<int:file_id>/', serve_pdf_api, name='serve_pdf'),
]

View File

@@ -0,0 +1,18 @@
from .upload_files_page_view import upload_files_page
from .upload_files_api_view import upload_files_api
from .explorer_view import explorer_view, pdf_viewer_page, markdown_editor_page
from .tree_api_view import get_file_tree
from .content_api_view import get_file_content, save_file_content, serve_pdf_api
__all__ = [
'upload_files_page',
'upload_files_api',
'explorer_view',
'pdf_viewer_page',
'markdown_editor_page',
'get_file_tree',
'get_file_content',
'save_file_content',
'serve_pdf_api',
]

View File

@@ -0,0 +1,48 @@
import json
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.shortcuts import get_object_or_404
from file.models import File
@require_http_methods(["GET"])
def get_file_content(request, file_id):
"""Get the text content of a specific file."""
file_obj = get_object_or_404(File, id=file_id, user=request.quiz_user)
return JsonResponse({
'id': file_obj.id,
'name': file_obj.name,
'content': file_obj.text
})
@require_http_methods(["POST"])
def save_file_content(request, file_id):
"""Save updated text content to a file."""
file_obj = get_object_or_404(File, id=file_id, user=request.quiz_user)
try:
data = json.loads(request.body)
new_content = data.get('content', '')
file_obj.text = new_content
file_obj.save()
return JsonResponse({'success': True})
except (json.JSONDecodeError, KeyError):
return JsonResponse({'success': False, 'error': 'Invalid data'}, status=400)
from django.http import FileResponse
def serve_pdf_api(request, file_id):
"""Serve the raw PDF file for viewing."""
file_obj = get_object_or_404(File, id=file_id, user=request.quiz_user)
if not file_obj.mime_type == 'application/pdf' or not file_obj.file_content:
return JsonResponse({'error': 'Not a PDF file'}, status=400)
response = FileResponse(
file_obj.file_content.open('rb'),
content_type='application/pdf'
)
# Add CORS headers to allow CDN-hosted PDF.js viewer to fetch the PDF
response['Access-Control-Allow-Origin'] = '*'
response['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
response['Access-Control-Allow-Headers'] = 'Content-Type'
return response

View File

@@ -0,0 +1,32 @@
from django.shortcuts import render
def explorer_view(request):
"""Render the file explorer page."""
return render(request, 'file/explorer.html')
def pdf_viewer_page(request, file_id):
"""Render the PDF viewer template for a specific file."""
from django.urls import reverse
from file.models import File
file_obj = File.objects.get(id=file_id, user=request.quiz_user)
relative_url = reverse('file:serve_pdf', args=[file_id])
# Build absolute URL for PDF.js library
pdf_url = request.build_absolute_uri(relative_url)
return render(request, 'file/pdf_viewer.html', {
'pdf_url': pdf_url,
'file_name': file_obj.name
})
def markdown_editor_page(request, file_id):
"""Render the Markdown editor template for a specific file."""
from django.urls import reverse
from file.models import File
file_obj = File.objects.get(id=file_id, user=request.quiz_user)
context = {
'file_id': file_id,
'file_name': file_obj.name,
'get_content_url': reverse('file:get_content', args=[file_id]),
'save_content_url': reverse('file:save_content', args=[file_id]),
}
return render(request, 'file/markdown_editor.html', context)

View File

@@ -0,0 +1,31 @@
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from file.models import File
@require_http_methods(["GET"])
def get_file_tree(request):
"""Return the hierarchical file tree for the user."""
files = File.objects.filter(user=request.quiz_user).select_related('parent').order_by('name')
# Create a mapping of id -> item
item_map = {}
for f in files:
item_map[f.id] = {
'id': f.id,
'name': f.name,
'path': f.path,
'type': 'folder' if f.mime_type == 'application/x-folder' else 'file',
'mime_type': f.mime_type,
'children': [],
'content': f.text if f.mime_type.startswith('text/') else None
}
root_items = []
for f in files:
item = item_map[f.id]
if f.parent_id:
if f.parent_id in item_map:
item_map[f.parent_id]['children'].append(item)
else:
root_items.append(item)
return JsonResponse(root_items, safe=False)

View File

@@ -0,0 +1,101 @@
import os
import mimetypes
from pathlib import Path
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from file.models import File
@require_http_methods(["POST"])
def upload_files_api(request):
"""Handle file/folder uploads and create File model instances"""
uploaded_files = request.FILES.getlist('files')
if not uploaded_files:
return JsonResponse({'error': 'No files uploaded'}, status=400)
created_files = []
folder_cache = {} # Cache for created folder objects
for idx, uploaded_file in enumerate(uploaded_files):
# Get the relative path if it exists (from webkitRelativePath)
relative_path = request.POST.get(f'path_{idx}', '')
if relative_path:
# This is from a folder upload
path_obj = Path(relative_path)
parts = path_obj.parts
# Create parent folders if needed
parent = None
for i, part in enumerate(parts[:-1]): # Exclude the file itself
folder_path = os.path.join(*parts[:i+1])
if folder_path not in folder_cache:
# Create or get folder
folder, created = File.objects.get_or_create(
user=request.quiz_user,
path=folder_path,
defaults={
'name': part,
'mime_type': 'application/x-folder',
'parent': parent
}
)
folder_cache[folder_path] = folder
parent = folder_cache[folder_path]
file_path = relative_path
file_name = parts[-1]
else:
# Single file upload
file_path = uploaded_file.name
file_name = uploaded_file.name
parent = None
# Determine MIME type
mime_type, _ = mimetypes.guess_type(file_name)
if not mime_type:
mime_type = 'application/octet-stream'
# Read file content (for text files, store in text field)
text_content = ''
if mime_type.startswith('text/'):
try:
content_bytes = uploaded_file.read()
text_content = content_bytes.decode('utf-8')
uploaded_file.seek(0) # Reset for saving to disk
except (UnicodeDecodeError, AttributeError):
uploaded_file.seek(0)
# Generate unique filename with 8-digit hash
import hashlib
file_hash = hashlib.md5(f"{file_path}{uploaded_file.name}".encode()).hexdigest()[:8]
name_parts = os.path.splitext(file_name)
unique_filename = f"{name_parts[0]}_{file_hash}{name_parts[1]}"
# Create File instance
file_obj = File.objects.create(
user=request.quiz_user,
name=file_name,
path=file_path,
mime_type=mime_type,
parent=parent,
text=text_content
)
# Save the uploaded file to disk (not for folders)
if mime_type != 'application/x-folder':
file_obj.file_content.save(unique_filename, uploaded_file, save=True)
created_files.append(file_obj)
return JsonResponse({
'success': True,
'count': len(created_files),
'files': [{'name': f.name, 'path': f.path} for f in created_files]
})

View File

@@ -0,0 +1,7 @@
from django.shortcuts import render
def upload_files_page(request):
"""Render the file upload interface"""
return render(request, 'file/upload_files.html')