Rocket Loader (Cloudflare) reecrit les balises script inline en type=...-text/javascript et les execute de facon differee, ce qui casse l'enregistrement des composants Alpine et l'init des pages lors d'un acces direct via le tunnel (ex. /settings ne se chargeait pas au complet). Ajout de data-cfasync=false sur les scripts inline des pages completes et des partiels du shell (settings, accounts, trash, workspace, board, gitea_workspace, welcome, import, page_editor_collection/embed, _database_table_scripts, _notification_bell, public_page, local_workspace). Les fragments charges via HTMX restent inchanges.
820 lines
35 KiB
HTML
820 lines
35 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_title %}{{ workspace_name }}{% endblock %}
|
|
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
|
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
|
|
|
{% block topbar %}
|
|
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
|
{% set page_icon = "link" %}
|
|
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
|
{% include '_header.html' %}
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Prism.js syntax highlighting -->
|
|
<link rel="stylesheet" href="/static/css/prism.css">
|
|
<script src="/static/js/prism.min.js"></script>
|
|
<style>
|
|
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
|
|
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
|
.gw-content h3{font-size:16px;margin-bottom:12px;color:var(--text-dim);}
|
|
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
|
.gw-content pre[class*="language-"]{background:transparent;border:none;padding:0;margin:0;white-space:pre;word-break:normal;}
|
|
.gw-content code[class*="language-"]{font-size:13px;line-height:1.5;tab-size:4;}
|
|
.gw-content .empty-state{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
|
.gw-content .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
|
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
|
.gw-content textarea:focus{border-color:var(--accent);}
|
|
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
|
|
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
|
|
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
|
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
|
.gw-commit-bar input:focus{border-color:var(--accent);}
|
|
|
|
/* Prism theme tweaks for dark UI */
|
|
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
|
</style>
|
|
|
|
<script data-cfasync="false">
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('giteaWorkspace', () => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const owner = params.get('owner') || '';
|
|
const repo = params.get('repo') || '';
|
|
|
|
return {
|
|
owner, repo,
|
|
showFile: false,
|
|
showEditor: false,
|
|
showPrivate: false,
|
|
privatePages: [],
|
|
editingPrivate: null,
|
|
editingPrivateTitle: '',
|
|
editingPrivateContent: '',
|
|
filePath: '',
|
|
fileContent: '',
|
|
fileSize: 0,
|
|
fileSha: '',
|
|
fileLoading: false,
|
|
fileLanguage: 'text',
|
|
editContent: '',
|
|
commitMessage: 'Update via FlowDeck',
|
|
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
|
// File tree browser
|
|
treeItems: [],
|
|
sortedTreeItems: [],
|
|
treeLoading: false,
|
|
folderStack: [],
|
|
currentPath: '',
|
|
// Context menu
|
|
gwCtx: { visible: false, x: 0, y: 0, item: null },
|
|
|
|
_sortTree() {
|
|
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
|
|
if (a.type === b.type) return a.name.localeCompare(b.name);
|
|
return a.type === 'folder' ? -1 : 1;
|
|
});
|
|
},
|
|
|
|
init() {
|
|
// Expose globally for header to access
|
|
window._gwData = this;
|
|
// Set cookie for sidebar
|
|
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
|
// Load sidebar tree (into gitea section)
|
|
this.loadSidebarTree();
|
|
// Load main content tree
|
|
this.loadMainTree('');
|
|
// Listen for sidebar clicks on Gitea items
|
|
this.setupSidebarClicks();
|
|
},
|
|
|
|
async loadMainTree(path) {
|
|
this.treeLoading = true;
|
|
this.currentPath = path;
|
|
try {
|
|
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
|
|
if (path) url += '?path=' + encodeURIComponent(path);
|
|
var r = await fetch(url);
|
|
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
|
|
var d = await r.json();
|
|
this.treeItems = d.tree || [];
|
|
this._sortTree();
|
|
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
|
finally { this.treeLoading = false; }
|
|
},
|
|
|
|
drillDown(path) {
|
|
this.folderStack.push(path.split('/').pop());
|
|
this.loadMainTree(path);
|
|
},
|
|
|
|
navigateToFolder(idx) {
|
|
// Truncate stack and rebuild path
|
|
this.folderStack = this.folderStack.slice(0, idx + 1);
|
|
var path = this.folderStack.join('/');
|
|
this.loadMainTree(path);
|
|
},
|
|
|
|
navigateToRoot() {
|
|
this.folderStack = [];
|
|
this.loadMainTree('');
|
|
},
|
|
|
|
openGwContext(ev, item) {
|
|
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
|
|
},
|
|
gwRename() {
|
|
this.gwCtx.visible = false;
|
|
var item = this.gwCtx.item;
|
|
if (!item) return;
|
|
var newName = prompt('Rename:', item.name);
|
|
if (!newName || !newName.trim() || newName.trim() === item.name) return;
|
|
var self = this;
|
|
var oldPath = item.path;
|
|
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
|
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
|
}).then(function(r){ return r.json(); })
|
|
.then(function(d){
|
|
if (d.status === 'ok') { self.refreshTree(); }
|
|
else { window.showToast('Rename failed', 'error'); }
|
|
})
|
|
.catch(function(){ window.showToast('Rename failed', 'error'); });
|
|
},
|
|
gwDelete() {
|
|
this.gwCtx.visible = false;
|
|
var item = this.gwCtx.item;
|
|
if (!item) return;
|
|
if (!confirm('Delete ' + item.name + '?')) return;
|
|
var self = this;
|
|
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {
|
|
method: 'DELETE'
|
|
}).then(function(r){
|
|
if (r.ok) { self.refreshTree(); }
|
|
else { window.showToast('Delete failed', 'error'); }
|
|
}).catch(function(){ window.showToast('Delete failed', 'error'); });
|
|
},
|
|
|
|
async loadSidebarTree() {
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
|
|
if (!r.ok) {
|
|
// If API fails (e.g. no Gitea token), set a message
|
|
var container = document.getElementById('sidebar-gitea-items');
|
|
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
|
return;
|
|
}
|
|
var d = await r.json();
|
|
var items = d.tree || [];
|
|
var container = document.getElementById('sidebar-gitea-items');
|
|
if (!container) return;
|
|
// Ensure gitea section is visible
|
|
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
|
|
window.appState.sectionsOpen.gitea = true;
|
|
}
|
|
container.innerHTML = '';
|
|
// Build tree HTML as string and set once (more performant)
|
|
var html = '';
|
|
for (var i = 0; i < items.length; i++) {
|
|
var item = items[i];
|
|
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
|
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
|
html += '<span class="page-icon">' + icon + '</span>';
|
|
html += '<span class="page-name">' + item.name + '</span>';
|
|
html += '</li>';
|
|
}
|
|
// Add Private Pages link
|
|
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
|
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
|
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
|
|
container.innerHTML = html;
|
|
// Re-attach event listeners
|
|
this.setupSidebarClicks();
|
|
} catch(e) {
|
|
console.error('Gitea sidebar tree load failed:', e);
|
|
}
|
|
},
|
|
|
|
setupSidebarClicks() {
|
|
var self = this;
|
|
document.addEventListener('click', function(e) {
|
|
var el = e.target.closest('.sidebar-item[data-gitea-path]');
|
|
if (!el) return;
|
|
|
|
var path = el.getAttribute('data-gitea-path');
|
|
var type = el.getAttribute('data-gitea-type');
|
|
|
|
// If clicking the checkbox, let its own handler deal with selection
|
|
if (e.target.classList.contains('gitea-checkbox')) return;
|
|
|
|
// If clicking the inline rename input, don't navigate
|
|
if (e.target.classList.contains('inline-rename-input')) return;
|
|
|
|
// If Shift or Ctrl/Meta is pressed, use multi-selection
|
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
self.selectItem(path, el, e);
|
|
return;
|
|
}
|
|
|
|
// Normal click: navigate (open file/folder)
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Update visual "active" state
|
|
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
|
|
si.classList.remove('active');
|
|
});
|
|
el.classList.add('active');
|
|
|
|
if (type === 'folder') {
|
|
self.loadSubdir(path, el);
|
|
} else {
|
|
self.openFile(path, el.getAttribute('data-gitea-sha'));
|
|
}
|
|
});
|
|
|
|
// Listen for custom delete event on gitea sidebar items
|
|
document.addEventListener('gitea-delete', function(e) {
|
|
var el = e.target;
|
|
var path = el.getAttribute('data-gitea-path');
|
|
if (!path) return;
|
|
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
|
var sha = el.getAttribute('data-gitea-sha') || '';
|
|
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
|
method: 'DELETE',
|
|
}).then(function(r) {
|
|
if (r.ok) self.refreshTree();
|
|
}).catch(function() {});
|
|
});
|
|
},
|
|
|
|
async loadSubdir(path, el) {
|
|
var self = this;
|
|
// Check if already loaded
|
|
var ul = el.querySelector('ul');
|
|
if (ul) {
|
|
ul.style.display = ul.style.display === 'none' ? '' : 'none';
|
|
return;
|
|
}
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
|
|
if (!r.ok) return;
|
|
var d = await r.json();
|
|
var children = d.tree || [];
|
|
ul = document.createElement('ul');
|
|
ul.className = 'sidebar-items';
|
|
ul.style.paddingLeft = '20px';
|
|
children.forEach(function(child) {
|
|
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
|
var li = document.createElement('li');
|
|
li.className = 'sidebar-item';
|
|
li.setAttribute('data-gitea-path', child.path);
|
|
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
|
li.setAttribute('data-gitea-sha', child.sha || '');
|
|
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
|
// Checkbox
|
|
var cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.className = 'gitea-checkbox';
|
|
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
|
cb.addEventListener('click', function(ev) {
|
|
ev.stopPropagation();
|
|
self.selectItem(child.path, li, ev);
|
|
});
|
|
li.appendChild(cb);
|
|
// Icon
|
|
var iconSpan = document.createElement('span');
|
|
iconSpan.className = 'sidebar-icon';
|
|
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
|
|
li.appendChild(iconSpan);
|
|
// Name
|
|
var nameSpan = document.createElement('span');
|
|
nameSpan.textContent = child.name;
|
|
nameSpan.addEventListener('dblclick', function(ev) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
self.startInlineRename(nameSpan, child.path, li);
|
|
});
|
|
li.appendChild(nameSpan);
|
|
ul.appendChild(li);
|
|
});
|
|
el.appendChild(ul);
|
|
} catch(e) {}
|
|
},
|
|
|
|
async openFile(path, sha) {
|
|
this.filePath = path;
|
|
this.fileSha = sha || '';
|
|
this.showEditor = false;
|
|
this.showFile = true;
|
|
this.fileLoading = true;
|
|
this.fileLanguage = this.getLanguage(path);
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
|
if (r.ok) {
|
|
var d = await r.json();
|
|
this.fileContent = d.content || '';
|
|
this.fileSize = d.content ? d.content.length : 0;
|
|
} else {
|
|
this.fileContent = '[Error loading file]';
|
|
}
|
|
} catch(e) {
|
|
this.fileContent = '[Error loading file]';
|
|
}
|
|
this.fileLoading = false;
|
|
// Highlight after Alpine renders
|
|
var self = this;
|
|
this.$nextTick(function() {
|
|
var block = self.$refs.codeBlock;
|
|
if (block && block.textContent && typeof Prism !== 'undefined') {
|
|
Prism.highlightElement(block);
|
|
}
|
|
});
|
|
},
|
|
|
|
getLanguage(path) {
|
|
var ext = (path || '').split('.').pop().toLowerCase();
|
|
var map = {
|
|
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
|
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
|
|
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
|
|
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
|
|
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
|
|
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
|
|
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
|
|
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
|
|
};
|
|
return map[ext] || 'text';
|
|
},
|
|
|
|
openEditor() {
|
|
this.editContent = this.fileContent;
|
|
this.showEditor = true;
|
|
},
|
|
|
|
async saveFile() {
|
|
if (!this.filePath) return;
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify({
|
|
path: this.filePath, content: this.editContent,
|
|
message: this.commitMessage, sha: this.fileSha,
|
|
})
|
|
});
|
|
if (r.ok) {
|
|
this.fileContent = this.editContent;
|
|
this.showEditor = false;
|
|
if (!this.commitHistory.includes(this.commitMessage)) {
|
|
this.commitHistory.unshift(this.commitMessage);
|
|
if (this.commitHistory.length > 10) this.commitHistory.pop();
|
|
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
|
|
}
|
|
} else {
|
|
var d = await r.json();
|
|
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
|
}
|
|
} catch(e) { window.showToast('Network error', 'error'); }
|
|
},
|
|
|
|
async deleteCurrentFile() {
|
|
if (!this.filePath) return;
|
|
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {
|
|
method: 'DELETE',
|
|
});
|
|
if (r.ok) {
|
|
this.showFile = false;
|
|
this.filePath = '';
|
|
await this.refreshTree();
|
|
}
|
|
} catch(e) {}
|
|
},
|
|
|
|
async refreshTree() {
|
|
// Reload main tree and sidebar tree without full page reload
|
|
this.loadMainTree(this.currentPath);
|
|
this.loadSidebarTree();
|
|
},
|
|
|
|
formatSize(bytes) {
|
|
if (!bytes) return '';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
|
return (bytes/1048576).toFixed(1) + ' MB';
|
|
},
|
|
|
|
// ── Private Pages ──
|
|
|
|
async loadPrivatePages() {
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
|
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
|
} catch(e) {}
|
|
},
|
|
|
|
newPrivate() {
|
|
this.editingPrivate = 'new';
|
|
this.editingPrivateTitle = '';
|
|
this.editingPrivateContent = '';
|
|
},
|
|
|
|
async openPrivate(id) {
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
|
if (r.ok) {
|
|
var d = await r.json();
|
|
this.editingPrivate = id;
|
|
this.editingPrivateTitle = d.page.title;
|
|
this.editingPrivateContent = d.page.content;
|
|
}
|
|
} catch(e) {}
|
|
},
|
|
|
|
async savePrivate() {
|
|
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
|
var method = 'POST';
|
|
if (this.editingPrivate !== 'new') {
|
|
url += '/' + this.editingPrivate;
|
|
method = 'PUT';
|
|
}
|
|
try {
|
|
var r = await fetch(url, {
|
|
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
|
|
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
|
|
});
|
|
if (r.ok) {
|
|
this.editingPrivate = null;
|
|
this.editingPrivateTitle = '';
|
|
this.editingPrivateContent = '';
|
|
await this.loadPrivatePages();
|
|
}
|
|
} catch(e) {}
|
|
},
|
|
|
|
// Create new file
|
|
showNewFile: false,
|
|
newFilePath: '',
|
|
newFileContent: '',
|
|
newFileMsg: 'Create via FlowDeck',
|
|
async createNewFile() {
|
|
if (!this.newFilePath.trim()) return;
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
|
|
});
|
|
if (r.ok) {
|
|
this.showNewFile = false;
|
|
this.newFilePath = '';
|
|
this.newFileContent = '';
|
|
this.newFileMsg = 'Create via FlowDeck';
|
|
await this.loadSidebarTree();
|
|
} else {
|
|
var d = await r.json();
|
|
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
|
|
}
|
|
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
|
|
},
|
|
|
|
// Upload files
|
|
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
|
|
async doUpload(ev) {
|
|
var files = ev.target.files;
|
|
if (!files.length) return;
|
|
for (var i = 0; i < files.length; i++) {
|
|
var form = new FormData();
|
|
form.append('file', files[i]);
|
|
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
|
|
method: 'POST',
|
|
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
|
body: form
|
|
});
|
|
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
|
|
} catch(e) { console.error('Upload error:', e); }
|
|
}
|
|
await this.loadSidebarTree();
|
|
ev.target.value = '';
|
|
},
|
|
|
|
async deletePrivate(id) {
|
|
if (!confirm('Delete this private page?')) return;
|
|
try {
|
|
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
|
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
|
});
|
|
if (r.ok) await this.loadPrivatePages();
|
|
} catch(e) {}
|
|
},
|
|
|
|
getCsrfToken() {
|
|
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
|
},
|
|
|
|
// ── Multi-selection ──
|
|
multiSelect: false,
|
|
selectedPaths: [],
|
|
lastClickedPath: null,
|
|
|
|
toggleMultiSelect() {
|
|
this.multiSelect = !this.multiSelect;
|
|
var cbs = document.querySelectorAll('.gitea-checkbox');
|
|
var self = this;
|
|
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
|
|
if (!this.multiSelect) {
|
|
// Clear selection when leaving multi-select mode
|
|
cbs.forEach(function(cb) { cb.checked = false; });
|
|
this.selectedPaths = [];
|
|
this.lastClickedPath = null;
|
|
// Remove selected class
|
|
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
|
el.classList.remove('gitea-selected');
|
|
});
|
|
}
|
|
},
|
|
|
|
selectItem(path, li, ev) {
|
|
if (ev.shiftKey && this.lastClickedPath) {
|
|
// Range select
|
|
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
|
|
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
|
|
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
|
|
if (startIdx >= 0 && endIdx >= 0) {
|
|
var lo = Math.min(startIdx, endIdx);
|
|
var hi = Math.max(startIdx, endIdx);
|
|
this.selectedPaths = [];
|
|
for (var i = lo; i <= hi; i++) {
|
|
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
|
|
all[i].classList.add('gitea-selected');
|
|
var cb = all[i].querySelector('.gitea-checkbox');
|
|
if (cb) cb.checked = true;
|
|
}
|
|
}
|
|
this.multiSelect = true;
|
|
this.toggleMultiSelect(); // ensure checkboxes are visible
|
|
} else if (ev.ctrlKey || ev.metaKey) {
|
|
// Toggle single
|
|
var idx = this.selectedPaths.indexOf(path);
|
|
if (idx >= 0) {
|
|
this.selectedPaths.splice(idx, 1);
|
|
li.classList.remove('gitea-selected');
|
|
var cb = li.querySelector('.gitea-checkbox');
|
|
if (cb) cb.checked = false;
|
|
} else {
|
|
this.selectedPaths.push(path);
|
|
li.classList.add('gitea-selected');
|
|
var cb = li.querySelector('.gitea-checkbox');
|
|
if (cb) cb.checked = true;
|
|
}
|
|
this.multiSelect = this.selectedPaths.length > 0;
|
|
this.toggleMultiSelect();
|
|
} else {
|
|
// Normal select — clear multi-selection
|
|
this.selectedPaths = [path];
|
|
this.lastClickedPath = path;
|
|
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
|
el.classList.remove('gitea-selected');
|
|
});
|
|
li.classList.add('gitea-selected', 'active');
|
|
var cb = li.querySelector('.gitea-checkbox');
|
|
if (cb) cb.checked = true;
|
|
}
|
|
},
|
|
|
|
isSelected(path) {
|
|
return this.selectedPaths.indexOf(path) >= 0;
|
|
},
|
|
|
|
// ── Inline rename ──
|
|
startInlineRename(nameSpan, path, li) {
|
|
var originalName = nameSpan.textContent;
|
|
var input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.value = originalName;
|
|
input.className = 'inline-rename-input';
|
|
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
|
|
nameSpan.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
var self = this;
|
|
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
|
|
var cancel = function() {
|
|
var span = document.createElement('span');
|
|
span.className = 'page-name';
|
|
span.textContent = originalName;
|
|
input.replaceWith(span);
|
|
};
|
|
input.addEventListener('blur', finish);
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') finish();
|
|
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
|
|
});
|
|
},
|
|
|
|
finishInlineRename(input, originalName, path, li) {
|
|
if (input._renaming) return; // guard against double-fire
|
|
input._renaming = true;
|
|
var newName = input.value.trim();
|
|
if (!newName || newName === originalName) {
|
|
var span = document.createElement('span');
|
|
span.className = 'page-name';
|
|
span.textContent = originalName;
|
|
input.replaceWith(span);
|
|
return;
|
|
}
|
|
var self = this;
|
|
// Construct new path by replacing the last segment
|
|
var parts = path.split('/');
|
|
parts.pop();
|
|
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
|
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
|
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
|
|
}).then(function(r) {
|
|
if (r.ok) {
|
|
self.refreshTree();
|
|
} else {
|
|
var span = document.createElement('span');
|
|
span.className = 'page-name';
|
|
span.textContent = originalName;
|
|
input.replaceWith(span);
|
|
window.showToast('Rename failed', 'error');
|
|
}
|
|
}).catch(function() {
|
|
var span = document.createElement('span');
|
|
span.className = 'page-name';
|
|
span.textContent = originalName;
|
|
input.replaceWith(span);
|
|
});
|
|
},
|
|
};
|
|
});
|
|
});
|
|
</script>
|
|
<div class="gw-main" x-data="giteaWorkspace">
|
|
<!-- File view -->
|
|
<div x-show="showFile && !showEditor" x-transition>
|
|
<div class="gw-file-toolbar">
|
|
<strong x-text="filePath || ''"></strong>
|
|
<span x-text="formatSize(fileSize)"></span>
|
|
<span style="flex:1;"></span>
|
|
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
|
|
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
|
</div>
|
|
<div class="gw-content">
|
|
<!-- Skeleton loading -->
|
|
<div x-show="fileLoading" class="skeleton-file">
|
|
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
|
|
<div class="skeleton" style="height:15px;width:50%;"></div>
|
|
</div>
|
|
<!-- Syntax-highlighted code -->
|
|
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Editor view -->
|
|
<template x-if="showEditor">
|
|
<div>
|
|
<div class="gw-file-toolbar">
|
|
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
|
|
<span style="flex:1;"></span>
|
|
<button class="btn" @click="showEditor=false">Cancel</button>
|
|
</div>
|
|
<div class="gw-content">
|
|
<textarea x-model="editContent"></textarea>
|
|
</div>
|
|
<div class="gw-commit-bar">
|
|
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
|
|
<datalist id="commit-history">
|
|
<template x-for="msg in commitHistory" :key="msg">
|
|
<option :value="msg">
|
|
</template>
|
|
</datalist>
|
|
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- File tree browser (like local-workspace) -->
|
|
<template x-if="!showFile && !showEditor && !showPrivate">
|
|
<div class="gw-content">
|
|
<!-- Breadcrumb -->
|
|
<div class="gw-breadcrumb" style="display:flex;align-items:center;gap:4px;margin-bottom:16px;font-size:14px;color:var(--text-secondary);">
|
|
<a href="#" @click.prevent="navigateToRoot()" style="color:var(--text-secondary);text-decoration:none;" x-text="owner + '/' + repo"></a>
|
|
<template x-for="(b, i) in folderStack" :key="i">
|
|
<span style="display:contents;">
|
|
<span style="color:var(--text-tertiary);">/</span>
|
|
<a href="#" @click.prevent="navigateToFolder(i)" style="color:var(--text-secondary);text-decoration:none;" x-text="b"></a>
|
|
</span>
|
|
</template>
|
|
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
|
</div>
|
|
|
|
<!-- Tree items -->
|
|
<div x-show="!treeLoading">
|
|
<template x-if="treeItems.length === 0">
|
|
<div class="empty-state">
|
|
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
|
<p>No files in this folder</p>
|
|
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="ws-tree">
|
|
<template x-for="item in sortedTreeItems" :key="item.path">
|
|
<div class="ws-tree-item"
|
|
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
|
@contextmenu.prevent="openGwContext($event, item)"
|
|
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
|
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
|
|
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
|
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Context menu -->
|
|
<div class="gw-context-menu"
|
|
x-show="gwCtx.visible"
|
|
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
|
|
@click.outside="gwCtx.visible = false"
|
|
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
|
|
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
|
|
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New file form -->
|
|
<div x-show="showNewFile" style="margin-top:16px;max-width:400px;">
|
|
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
|
|
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
|
|
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
|
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Private pages view -->
|
|
<template x-if="showPrivate && !editingPrivate">
|
|
<div class="gw-content">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
|
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
|
|
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
|
|
</div>
|
|
<template x-if="privatePages.length === 0">
|
|
<div class="empty-state" style="padding:40px 20px;">
|
|
<p>No private pages yet. Create one to store notes linked to this project.</p>
|
|
</div>
|
|
</template>
|
|
<template x-for="pp in privatePages" :key="pp.id">
|
|
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
|
|
@click="openPrivate(pp.id)">
|
|
<div>
|
|
<div style="font-weight:500;" x-text="pp.title"></div>
|
|
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
|
|
</div>
|
|
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Private page editor -->
|
|
<template x-if="editingPrivate">
|
|
<div>
|
|
<div class="gw-file-toolbar">
|
|
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
|
<span style="flex:1;"></span>
|
|
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
|
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
|
</div>
|
|
<div class="gw-content">
|
|
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
|
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{% endblock %}
|