vault backup: 2025-12-26 02:09:22
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s
This commit is contained in:
0
stroma/file/__init__.py
Normal file
0
stroma/file/__init__.py
Normal file
4
stroma/file/admin/__init__.py
Normal file
4
stroma/file/admin/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .file_admin import FileAdmin
|
||||
|
||||
__all__ = ['FileAdmin']
|
||||
|
||||
88
stroma/file/admin/file_admin.py
Normal file
88
stroma/file/admin/file_admin.py
Normal 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
6
stroma/file/apps.py
Normal 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
3249
stroma/file/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
stroma/file/frontend/package.json
Normal file
29
stroma/file/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
stroma/file/frontend/postcss.config.js
Normal file
6
stroma/file/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
130
stroma/file/frontend/src/editor.js
Normal file
130
stroma/file/frontend/src/editor.js
Normal 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();
|
||||
}
|
||||
2
stroma/file/frontend/src/styles.css
Normal file
2
stroma/file/frontend/src/styles.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import 'remixicon/fonts/remixicon.css';
|
||||
13
stroma/file/frontend/tailwind.config.js
Normal file
13
stroma/file/frontend/tailwind.config.js
Normal 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'),
|
||||
],
|
||||
}
|
||||
25
stroma/file/frontend/vite.config.js
Normal file
25
stroma/file/frontend/vite.config.js
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
37
stroma/file/migrations/0001_initial.py
Normal file
37
stroma/file/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
stroma/file/migrations/__init__.py
Normal file
0
stroma/file/migrations/__init__.py
Normal file
4
stroma/file/models/__init__.py
Normal file
4
stroma/file/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .file_model import File
|
||||
|
||||
__all__ = ['File']
|
||||
|
||||
39
stroma/file/models/file_model.py
Normal file
39
stroma/file/models/file_model.py
Normal 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}"
|
||||
|
||||
1
stroma/file/static/file/editor_dist/assets/editor.css
Normal file
1
stroma/file/static/file/editor_dist/assets/editor.css
Normal file
File diff suppressed because one or more lines are too long
124
stroma/file/static/file/editor_dist/assets/editor.js
Normal file
124
stroma/file/static/file/editor_dist/assets/editor.js
Normal file
File diff suppressed because one or more lines are too long
BIN
stroma/file/static/file/editor_dist/assets/remixicon.eot
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.eot
Normal file
Binary file not shown.
9427
stroma/file/static/file/editor_dist/assets/remixicon.svg
Normal file
9427
stroma/file/static/file/editor_dist/assets/remixicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.8 MiB |
BIN
stroma/file/static/file/editor_dist/assets/remixicon.ttf
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.ttf
Normal file
Binary file not shown.
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff
Normal file
Binary file not shown.
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff2
Normal file
BIN
stroma/file/static/file/editor_dist/assets/remixicon.woff2
Normal file
Binary file not shown.
342
stroma/file/templates/file/explorer.html
Normal file
342
stroma/file/templates/file/explorer.html
Normal 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"><></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>
|
||||
92
stroma/file/templates/file/markdown_editor.html
Normal file
92
stroma/file/templates/file/markdown_editor.html
Normal 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"><></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>
|
||||
24
stroma/file/templates/file/pdf_viewer.html
Normal file
24
stroma/file/templates/file/pdf_viewer.html
Normal 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>
|
||||
455
stroma/file/templates/file/upload_files.html
Normal file
455
stroma/file/templates/file/upload_files.html
Normal 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
21
stroma/file/urls.py
Normal 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'),
|
||||
]
|
||||
|
||||
18
stroma/file/views/__init__.py
Normal file
18
stroma/file/views/__init__.py
Normal 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',
|
||||
]
|
||||
|
||||
48
stroma/file/views/content_api_view.py
Normal file
48
stroma/file/views/content_api_view.py
Normal 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
|
||||
32
stroma/file/views/explorer_view.py
Normal file
32
stroma/file/views/explorer_view.py
Normal 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)
|
||||
31
stroma/file/views/tree_api_view.py
Normal file
31
stroma/file/views/tree_api_view.py
Normal 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)
|
||||
101
stroma/file/views/upload_files_api_view.py
Normal file
101
stroma/file/views/upload_files_api_view.py
Normal 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]
|
||||
})
|
||||
|
||||
7
stroma/file/views/upload_files_page_view.py
Normal file
7
stroma/file/views/upload_files_page_view.py
Normal 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')
|
||||
|
||||
Reference in New Issue
Block a user