Files
flowdeck/app/templates/workspaces.html
T
bruno 6f15ad3ad4
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
v2.8.0 WIP: create/upload files + commit history + labels sync
- POST /api/gitea/projects/{owner}/{repo}/upload (binary upload)
- get_file_commits() in GiteaClient
- GET /api/gitea/projects/{owner}/{repo}/commits (per-file history)
- POST .../sync-labels (Gitea labels → FlowDeck tags)
- New file form + upload button in gitea_workspace.html topbar
2026-07-13 22:33:47 -04:00

357 lines
16 KiB
HTML

{% extends "base.html" %}
{% block page_title %}Workspaces{% 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">
<span class="breadcrumb" style="color:rgba(255,255,255,.5)">Home</span>
<span class="page-title-topbar">Workspaces</span>
</div>
<div class="topbar-right">
<button class="page-action-btn" onclick="window.location='/accounts/settings'" title="Settings">⚙</button>
</div>
</header>
{% endblock %}
{% block content %}
<style>
.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, 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: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;}
.dialog-input{width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px;outline:none;}
.dialog-input:focus{border-color:var(--accent);}
.dialog-actions{display:flex;gap:8px;justify-content:flex-end;}
.btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-secondary{background:var(--bg-tertiary);color:var(--text);}
.btn-secondary:hover{background:var(--bg-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
.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);}
/* Org tabs */
.org-tab{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:13px;color:var(--text-dim);background:transparent;border:1px solid var(--border);cursor:pointer;white-space:nowrap;transition:all 100ms;}
.org-tab:hover{background:var(--bg-tertiary);color:var(--text);}
.org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);}
.search-input:focus{border-color:var(--accent);}
/* ── Skeleton loading animation ── */
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
</style>
<div class="ws-page" x-data="workspacesPage()">
<div class="ws-header">
<h1>🏠 Workspaces</h1>
</div>
<!-- ── 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">🔗 Connect Gitea</a>
<span style="margin:0 8px;color:var(--text-dim);">or</span>
<a class="gitea-connect" href="/auth/login?provider=gitea">📝 Register with Gitea</a>
</div>
<!-- Loading skeletons -->
<div x-show="giteaStatus==='loading'" class="ws-grid">
<template x-for="i in 6" :key="i">
<div class="ws-card" style="pointer-events:none;">
<div class="skeleton" style="height:16px;width:65%;margin-bottom:8px;"></div>
<div class="skeleton" style="height:12px;width:45%;margin-bottom:6px;"></div>
<div class="skeleton" style="height:12px;width:80%;"></div>
</div>
</template>
</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 (tabs by org + search) -->
<div x-show="giteaStatus==='ok' && giteaGroups.length > 0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
<!-- Org tabs -->
<div style="display:flex;gap:4px;overflow-x:auto;flex:1;min-width:0;">
<button class="org-tab" :class="{active:activeOrg==='all'}" @click="activeOrg='all'">
All <span class="org-tab-badge" x-text="giteaTotal"></span>
</button>
<template x-for="g in giteaGroups" :key="g.org">
<button class="org-tab" :class="{active:activeOrg===g.org}" @click="activeOrg=g.org">
<span x-text="g.org"></span>
<span class="org-tab-badge" x-text="g.projects.length"></span>
</button>
</template>
</div>
<!-- Search -->
<input type="text" class="search-input" x-model="giteaSearch" placeholder="🔍 Filter projects…" style="width:200px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none;">
</div>
<!-- Project cards (filtered) -->
<template x-for="group in filteredGroups" :key="group.org">
<div class="gitea-org">
<div class="gitea-org-header">
<span class="gitea-org-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="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 || proj.language || ''" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px;"></span>
</div>
</div>
</template>
</div>
</div>
</template>
<div class="gitea-empty" x-show="filteredGroups.length===0" style="margin-top:12px;">
No projects match your search.
</div>
</div>
<!-- No projects at all -->
<div class="gitea-empty" x-show="giteaStatus==='ok' && giteaTotal==0 && giteaGroups.length===0">
No Gitea projects found.
</div>
</div>
<!-- Create/Rename dialog -->
<div class="dialog-overlay" x-show="showCreate||showRename" @click.away="showCreate=false;showRename=false">
<div class="dialog-box">
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
<div class="dialog-actions">
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
</div>
</div>
</div>
</div>
<script>
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
wsName: '',
renameTarget: null,
// Gitea
giteaStatus: 'loading', // loading|ok|not_linked|error
giteaGroups: [],
giteaTotal: 0,
activeOrg: 'all',
giteaSearch: '',
get filteredGroups() {
var self = this;
var q = (this.giteaSearch || '').toLowerCase();
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
if (!q) return groups;
return groups.map(function(g) {
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
})};
}).filter(function(g) { return g.projects.length > 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 selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {method:'POST'});
window.location = '/local-workspace';
},
async doCreate() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
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}`, {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showRename = false;
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 = [];
// Get username for the "personal" tab
let myLogin = 'Me';
try {
const meRes = await fetch('/auth/user');
if (meRes.ok) {
const meData = await meRes.json();
myLogin = meData.user?.login || 'Me';
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
}
} catch(e) {}
// 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: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
}
}
} 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) {
// Set workspace cookie so sidebar shows tree
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
}
};
}
</script>
{% endblock %}