workspaces.html: - Section 📁 Local Workspaces (existante, avec badges) - Section 🔗 Gitea Projects (par organisation, lazy load) - États: loading, not_linked (lien pour login OAuth), error (retry), ok gitea_workspace.html: - Arbre fichiers (lazy: un dossier à la fois) - Vue fichier (lecture seule → bouton Edit) - Éditeur avec zone de commit (message + historique dropdown) - Section 🔒 Private Pages (placeholder pour pages FlowDeck locales) - Bouton Delete fichier Routing: /gitea-workspace?owner=X&repo=Y Fallback: token admin serveur si pas de token OAuth utilisateur
341 lines
14 KiB
HTML
341 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
{% block page_title %}Gitea Workspace{% endblock %}
|
||
{% block page_icon %}🔗{% endblock %}
|
||
|
||
{% block topbar %}
|
||
<header class="topbar">
|
||
<button class="hamburger-btn" @click="mobileSidebarOpen = true; sidebarCollapsed = false;" title="Menu">
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
</button>
|
||
<div class="topbar-left">
|
||
<a href="/workspaces" class="breadcrumb" style="color:rgba(255,255,255,.5);text-decoration:none;">Home</a>
|
||
<span class="page-title-topbar" x-text="owner + '/' + repo"></span>
|
||
</div>
|
||
<div class="topbar-right">
|
||
<button class="page-action-btn" @click="refresh()" title="Refresh">🔄</button>
|
||
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
|
||
</div>
|
||
</header>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
.gw-layout{display:flex;height:calc(100vh - 48px);}
|
||
.gw-tree{width:280px;min-width:280px;background:var(--bg-sidebar);border-right:1px solid var(--border);overflow-y:auto;padding:12px 0;}
|
||
.gw-tree-item{display:flex;align-items:center;gap:6px;padding:4px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.gw-tree-item:hover{background:var(--bg-tertiary);color:var(--text);}
|
||
.gw-tree-item.selected{background:var(--accent);color:#fff;}
|
||
.gw-tree-icon{font-size:14px;flex-shrink:0;}
|
||
.gw-tree-indent{padding-left:32px !important;}
|
||
.gw-tree-indent2{padding-left:48px !important;}
|
||
.gw-tree-section-title{font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;padding:8px 16px 4px;margin-top:8px;border-top:1px solid var(--border);}
|
||
.gw-main{flex:1;display:flex;flex-direction:column;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 .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-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);}
|
||
.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;}
|
||
.btn-primary{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;background:var(--accent);color:#fff;}
|
||
.btn-primary:hover{background:var(--accent-hover);}
|
||
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
|
||
.btn-danger:hover{background:rgba(255,80,80,.25);}
|
||
.btn{border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;border:1px solid var(--border);background:transparent;color:var(--text-dim);}
|
||
.btn:hover{background:var(--bg-tertiary);color:var(--text);}
|
||
.loading-spinner{text-align:center;padding:20px;color:var(--text-dim);}
|
||
</style>
|
||
|
||
<div class="gw-layout" x-data="giteaWorkspace()">
|
||
<!-- Tree sidebar -->
|
||
<div class="gw-tree">
|
||
<div class="gw-tree-section-title">📁 GITEA REPO</div>
|
||
|
||
<!-- Loading -->
|
||
<div class="loading-spinner" x-show="loading">Loading…</div>
|
||
|
||
<!-- Tree items (recursive rendering) -->
|
||
<template x-for="item in treeItems" :key="item.path">
|
||
<div>
|
||
<div class="gw-tree-item"
|
||
:class="{selected: item.path === selectedPath}"
|
||
:style="'padding-left:' + (16 + item.depth * 16) + 'px'"
|
||
@click="item.type==='folder' ? toggleFolder(item) : openFile(item)">
|
||
<span class="gw-tree-icon" x-text="item.type==='folder' ? (item.expanded ? '📂' : '📁') : '📄'"></span>
|
||
<span x-text="item.name" style="overflow:hidden;text-overflow:ellipsis;"></span>
|
||
</div>
|
||
<!-- children -->
|
||
<template x-if="item.expanded">
|
||
<template x-for="child in item.children" :key="child.path">
|
||
<div class="gw-tree-item"
|
||
:class="{selected: child.path === selectedPath}"
|
||
:style="'padding-left:' + (16 + child.depth * 16) + 'px'"
|
||
@click="child.type==='folder' ? toggleFolder(child) : openFile(child)">
|
||
<span class="gw-tree-icon" x-text="child.type==='folder' ? (child.expanded ? '📂' : '📁') : '📄'"></span>
|
||
<span x-text="child.name" style="overflow:hidden;text-overflow:ellipsis;"></span>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Private section -->
|
||
<div class="gw-tree-section-title">🔒 PRIVATE PAGES</div>
|
||
<div class="gw-tree-item" @click="selectedPath='--private--'; showEditor=false; showFile=false;">
|
||
<span class="gw-tree-icon">📝</span>
|
||
<span>My notes</span>
|
||
</div>
|
||
<div class="gw-tree-item" @click="newPrivatePage()" style="color:var(--accent);">
|
||
<span class="gw-tree-icon">➕</span>
|
||
<span>New page</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<div class="gw-main">
|
||
<!-- Private pages view -->
|
||
<template x-if="selectedPath==='--private--'">
|
||
<div class="gw-content">
|
||
<template x-if="privatePages.length === 0">
|
||
<div class="empty-state">
|
||
<h2>🔒 Private Pages</h2>
|
||
<p>Create FlowDeck pages attached to this Gitea project. These are stored locally, not in the repo.</p>
|
||
<button class="btn btn-primary" @click="newPrivatePage()" style="margin-top:12px;">+ New Page</button>
|
||
</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;"
|
||
@click="openPrivatePage(pp)">
|
||
<div style="font-weight:500;margin-bottom:4px;" x-text="pp.title"></div>
|
||
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? 'Updated ' + new Date(pp.updated_at).toLocaleDateString() : ''"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- File view -->
|
||
<template x-if="showFile && !showEditor">
|
||
<div>
|
||
<div class="gw-file-toolbar">
|
||
<strong x-text="currentFile?.name || ''"></strong>
|
||
<span x-text="formatSize(currentFile?.size || 0)"></span>
|
||
<span style="flex:1;"></span>
|
||
<button class="btn" @click="openEditor()">✏️ Edit</button>
|
||
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
|
||
</div>
|
||
<div class="gw-content">
|
||
<pre x-text="fileContent"></pre>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Editor view -->
|
||
<template x-if="showEditor">
|
||
<div>
|
||
<div class="gw-file-toolbar">
|
||
<strong>Editing: <span x-text="currentFile?.name || '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()">💾 Commit</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Empty state -->
|
||
<template x-if="!showFile && !showEditor && selectedPath!=='--private--'">
|
||
<div class="gw-content">
|
||
<div class="empty-state">
|
||
<h2>📁 <span x-text="owner + '/' + repo"></span></h2>
|
||
<p>Select a file from the tree to view it, or create a private page.</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function giteaWorkspace() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const owner = params.get('owner') || '';
|
||
const repo = params.get('repo') || '';
|
||
|
||
return {
|
||
owner, repo,
|
||
treeItems: [],
|
||
selectedPath: '',
|
||
currentFile: null,
|
||
fileContent: '',
|
||
showFile: false,
|
||
showEditor: false,
|
||
editContent: '',
|
||
commitMessage: 'Update via FlowDeck',
|
||
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
||
loading: true,
|
||
privatePages: [],
|
||
|
||
async init() {
|
||
await this.loadRoot();
|
||
await this.loadPrivatePages();
|
||
},
|
||
|
||
async refresh() {
|
||
this.treeItems = [];
|
||
this.loading = true;
|
||
await this.loadRoot();
|
||
},
|
||
|
||
async loadRoot() {
|
||
this.loading = true;
|
||
try {
|
||
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/tree`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
this.treeItems = (d.tree || []).map(item => ({
|
||
...item,
|
||
depth: 0,
|
||
expanded: false,
|
||
children: [],
|
||
}));
|
||
}
|
||
} catch(e) {}
|
||
this.loading = false;
|
||
},
|
||
|
||
async toggleFolder(item) {
|
||
if (item.expanded) {
|
||
item.expanded = false;
|
||
return;
|
||
}
|
||
if (item.children.length > 0) {
|
||
item.expanded = true;
|
||
return;
|
||
}
|
||
// Lazy load
|
||
try {
|
||
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/tree?path=${encodeURIComponent(item.path)}`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
item.children = (d.tree || []).map(child => ({
|
||
...child,
|
||
depth: item.depth + 1,
|
||
expanded: false,
|
||
children: [],
|
||
}));
|
||
}
|
||
item.expanded = true;
|
||
} catch(e) {}
|
||
},
|
||
|
||
async openFile(item) {
|
||
this.selectedPath = item.path;
|
||
this.currentFile = item;
|
||
this.showEditor = false;
|
||
this.showFile = true;
|
||
try {
|
||
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/file?path=${encodeURIComponent(item.path)}`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
this.fileContent = d.content || '';
|
||
} else {
|
||
this.fileContent = '[Error loading file]';
|
||
}
|
||
} catch(e) {
|
||
this.fileContent = '[Error loading file]';
|
||
}
|
||
},
|
||
|
||
openEditor() {
|
||
this.editContent = this.fileContent;
|
||
this.showEditor = true;
|
||
},
|
||
|
||
async saveFile() {
|
||
if (!this.currentFile) return;
|
||
const path = this.currentFile.path;
|
||
try {
|
||
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/file`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
path, content: this.editContent,
|
||
message: this.commitMessage,
|
||
sha: this.currentFile.sha,
|
||
})
|
||
});
|
||
if (r.ok) {
|
||
this.fileContent = this.editContent;
|
||
this.showEditor = false;
|
||
// Save commit message history
|
||
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 {
|
||
const d = await r.json();
|
||
alert('Failed to save: ' + (d.error || 'Unknown error'));
|
||
}
|
||
} catch(e) {
|
||
alert('Network error');
|
||
}
|
||
},
|
||
|
||
async deleteCurrentFile() {
|
||
if (!this.currentFile) return;
|
||
if (!confirm(`Delete ${this.currentFile.path} from ${this.owner}/${this.repo}?`)) return;
|
||
try {
|
||
const r = await fetch(`/api/gitea/projects/${this.owner}/${this.repo}/file?path=${encodeURIComponent(this.currentFile.path)}&sha=${encodeURIComponent(this.currentFile.sha)}&message=${encodeURIComponent('Delete via FlowDeck')}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (r.ok) {
|
||
this.showFile = false;
|
||
this.selectedPath = '';
|
||
await this.refresh();
|
||
}
|
||
} catch(e) {}
|
||
},
|
||
|
||
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() {
|
||
// Pages stored in FlowDeck DB, linked to this Gitea project
|
||
// For now, store them as local workspace pages with gitea_owner/gitea_repo
|
||
// TODO: Create a dedicated API endpoint for gitea private pages
|
||
},
|
||
|
||
newPrivatePage() {
|
||
// TODO: Create a new FlowDeck page linked to this Gitea project
|
||
alert('Private pages will be available in a future update. Stay tuned!');
|
||
},
|
||
|
||
openPrivatePage(pp) {
|
||
// TODO: Open FlowDeck page editor
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
{% endblock %}
|