feat: frontend Gitea — workspaces 2 sections + gitea workspace vue
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
This commit is contained in:
@@ -393,6 +393,20 @@ async def workspace_page(request: Request):
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
async def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
ctx = {**sidebar, "user": user}
|
||||
template = env.get_template("gitea_workspace.html")
|
||||
return template.render(**ctx)
|
||||
|
||||
|
||||
@router.get("/api/workspace/projects")
|
||||
async def list_workspace_projects(request: Request):
|
||||
"""List all projects: built-in + Gitea + GitHub."""
|
||||
|
||||
@@ -6,11 +6,12 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
|
||||
|
||||
def _require_gitea(request: Request):
|
||||
"""Return a per-user GiteaClient or raise 401."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
"""Return a per-user GiteaClient or fallback to admin token."""
|
||||
from app.services.gitea_client import get_user_gitea_client, GiteaClient
|
||||
client = get_user_gitea_client(request)
|
||||
if not client:
|
||||
raise HTTPException(status_code=401, detail="Gitea account not linked. Login with Gitea or connect in Settings.")
|
||||
# Fallback to server admin token
|
||||
client = GiteaClient()
|
||||
return client
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
{% 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 %}
|
||||
+155
-20
@@ -19,19 +19,33 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.ws-page{max-width:800px;margin:0 auto;padding:40px 24px;}
|
||||
.ws-page{max-width:1000px;margin:0 auto;padding:40px 24px;}
|
||||
.ws-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
|
||||
.ws-header h1{font-size:24px;}
|
||||
.ws-section{margin-bottom:40px;}
|
||||
.ws-section-title{font-size:14px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px;display:flex;align-items:center;gap:8px;}
|
||||
.ws-section-title .badge{font-size:11px;background:var(--bg-tertiary);color:var(--text-dim);padding:1px 8px;border-radius:10px;}
|
||||
|
||||
.ws-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;}
|
||||
.ws-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:background 150ms;}
|
||||
.ws-card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:20px;cursor:pointer;transition:background 150ms, border-color 150ms;}
|
||||
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
|
||||
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
|
||||
.ws-card-name{font-size:16px;font-weight:500;margin-bottom:4px;}
|
||||
.ws-card-name{font-size:15px;font-weight:500;margin-bottom:2px;}
|
||||
.ws-card-sub{font-size:12px;color:var(--text-tertiary);margin-bottom:4px;}
|
||||
.ws-card-count{font-size:12px;color:var(--text-tertiary);}
|
||||
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
|
||||
.ws-card:hover .ws-card-actions{opacity:1;}
|
||||
|
||||
/* Gitea org grouping */
|
||||
.gitea-org{margin-bottom:16px;}
|
||||
.gitea-org-header{font-size:14px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px;margin-bottom:8px;}
|
||||
.gitea-org-header img{border-radius:4px;}
|
||||
.gitea-org-avatar{width:24px;height:24px;border-radius:4px;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;font-size:14px;}
|
||||
.gitea-empty{padding:24px;text-align:center;color:var(--text-dim);font-size:14px;border:1px dashed var(--border);border-radius:var(--radius);}
|
||||
.gitea-connect{display:inline-block;margin-top:8px;color:var(--accent);cursor:pointer;font-size:13px;}
|
||||
.gitea-connect:hover{text-decoration:underline;}
|
||||
.gitea-loading{color:var(--text-dim);font-size:13px;padding:12px 0;}
|
||||
|
||||
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
|
||||
.dialog-box{background:#1F1F1F;border:1px solid var(--border);border-radius:18px;padding:24px;width:400px;max-width:90vw;}
|
||||
.dialog-box h3{margin-bottom:16px;}
|
||||
@@ -47,28 +61,85 @@
|
||||
.btn-danger:hover{background:rgba(255,80,80,.25);}
|
||||
.btn-new{border:1px dashed var(--border-strong);background:transparent;color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-height:120px;}
|
||||
.btn-new:hover{background:var(--bg-tertiary);color:var(--text);}
|
||||
.btn-sm{padding:4px 8px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-dim);cursor:pointer;}
|
||||
.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);}
|
||||
</style>
|
||||
|
||||
<div class="ws-page" x-data="workspacesPage()">
|
||||
|
||||
<div class="ws-header">
|
||||
<h1>🏠 Workspaces</h1>
|
||||
<button class="btn btn-primary" @click="showCreate=true">+ New Workspace</button>
|
||||
</div>
|
||||
|
||||
<div class="ws-grid">
|
||||
<template x-for="ws in workspaces" :key="ws.id">
|
||||
<div class="ws-card" :class="{active:ws.id===activeId}" @click="select(ws)">
|
||||
<div class="ws-card-name" x-text="ws.name"></div>
|
||||
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
|
||||
<div class="ws-card-actions" @click.stop>
|
||||
<button class="btn btn-secondary" style="padding:4px 8px;font-size:11px" @click="renameWs(ws)">✏️</button>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px" @click="deleteWs(ws)">🗑</button>
|
||||
<!-- ── Local Workspaces ── -->
|
||||
<div class="ws-section">
|
||||
<div class="ws-section-title">📁 My Workspaces <span class="badge" x-text="workspaces.length"></span></div>
|
||||
<div class="ws-grid">
|
||||
<template x-for="ws in workspaces" :key="'local-'+ws.id">
|
||||
<div class="ws-card" :class="{active:ws.id===activeId}" @click="selectLocal(ws)">
|
||||
<div class="ws-card-name" x-text="ws.name"></div>
|
||||
<div class="ws-card-count" x-text="ws.page_count+' page(s)'"></div>
|
||||
<div class="ws-card-actions" @click.stop>
|
||||
<button class="btn-sm" @click="renameWs(ws)">✏️</button>
|
||||
<button class="btn-sm" style="color:var(--danger);border-color:rgba(255,80,80,.3)" @click="deleteWs(ws)">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ws-card btn-new" @click="showCreate=true">
|
||||
<span style="font-size:32px">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Gitea Projects ── -->
|
||||
<div class="ws-section">
|
||||
<div class="ws-section-title">🔗 Gitea Projects <span class="badge" x-text="giteaTotal"></span></div>
|
||||
|
||||
<!-- Not linked -->
|
||||
<div class="gitea-empty" x-show="giteaStatus==='not_linked'">
|
||||
<div>Connect your Gitea account to see your projects.</div>
|
||||
<a class="gitea-connect" href="/auth/login?provider=gitea">🔗 Login with Gitea</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div class="gitea-loading" x-show="giteaStatus==='loading'">Loading Gitea projects…</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div class="gitea-empty" x-show="giteaStatus==='error'">
|
||||
<div>⚠ Could not load Gitea projects.</div>
|
||||
<button class="gitea-connect" @click="loadGitea()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Projects (grouped by org) -->
|
||||
<template x-for="group in giteaGroups" :key="group.org">
|
||||
<div class="gitea-org">
|
||||
<div class="gitea-org-header">
|
||||
<img x-show="group.avatar" :src="group.avatar" class="gitea-org-avatar" alt="">
|
||||
<span class="gitea-org-avatar" x-show="!group.avatar" x-text="group.org.charAt(0).toUpperCase()"></span>
|
||||
<span x-text="group.org"></span>
|
||||
</div>
|
||||
<div class="ws-grid">
|
||||
<template x-for="proj in group.projects" :key="proj.id">
|
||||
<div class="ws-card" @click="openGitea(proj)">
|
||||
<div class="ws-card-name" x-text="proj.name"></div>
|
||||
<div class="ws-card-sub">
|
||||
<span x-text="proj.owner?.login || group.org"></span>
|
||||
<span style="margin:0 4px">/</span>
|
||||
<span x-text="proj.name"></span>
|
||||
</div>
|
||||
<div class="ws-card-count">
|
||||
<span x-show="proj.private" style="margin-right:6px">🔒</span>
|
||||
<span x-text="proj.description || ''" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ws-card btn-new" @click="showCreate=true">
|
||||
<span style="font-size:32px">+</span>
|
||||
|
||||
<!-- No projects in orgs -->
|
||||
<div class="gitea-empty" x-show="giteaStatus==='ok' && giteaTotal==0">
|
||||
No Gitea projects found.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,23 +167,28 @@ function workspacesPage() {
|
||||
wsName: '',
|
||||
renameTarget: null,
|
||||
|
||||
// Gitea
|
||||
giteaStatus: 'loading', // loading|ok|not_linked|error
|
||||
giteaGroups: [],
|
||||
giteaTotal: 0,
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
await this.loadGitea();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const r = await fetch('/api/workspaces');
|
||||
const d = await r.json();
|
||||
this.workspaces = d.workspaces || [];
|
||||
this.activeId = d.active_id;
|
||||
},
|
||||
async select(ws) {
|
||||
|
||||
async selectLocal(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
|
||||
if (ws.page_count > 0) {
|
||||
window.location = '/local-workspace';
|
||||
} else {
|
||||
window.location = '/local-workspace';
|
||||
}
|
||||
window.location = '/local-workspace';
|
||||
},
|
||||
|
||||
async doCreate() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
@@ -124,11 +200,13 @@ function workspacesPage() {
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
renameWs(ws) {
|
||||
this.renameTarget = ws;
|
||||
this.wsName = ws.name;
|
||||
this.showRename = true;
|
||||
},
|
||||
|
||||
async doRename() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
@@ -141,10 +219,67 @@ function workspacesPage() {
|
||||
this.renameTarget = null;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {method:'DELETE'});
|
||||
await this.load();
|
||||
},
|
||||
|
||||
// ── Gitea ──
|
||||
|
||||
async loadGitea() {
|
||||
this.giteaStatus = 'loading';
|
||||
try {
|
||||
// Get orgs
|
||||
const orgsRes = await fetch('/api/gitea/orgs');
|
||||
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
|
||||
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
|
||||
const orgsData = await orgsRes.json();
|
||||
const orgs = orgsData.orgs || [];
|
||||
|
||||
// Fetch repos: user repos + org repos
|
||||
const groups = [];
|
||||
|
||||
// User repos (no org filter)
|
||||
try {
|
||||
const userRes = await fetch('/api/gitea/projects');
|
||||
if (userRes.ok) {
|
||||
const d = await userRes.json();
|
||||
const repos = d.projects || [];
|
||||
if (repos.length) {
|
||||
groups.push({org: 'Personal', avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: '~'}}))});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Org repos
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
if (d.projects && d.projects.length) {
|
||||
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
this.giteaGroups = groups;
|
||||
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||
this.giteaStatus = 'ok';
|
||||
} catch(e) {
|
||||
this.giteaStatus = 'error';
|
||||
}
|
||||
},
|
||||
|
||||
openGitea(proj) {
|
||||
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
|
||||
const repo = proj.name;
|
||||
if (owner && repo) {
|
||||
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user