app/routers/library.py: /api/library/recents|favorites|shared|published|private|workspace
app/routers/sharing.py: /api/pages/{id}/share|publish + page_shares
app/templates/base.html: OAuth badge 🦎/🐙, '→ Library' buttons
app/templates/library.html: page avec tabs + filtres source
app/routers/dashboard.py: auth_method + github_linked dans context
tests/test_app.py: tests library + sharing + recents
352 lines
10 KiB
HTML
352 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_icon %}📚{% endblock %}
|
|
{% block page_title %}Library{% endblock %}
|
|
{% block title_prefix %}Library{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-title-area">
|
|
<div class="page-title">
|
|
<span class="page-icon-lg">📚</span>
|
|
<h1>Library</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-data="libraryPage()" style="padding: 0 24px;">
|
|
|
|
<!-- ── Tab bar ── -->
|
|
<div class="view-tabs">
|
|
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
|
|
<span class="tab-icon">🕒</span> Recents
|
|
</button>
|
|
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
|
|
<span class="tab-icon">⭐</span> Favorites
|
|
</button>
|
|
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="switchTab('shared')">
|
|
<span class="tab-icon">👥</span> Shared
|
|
</button>
|
|
<button class="view-tab" :class="{ active: tab === 'published' }" @click="switchTab('published')">
|
|
<span class="tab-icon">🌐</span> Published
|
|
</button>
|
|
<button class="view-tab" :class="{ active: tab === 'private' }" @click="switchTab('private')">
|
|
<span class="tab-icon">🔒</span> Private
|
|
</button>
|
|
<button class="view-tab" :class="{ active: tab === 'workspace' }" @click="switchTab('workspace')">
|
|
<span class="tab-icon">📁</span> Workspace
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── Toolbar with source_type filter ── -->
|
|
<div class="view-toolbar">
|
|
<div class="source-filter-group">
|
|
<button class="source-filter-btn" :class="{ active: sourceType === 'all' }" @click="setSourceType('all')">All</button>
|
|
<button class="source-filter-btn" :class="{ active: sourceType === 'local' }" @click="setSourceType('local')">Local</button>
|
|
<button class="source-filter-btn" :class="{ active: sourceType === 'gitea' }" @click="setSourceType('gitea')">Gitea</button>
|
|
<button class="source-filter-btn" :class="{ active: sourceType === 'github' }" @click="setSourceType('github')">GitHub</button>
|
|
</div>
|
|
<div class="view-toolbar-spacer"></div>
|
|
<button class="toolbar-btn" title="Search">🔍</button>
|
|
<button class="btn-new" @click="newPage()">New page</button>
|
|
</div>
|
|
|
|
<!-- ── Loading state ── -->
|
|
<div x-show="loading" class="lib-loading">
|
|
<div class="spinner"></div>
|
|
<p>Loading…</p>
|
|
</div>
|
|
|
|
<!-- ── Card grid ── -->
|
|
<div x-show="!loading" class="library-card-grid">
|
|
<template x-for="item in items" :key="item.id">
|
|
<div class="library-card" @click="openItem(item)">
|
|
<!-- Icon -->
|
|
<div class="library-card-icon" x-text="item.icon"></div>
|
|
<!-- Info -->
|
|
<div class="library-card-info">
|
|
<div class="library-card-title" x-text="item.title"></div>
|
|
<div class="library-card-meta">
|
|
<span class="source-badge" :class="'source-' + item.source_type" x-text="item.source_badge"></span>
|
|
<span class="library-card-workspace" x-show="item.workspace_name && item.workspace_name !== 'Private'" x-text="item.workspace_name"></span>
|
|
<span class="library-card-date" x-text="formatDate(item.updated_at)"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- ── Empty state ── -->
|
|
<div x-show="!loading && items.length === 0" class="lib-empty">
|
|
<div style="font-size:48px;margin-bottom:16px;" x-text="emptyIcon"></div>
|
|
<h3 x-text="emptyTitle"></h3>
|
|
<p x-text="emptyText"></p>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<style>
|
|
/* ── Source filter pill buttons ── */
|
|
.source-filter-group {
|
|
display: flex;
|
|
gap: 2px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
padding: 2px;
|
|
}
|
|
.source-filter-btn {
|
|
padding: 4px 12px;
|
|
border-radius: 5px;
|
|
font-size: 13px;
|
|
color: var(--text-dim);
|
|
background: transparent;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.source-filter-btn:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
.source-filter-btn.active {
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
box-shadow: 0 1px 2px rgba(0,0,0,.2);
|
|
}
|
|
|
|
/* ── Card grid ── */
|
|
.library-card-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
.library-card {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 14px 16px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
}
|
|
.library-card:hover {
|
|
background: var(--bg-tertiary);
|
|
border-color: var(--border-light);
|
|
}
|
|
.library-card-icon {
|
|
font-size: 24px;
|
|
line-height: 1;
|
|
flex-shrink: 0;
|
|
margin-top: 2px;
|
|
}
|
|
.library-card-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.library-card-title {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
margin-bottom: 6px;
|
|
}
|
|
.library-card-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.source-badge {
|
|
display: inline-block;
|
|
padding: 1px 8px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
.source-local {
|
|
background: rgba(100, 180, 100, 0.15);
|
|
color: #64b464;
|
|
}
|
|
.source-gitea {
|
|
background: rgba(100, 160, 220, 0.15);
|
|
color: #64a0dc;
|
|
}
|
|
.source-github {
|
|
background: rgba(180, 140, 220, 0.15);
|
|
color: #b48cdc;
|
|
}
|
|
.library-card-workspace {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.library-card-date {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin-left: auto;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Loading spinner ── */
|
|
.lib-loading {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 64px 0;
|
|
color: var(--text-dim);
|
|
gap: 16px;
|
|
}
|
|
.spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--text-dim);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
function libraryPage() {
|
|
return {
|
|
tab: 'recents',
|
|
sourceType: 'all',
|
|
items: [],
|
|
loading: false,
|
|
|
|
get emptyIcon() {
|
|
const icons = {
|
|
recents: '🕒', favorites: '⭐', shared: '👥',
|
|
published: '🌐', private: '🔒', workspace: '📁'
|
|
};
|
|
return icons[this.tab] || '📄';
|
|
},
|
|
get emptyTitle() {
|
|
const titles = {
|
|
recents: 'No recent pages',
|
|
favorites: 'No favorites',
|
|
shared: 'No shared pages',
|
|
published: 'No published pages',
|
|
private: 'No private pages',
|
|
workspace: 'No workspace pages'
|
|
};
|
|
return titles[this.tab] || 'No items';
|
|
},
|
|
get emptyText() {
|
|
const texts = {
|
|
recents: 'Pages you\'ve recently viewed or edited will appear here.',
|
|
favorites: 'Right-click a page in the sidebar and select "Add to Favorites".',
|
|
shared: 'Shared pages from your team will appear here.',
|
|
published: 'Pages published to the web will appear here.',
|
|
private: 'Create a new page with the + button in the sidebar.',
|
|
workspace: 'Pages inside workspaces will appear here.'
|
|
};
|
|
return texts[this.tab] || '';
|
|
},
|
|
|
|
init() {
|
|
// Read ?tab= from URL
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlTab = params.get('tab');
|
|
if (urlTab && ['recents','favorites','shared','published','private','workspace'].includes(urlTab)) {
|
|
this.tab = urlTab;
|
|
}
|
|
const urlSource = params.get('source_type');
|
|
if (urlSource && ['all','local','gitea','github'].includes(urlSource)) {
|
|
this.sourceType = urlSource;
|
|
}
|
|
this.loadTab();
|
|
},
|
|
|
|
switchTab(t) {
|
|
if (this.tab === t) return;
|
|
this.tab = t;
|
|
this.items = [];
|
|
this.updateUrl();
|
|
this.loadTab();
|
|
},
|
|
|
|
setSourceType(st) {
|
|
if (this.sourceType === st) return;
|
|
this.sourceType = st;
|
|
this.items = [];
|
|
this.updateUrl();
|
|
this.loadTab();
|
|
},
|
|
|
|
updateUrl() {
|
|
const params = new URLSearchParams();
|
|
if (this.tab !== 'recents') params.set('tab', this.tab);
|
|
if (this.sourceType !== 'all') params.set('source_type', this.sourceType);
|
|
const qs = params.toString();
|
|
const newUrl = qs ? `/library?${qs}` : '/library';
|
|
window.history.replaceState({}, '', newUrl);
|
|
},
|
|
|
|
async loadTab() {
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch(`/api/library/${this.tab}?source_type=${this.sourceType}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
this.items = data.items || [];
|
|
} catch (e) {
|
|
console.error('Library load error:', e);
|
|
this.items = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
openItem(item) {
|
|
if (!item || !item.url) return;
|
|
window.location.href = item.url;
|
|
},
|
|
|
|
formatDate(d) {
|
|
if (!d) return '';
|
|
try {
|
|
const date = new Date(d);
|
|
if (isNaN(date.getTime())) return d;
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return 'Just now';
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 7) return `${days}d ago`;
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
} catch {
|
|
return d;
|
|
}
|
|
},
|
|
|
|
newPage() {
|
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch('/board/api/pages?section=Private', {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
|
})
|
|
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
|
|
.then(data => { window.location.href = `/pages/${data.id}`; })
|
|
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|