455 lines
16 KiB
HTML
455 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Prism Notes Tasks</title>
|
|
<link rel="icon" type="image/png" href="/images/prism.png" />
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
.panel {
|
|
border-radius: 1rem;
|
|
border: 1px solid rgb(30 41 59 / 1);
|
|
background: rgb(15 23 42 / 0.78);
|
|
backdrop-filter: blur(8px);
|
|
padding: 1.25rem;
|
|
}
|
|
.btn {
|
|
border-radius: 0.75rem;
|
|
padding: 0.55rem 1rem;
|
|
font-weight: 600;
|
|
transition: all 160ms ease;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.btn:focus-visible,
|
|
.field:focus-visible {
|
|
outline: 2px solid rgb(34 211 238 / 0.8);
|
|
outline-offset: 1px;
|
|
}
|
|
.btn::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(120deg, transparent 0%, rgb(255 255 255 / 0.2) 48%, transparent 100%);
|
|
transform: translateX(-130%);
|
|
transition: transform 260ms ease;
|
|
pointer-events: none;
|
|
}
|
|
.btn:hover {
|
|
transform: translateY(-1px) scale(1.01);
|
|
box-shadow: 0 10px 22px rgb(15 23 42 / 0.28);
|
|
}
|
|
.btn:hover::before { transform: translateX(130%); }
|
|
.btn-accent-cyan {
|
|
color: rgb(8 47 73 / 1);
|
|
background: rgb(6 182 212 / 1);
|
|
}
|
|
.btn-accent-emerald {
|
|
color: rgb(6 44 32 / 1);
|
|
background: rgb(34 197 94 / 1);
|
|
}
|
|
.btn-secondary {
|
|
border: 1px solid rgb(51 65 85 / 1);
|
|
background: rgb(15 23 42 / 1);
|
|
color: rgb(226 232 240 / 1);
|
|
}
|
|
.btn-secondary:hover { border-color: rgb(100 116 139 / 1); }
|
|
.field {
|
|
width: 100%;
|
|
border-radius: 0.75rem;
|
|
border: 1px solid rgb(51 65 85 / 1);
|
|
background: rgb(2 6 23 / 1);
|
|
padding: 0.55rem 0.75rem;
|
|
font-size: 0.875rem;
|
|
color: rgb(248 250 252 / 1);
|
|
}
|
|
.field option {
|
|
background: rgb(15 23 42 / 0.96);
|
|
color: rgb(248 250 252 / 1);
|
|
}
|
|
.field::placeholder { color: rgb(100 116 139 / 1); }
|
|
.mono-pill {
|
|
border-radius: 0.5rem;
|
|
border: 1px solid rgb(51 65 85 / 1);
|
|
background: rgb(2 6 23 / 1);
|
|
padding: 0.15rem 0.5rem;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
font-size: 0.75rem;
|
|
color: rgb(191 219 254 / 1);
|
|
white-space: nowrap;
|
|
}
|
|
.task-cell {
|
|
border-top: 1px solid rgb(30 41 59 / 1);
|
|
padding: 0.65rem 0.5rem;
|
|
vertical-align: top;
|
|
font-size: 0.8rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-950 to-slate-900 text-slate-100">
|
|
<main class="mx-auto max-w-7xl px-6 py-10">
|
|
<header class="panel mb-6">
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
<div class="flex items-center gap-4">
|
|
<img src="/images/prism.png" alt="Prism Notes" class="h-14 w-14 rounded-xl object-cover ring-1 ring-slate-600/60" />
|
|
<div>
|
|
<p class="text-xs uppercase tracking-[0.3em] text-cyan-400">Prism Notes</p>
|
|
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Tasks View</h1>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-3">
|
|
<a href="/notes-workspace" class="btn btn-secondary">Open Notes Workspace</a>
|
|
<a href="/notes" class="btn btn-secondary">Open Legacy Notes</a>
|
|
<a href="/" class="btn btn-secondary">Back to Testing View</a>
|
|
</div>
|
|
</div>
|
|
<p class="mt-3 text-sm text-slate-300">Browse PocketBase <span class="mono-pill">Tasgird</span> tasks. Default filter is current user, with optional user switching.</p>
|
|
</header>
|
|
|
|
<section class="mb-6 grid gap-4 md:grid-cols-3">
|
|
<article class="panel">
|
|
<p class="text-xs uppercase tracking-wider text-slate-400">Server</p>
|
|
<p id="healthStatus" class="mt-2 text-sm font-medium">Checking...</p>
|
|
</article>
|
|
<article class="panel">
|
|
<p class="text-xs uppercase tracking-wider text-slate-400">PocketBase User</p>
|
|
<p id="pbStatus" class="mt-2 text-sm font-medium">Not logged in</p>
|
|
</article>
|
|
<article class="panel">
|
|
<p class="text-xs uppercase tracking-wider text-slate-400">Task Count</p>
|
|
<p id="taskCount" class="mt-2 text-sm font-medium">0</p>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="mb-6 grid gap-6 lg:grid-cols-2">
|
|
<article class="panel p-6">
|
|
<h2 class="text-xl font-medium">1) PocketBase Login</h2>
|
|
<p class="mt-2 text-sm text-slate-300">Uses your existing PocketBase Microsoft OAuth provider.</p>
|
|
<div class="mt-4 flex flex-wrap gap-3">
|
|
<button id="loginBtn" class="btn btn-accent-cyan">Login with Microsoft</button>
|
|
<button id="logoutBtn" class="btn btn-secondary">Logout</button>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel p-6">
|
|
<h2 class="text-xl font-medium">2) Task Filter</h2>
|
|
<p class="mt-2 text-sm text-slate-300">Defaults to your tasks, but you can select other users.</p>
|
|
<div class="mt-4 space-y-4">
|
|
<div>
|
|
<label class="text-sm font-medium text-slate-300">User</label>
|
|
<select id="taskUserSelect" class="field mt-1">
|
|
<option value="">— login to load users —</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex flex-wrap gap-3">
|
|
<button id="loadTasksBtn" class="btn btn-accent-emerald">Load Tasks</button>
|
|
<button id="refreshUsersBtn" class="btn btn-secondary">Refresh Users</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
<section class="panel p-6">
|
|
<h2 class="text-xl font-medium">Tasks (Tasgird)</h2>
|
|
<div class="mt-4 overflow-x-auto">
|
|
<table class="w-full min-w-[1300px] border-collapse">
|
|
<thead>
|
|
<tr class="text-left text-xs uppercase tracking-wider text-slate-400">
|
|
<th class="task-cell">id</th>
|
|
<th class="task-cell">user</th>
|
|
<th class="task-cell">title</th>
|
|
<th class="task-cell">startDate (UTC)</th>
|
|
<th class="task-cell">dueDate (UTC)</th>
|
|
<th class="task-cell">priority</th>
|
|
<th class="task-cell">completed</th>
|
|
<th class="task-cell">content</th>
|
|
<th class="task-cell">size</th>
|
|
<th class="task-cell">status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tasksBody">
|
|
<tr><td class="task-cell text-slate-400" colspan="10">No tasks loaded.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel p-6 mt-6">
|
|
<h2 class="text-xl font-medium">Output</h2>
|
|
<pre id="output" class="mt-4 min-h-[180px] overflow-auto rounded-xl border border-slate-800 bg-slate-950 p-4 text-xs leading-5 text-slate-200"></pre>
|
|
</section>
|
|
</main>
|
|
|
|
<script type="module">
|
|
import PocketBase from 'https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/+esm';
|
|
|
|
const PB_DEFAULT_AUTH_COLLECTION = 'Users';
|
|
const PB_DEFAULT_OAUTH_PROVIDER = 'microsoft';
|
|
|
|
let pb = null;
|
|
let pbConfigPromise = null;
|
|
let usersCache = [];
|
|
|
|
const output = document.getElementById('output');
|
|
const healthStatus = document.getElementById('healthStatus');
|
|
const pbStatus = document.getElementById('pbStatus');
|
|
const taskCount = document.getElementById('taskCount');
|
|
const taskUserSelect = document.getElementById('taskUserSelect');
|
|
const tasksBody = document.getElementById('tasksBody');
|
|
|
|
function writeOutput(value) {
|
|
output.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
}
|
|
|
|
function toUtcText(value) {
|
|
if (!value) return '';
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return String(value);
|
|
return d.toISOString();
|
|
}
|
|
|
|
function stripHtml(value) {
|
|
if (!value) return '';
|
|
const div = document.createElement('div');
|
|
div.innerHTML = String(value);
|
|
return (div.textContent || div.innerText || '').trim();
|
|
}
|
|
|
|
async function getPocketBaseConfig() {
|
|
if (!pbConfigPromise) {
|
|
pbConfigPromise = fetch('/api/auth/pocketbase-config')
|
|
.then(async (resp) => {
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !data?.success || !data?.pbUrl) {
|
|
throw new Error(data?.message || 'PocketBase config unavailable');
|
|
}
|
|
return {
|
|
pbUrl: data.pbUrl,
|
|
collection: data.collection || PB_DEFAULT_AUTH_COLLECTION,
|
|
provider: data.provider || PB_DEFAULT_OAUTH_PROVIDER,
|
|
};
|
|
})
|
|
.catch((error) => {
|
|
pbConfigPromise = null;
|
|
throw error;
|
|
});
|
|
}
|
|
return pbConfigPromise;
|
|
}
|
|
|
|
async function ensurePocketBaseClient() {
|
|
if (pb) return pb;
|
|
const config = await getPocketBaseConfig();
|
|
pb = new PocketBase(config.pbUrl);
|
|
return pb;
|
|
}
|
|
|
|
async function updatePbStatus() {
|
|
const client = await ensurePocketBaseClient();
|
|
if (client.authStore?.isValid) {
|
|
const email = client.authStore.model?.email || '(unknown user)';
|
|
pbStatus.textContent = `Logged in: ${email}`;
|
|
} else {
|
|
pbStatus.textContent = 'Not logged in';
|
|
}
|
|
}
|
|
|
|
async function loadHealth() {
|
|
try {
|
|
const resp = await fetch('/health');
|
|
const data = await resp.json().catch(() => ({}));
|
|
healthStatus.textContent = resp.ok ? `OK (${data.pbDB || 'configured'})` : 'Unavailable';
|
|
} catch {
|
|
healthStatus.textContent = 'Unavailable';
|
|
}
|
|
}
|
|
|
|
async function loginPocketBase() {
|
|
writeOutput('Opening Microsoft login...');
|
|
const client = await ensurePocketBaseClient();
|
|
const config = await getPocketBaseConfig();
|
|
const authData = await client.collection(config.collection).authWithOAuth2({
|
|
provider: config.provider,
|
|
urlCallback(url) {
|
|
const w = Math.min(900, window.screen.availWidth || 900);
|
|
const h = Math.min(680, window.screen.availHeight || 680);
|
|
const left = Math.floor(((window.screen.availWidth || 1280) - w) / 2);
|
|
const top = Math.floor(((window.screen.availHeight || 800) - h) / 2);
|
|
window.open(url, 'pb_oauth', `width=${w},height=${h},top=${top},left=${left},resizable=yes,menubar=no`);
|
|
},
|
|
});
|
|
|
|
await updatePbStatus();
|
|
await loadUsers();
|
|
writeOutput({ message: 'PocketBase login completed.', user: authData?.record?.email || client.authStore.model?.email || null });
|
|
}
|
|
|
|
async function logoutPocketBase() {
|
|
const client = await ensurePocketBaseClient();
|
|
client.authStore.clear();
|
|
usersCache = [];
|
|
taskUserSelect.innerHTML = '<option value="">— login to load users —</option>';
|
|
tasksBody.innerHTML = '<tr><td class="task-cell text-slate-400" colspan="10">No tasks loaded.</td></tr>';
|
|
taskCount.textContent = '0';
|
|
await updatePbStatus();
|
|
writeOutput({ message: 'PocketBase session cleared.' });
|
|
}
|
|
|
|
function getCurrentToken() {
|
|
if (!pb?.authStore?.isValid || !pb?.authStore?.token) {
|
|
throw new Error('PocketBase login required');
|
|
}
|
|
return pb.authStore.token;
|
|
}
|
|
|
|
async function loadUsers() {
|
|
await ensurePocketBaseClient();
|
|
const token = getCurrentToken();
|
|
const resp = await fetch('/api/tasks/users', {
|
|
headers: { 'x-pb-token': token },
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !data?.success) {
|
|
throw new Error(data?.message || 'Failed to load users');
|
|
}
|
|
|
|
usersCache = Array.isArray(data.users) ? data.users : [];
|
|
const meId = String(data?.me?.id || '').trim();
|
|
const meName = data?.me?.name || data?.me?.email || 'Me';
|
|
const meLookupName = String(data?.me?.lookupName || data?.me?.name || '').trim();
|
|
|
|
taskUserSelect.innerHTML = '';
|
|
for (const user of usersCache) {
|
|
const opt = document.createElement('option');
|
|
const lookupName = String(user.lookupName || user.name || '').trim();
|
|
if (!lookupName) {
|
|
continue;
|
|
}
|
|
opt.value = lookupName;
|
|
opt.dataset.userId = user.id;
|
|
opt.textContent = `${user.name || user.email || user.id}${user.id === meId ? ' (Me)' : ''}`;
|
|
taskUserSelect.appendChild(opt);
|
|
}
|
|
|
|
if (meLookupName) {
|
|
taskUserSelect.value = meLookupName;
|
|
}
|
|
|
|
writeOutput({ message: `Loaded ${usersCache.length} users`, me: { id: meId, name: meName, lookupName: meLookupName } });
|
|
if (data?.warning) {
|
|
writeOutput({ message: `Loaded ${usersCache.length} users`, warning: data.warning, me: { id: meId, name: meName, lookupName: meLookupName } });
|
|
}
|
|
}
|
|
|
|
function renderTasks(tasks) {
|
|
const rows = Array.isArray(tasks) ? tasks : [];
|
|
taskCount.textContent = String(rows.length);
|
|
|
|
if (!rows.length) {
|
|
tasksBody.innerHTML = '<tr><td class="task-cell text-slate-400" colspan="10">No tasks found for selected user.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tasksBody.innerHTML = '';
|
|
for (const task of rows) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td class="task-cell"><span class="mono-pill">${task.id || ''}</span></td>
|
|
<td class="task-cell">${task.userDisplay || task.user || ''}</td>
|
|
<td class="task-cell">${task.title || ''}</td>
|
|
<td class="task-cell">${toUtcText(task.startDate) || ''}</td>
|
|
<td class="task-cell">${toUtcText(task.dueDate) || ''}</td>
|
|
<td class="task-cell">${task.priority ?? ''}</td>
|
|
<td class="task-cell">${task.completed ? 'true' : 'false'}</td>
|
|
<td class="task-cell">${stripHtml(task.content || '').slice(0, 240)}</td>
|
|
<td class="task-cell">${task.size ?? ''}</td>
|
|
<td class="task-cell">${task.status ?? ''}</td>
|
|
`;
|
|
tasksBody.appendChild(tr);
|
|
}
|
|
}
|
|
|
|
async function loadTasks() {
|
|
await ensurePocketBaseClient();
|
|
const token = getCurrentToken();
|
|
const userName = String(taskUserSelect.value || '').trim();
|
|
const selectedOption = taskUserSelect.selectedOptions?.[0] || null;
|
|
const userId = String(selectedOption?.dataset?.userId || '').trim();
|
|
const query = new URLSearchParams();
|
|
if (userName) query.set('userName', userName);
|
|
if (userId) query.set('userId', userId);
|
|
const url = query.toString()
|
|
? `/api/tasks/list?${query.toString()}`
|
|
: '/api/tasks/list';
|
|
const resp = await fetch(url, {
|
|
headers: { 'x-pb-token': token },
|
|
});
|
|
const data = await resp.json().catch(() => ({}));
|
|
if (!resp.ok || !data?.success) {
|
|
throw new Error(data?.message || 'Failed to load tasks');
|
|
}
|
|
if (data?.userName && taskUserSelect.value !== data.userName) {
|
|
taskUserSelect.value = data.userName;
|
|
}
|
|
renderTasks(data.tasks || []);
|
|
writeOutput({
|
|
message: `Loaded ${data?.count ?? 0} task(s)`,
|
|
userName: data?.userName || userName,
|
|
userId: data?.userId || undefined,
|
|
warning: data?.warning || undefined,
|
|
tasks: data.tasks || [],
|
|
});
|
|
}
|
|
|
|
async function bootstrapTasksPage() {
|
|
try {
|
|
const client = await ensurePocketBaseClient();
|
|
const config = await getPocketBaseConfig();
|
|
|
|
if (client.authStore?.token) {
|
|
try {
|
|
await client.collection(config.collection).authRefresh();
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
await updatePbStatus();
|
|
if (client.authStore?.isValid) {
|
|
await loadUsers();
|
|
await loadTasks();
|
|
}
|
|
} catch (error) {
|
|
writeOutput({ error: error?.message || String(error) });
|
|
}
|
|
}
|
|
|
|
function bind(id, handler) {
|
|
document.getElementById(id).addEventListener('click', async () => {
|
|
try {
|
|
await handler();
|
|
} catch (error) {
|
|
writeOutput({ error: error?.message || String(error) });
|
|
}
|
|
});
|
|
}
|
|
|
|
bind('loginBtn', loginPocketBase);
|
|
bind('logoutBtn', logoutPocketBase);
|
|
bind('refreshUsersBtn', loadUsers);
|
|
bind('loadTasksBtn', loadTasks);
|
|
|
|
taskUserSelect.addEventListener('change', async () => {
|
|
try {
|
|
await loadTasks();
|
|
} catch (error) {
|
|
writeOutput({ error: error?.message || String(error) });
|
|
}
|
|
});
|
|
|
|
bootstrapTasksPage();
|
|
loadHealth();
|
|
</script>
|
|
</body>
|
|
</html>
|