From dd1afbbeb28effc4c2d44b6caee1e0e63da53164 Mon Sep 17 00:00:00 2001 From: th Date: Fri, 10 Apr 2026 14:46:50 +0200 Subject: [PATCH] gitea plug --- public/plugins/gitea/GiteaClient.php | 74 +++ public/plugins/gitea/TODO.md | 70 ++- public/plugins/gitea/backend.php | 365 +++++++++++ public/plugins/gitea/gitea.css | 112 ++++ public/plugins/gitea/gitea.html | 157 ++++- public/plugins/gitea/gitea.js | 902 +++++++++++++++++++++++++++ 6 files changed, 1650 insertions(+), 30 deletions(-) diff --git a/public/plugins/gitea/GiteaClient.php b/public/plugins/gitea/GiteaClient.php index 9035cc6..fbc6949 100644 --- a/public/plugins/gitea/GiteaClient.php +++ b/public/plugins/gitea/GiteaClient.php @@ -85,6 +85,80 @@ class GiteaClient return is_array($resp) ? $resp : []; } + /** Post a new comment on an issue. Returns the created comment object. */ + public function createIssueComment(string $owner, string $repo, int $index, string $body): array + { + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/comments'; + $resp = $this->request('POST', $path, [], ['body' => $body]); + return is_array($resp) ? $resp : []; + } + + /** + * Edit an issue. Pass any subset of: title, body, state ('open'|'closed'), + * assignees (string[]), milestone (int), due_date (RFC3339), unset_due_date (bool). + * Returns the updated issue. + */ + public function editIssue(string $owner, string $repo, int $index, array $changes): array + { + $allowed = ['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'ref', 'unset_due_date']; + $payload = []; + foreach ($allowed as $k) { + if (array_key_exists($k, $changes)) $payload[$k] = $changes[$k]; + } + if (!$payload) return []; + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index; + $resp = $this->request('PATCH', $path, [], $payload); + return is_array($resp) ? $resp : []; + } + + /** + * Replace the labels of an issue. $labels is an array of label IDs (ints). + * Returns the resulting list of labels. + */ + public function replaceIssueLabels(string $owner, string $repo, int $index, array $labels): array + { + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues/' . $index . '/labels'; + $resp = $this->request('PUT', $path, [], ['labels' => array_map('intval', $labels)]); + return is_array($resp) ? $resp : []; + } + + /** Fetch the available labels for a repository. */ + public function listRepoLabels(string $owner, string $repo, int $page = 1, int $limit = 100): array + { + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/labels'; + $resp = $this->request('GET', $path, ['page' => $page, 'limit' => $limit]); + return is_array($resp) ? $resp : []; + } + + /** + * Create a new issue in a repository. Pass any of: title (required), + * body, assignees (string[]), labels (int[] of label IDs), milestone (int), + * due_date (RFC3339), ref, closed (bool). + * Returns the created issue. + */ + public function createIssue(string $owner, string $repo, array $opts): array + { + if (empty($opts['title'])) return []; + $allowed = ['title', 'body', 'assignees', 'labels', 'milestone', 'due_date', 'ref', 'closed']; + $payload = []; + foreach ($allowed as $k) { + if (array_key_exists($k, $opts) && $opts[$k] !== null) $payload[$k] = $opts[$k]; + } + $path = '/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/issues'; + $resp = $this->request('POST', $path, [], $payload); + return is_array($resp) ? $resp : []; + } + + /** + * List repositories the authenticated user has access to. + * Used to populate the repo picker for "new issue". + */ + public function listMyRepos(int $page = 1, int $limit = 100): array + { + $resp = $this->request('GET', '/user/repos', ['page' => $page, 'limit' => $limit]); + return is_array($resp) ? $resp : []; + } + // ── Low-level request ───────────────────────────────────────── private function request(string $method, string $path, array $query = [], ?array $payload = null) diff --git a/public/plugins/gitea/TODO.md b/public/plugins/gitea/TODO.md index fe96d64..b7ddd24 100644 --- a/public/plugins/gitea/TODO.md +++ b/public/plugins/gitea/TODO.md @@ -15,42 +15,64 @@ - [x] Welcome view + accounts management form - [x] Connection test against `/api/v1/user` -## Phase 2 — issue reader (next) +## Phase 2 — issue reader ✅ -- [ ] **Issue list view** in the overlay: - - Search box (`q` parameter) +- [x] **Issue list view** in the overlay: + - Search box (`q` parameter, debounced 350ms) - State filter (open/closed/all) - - Owner/team filter (optional dropdown) - - Pagination (reuse pattern from email plugin) + - Pagination (prev/next, hasMore heuristic since Gitea doesn't always return total) - Each row: title, repo path, state badge, labels, author, age -- [ ] **Issue detail view** (right pane): - - Subject + state + labels + assignees + milestone - - Body (markdown rendered with the existing core renderer) - - Comments list (each with author + timestamp) +- [x] **Issue detail view** (right pane): + - Subject + state badge + labels + #number + repo + - Body (markdown rendered via `hyprcrd.markdown.render` — exposed by core) + - Comments list (each with author + relative timestamp + rendered markdown) - "Open on Gitea" link button -- [ ] **Extract → card**: - - Card title = issue title - - Card body = markdown with metadata header + body + comments + `#gitea` `#issue` tags - - Optionally store backref `(account_id, owner, repo, index)` in a - `gitea_issue_links` table for later sync +- [x] **Extract → card**: + - Card title = `[repo#number] title` + - Card body = metadata header + body + comments + `#gitea #issue` tags + - Color: green (#22c55e) +- [ ] **Issue → card backlink** in a `gitea_issue_links` table — needed + for Phase 5 bidirectional sync ## Phase 3 — write operations -- [ ] **Add comment** from the detail view -- [ ] **Close / reopen** issue -- [ ] **Edit title / body** (only for the issue creator) -- [ ] **Set labels** (need to fetch repo labels, multi-select UI) -- [ ] **Set assignees** (typeahead from repo collaborators) -- [ ] **Set milestone** -- [ ] **Set due date** +- [x] **Add comment** — composer textarea always visible at the bottom of the + detail pane. Ctrl/Cmd+Enter posts. New comment is appended to local state + and re-rendered immediately. +- [x] **Close / reopen** — single button in the action bar that toggles state. + Updates the badge, button label, and the row in the list. +- [x] **Edit title** — pencil icon next to the title swaps to an inline input. + Enter saves, Escape cancels. PATCH `/issues/{index}`. +- [x] **Edit body** — pencil icon next to the body swaps to a textarea. + Ctrl/Cmd+Enter saves, Escape cancels. +- [x] **Set labels** — tag icon opens an inline editor that fetches the repo's + label list (`GET /api/gitea/repo/.../labels`) and shows checkboxes with + original colors. Save uses `PUT /issues/{index}/labels`. +- [ ] **Set assignees** — needs a collaborator typeahead (next round, see + TODO Phase 3.5) +- [ ] **Set milestone** — needs milestone fetch + dropdown (next round) +- [ ] **Set due date** — date picker (next round) + +## Phase 3.5 — remaining metadata + +- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/milestones` +- [ ] `GET /api/gitea/repo/{accId}/{owner}/{repo}/collaborators` +- [ ] Milestone dropdown in detail view +- [ ] Assignee multi-select with typeahead +- [ ] Due date input + clear button ## Phase 4 — create issues -- [ ] **"new issue" button** in the overlay +- [x] **"new issue" button** in the list toolbar +- [x] **Repo picker** loaded from `/user/repos` (cached per account, filtered + to non-archived repos with push permission) +- [x] **Title + body + labels** form +- [x] **Submit** creates via POST `/repo/{accId}/{owner}/{repo}/issues`, + refreshes list, auto-opens the new issue in the detail view - [ ] **Create from card**: a card → push as new gitea issue, store the - backref so future edits can sync both ways + backref so future edits can sync both ways (Phase 5) - [ ] **Pre-fill template** from a repo's `.gitea/ISSUE_TEMPLATE.md` - if available + if available (next round) ## Phase 5 — bidirectional sync (the killer feature) diff --git a/public/plugins/gitea/backend.php b/public/plugins/gitea/backend.php index 201a9e2..07472d2 100644 --- a/public/plugins/gitea/backend.php +++ b/public/plugins/gitea/backend.php @@ -145,6 +145,371 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { giteaJson(['ok' => true]); } + // ── Issues: list ── + + if ($route === '/api/gitea/issues' && $method === 'GET') { + $accId = (int)($_GET['account'] ?? 0); + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + + $state = in_array($_GET['state'] ?? 'open', ['open', 'closed', 'all'], true) ? $_GET['state'] : 'open'; + $q = trim((string)($_GET['q'] ?? '')); + $owner = trim((string)($_GET['owner'] ?? '')); + $scope = $_GET['scope'] ?? 'all'; // all | assigned | created | mentioned + $page = max(1, (int)($_GET['page'] ?? 1)); + $limit = max(5, min(100, (int)($_GET['limit'] ?? 30))); + + try { + $client = giteaOpenClient($row); + $issues = $client->searchIssues([ + 'state' => $state, + 'q' => $q !== '' ? $q : null, + 'owner' => $owner !== '' ? $owner : null, + 'type' => 'issues', // exclude PRs + 'assigned' => $scope === 'assigned' ? true : null, + 'created' => $scope === 'created' ? true : null, + 'mentioned' => $scope === 'mentioned' ? true : null, + 'page' => $page, + 'limit' => $limit, + ]); + + // Map to a compact, UI-friendly shape + $mapped = array_map(function ($i) { + $repoFull = $i['repository']['full_name'] ?? null; + if (!$repoFull && isset($i['repository']['owner'], $i['repository']['name'])) { + $repoFull = $i['repository']['owner'] . '/' . $i['repository']['name']; + } + return [ + 'id' => $i['id'] ?? null, + 'number' => $i['number'] ?? null, + 'title' => $i['title'] ?? '', + 'state' => $i['state'] ?? 'open', + 'created_at' => $i['created_at'] ?? null, + 'updated_at' => $i['updated_at'] ?? null, + 'comments_count' => $i['comments'] ?? 0, + 'html_url' => $i['html_url'] ?? null, + 'repo' => $repoFull, + 'author' => $i['user']['login'] ?? null, + 'labels' => array_map(fn($l) => [ + 'name' => $l['name'] ?? '', + 'color' => $l['color'] ?? '888888', + ], $i['labels'] ?? []), + 'assignees' => array_map(fn($a) => $a['login'] ?? '', $i['assignees'] ?? []), + ]; + }, $issues); + + giteaJson([ + 'ok' => true, + 'issues' => $mapped, + 'page' => $page, + 'limit' => $limit, + 'hasMore' => count($mapped) >= $limit, // heuristic — Gitea doesn't always return total + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + // ── Issue detail (with comments) ── + + if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'GET') { + $accId = (int)$m[1]; + $owner = urldecode($m[2]); + $repo = urldecode($m[3]); + $index = (int)$m[4]; + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + + try { + $client = giteaOpenClient($row); + $issue = $client->getIssue($owner, $repo, $index); + $comments = $client->listIssueComments($owner, $repo, $index); + + giteaJson([ + 'ok' => true, + 'issue' => [ + 'id' => $issue['id'] ?? null, + 'number' => $issue['number'] ?? $index, + 'title' => $issue['title'] ?? '', + 'body' => $issue['body'] ?? '', + 'state' => $issue['state'] ?? 'open', + 'created_at' => $issue['created_at'] ?? null, + 'updated_at' => $issue['updated_at'] ?? null, + 'closed_at' => $issue['closed_at'] ?? null, + 'due_date' => $issue['due_date'] ?? null, + 'html_url' => $issue['html_url'] ?? null, + 'author' => $issue['user']['login'] ?? null, + 'author_avatar' => $issue['user']['avatar_url'] ?? null, + 'repo' => "$owner/$repo", + 'owner' => $owner, + 'repo_name' => $repo, + 'labels' => array_map(fn($l) => [ + 'name' => $l['name'] ?? '', + 'color' => $l['color'] ?? '888888', + ], $issue['labels'] ?? []), + 'assignees' => array_map(fn($a) => $a['login'] ?? '', $issue['assignees'] ?? []), + 'milestone' => $issue['milestone']['title'] ?? null, + ], + 'comments' => array_map(fn($c) => [ + 'id' => $c['id'] ?? null, + 'author' => $c['user']['login'] ?? null, + 'body' => $c['body'] ?? '', + 'created_at' => $c['created_at'] ?? null, + 'updated_at' => $c['updated_at'] ?? null, + ], $comments), + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + // ── Issue write operations ── + + if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/comment$#', $route, $m) && $method === 'POST') { + $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $body = trim((string)($input['body'] ?? '')); + if ($body === '') giteaJson(['error' => 'comment body required'], 400); + try { + $client = giteaOpenClient($row); + $created = $client->createIssueComment($owner, $repo, $index, $body); + giteaJson([ + 'ok' => true, + 'comment' => [ + 'id' => $created['id'] ?? null, + 'author' => $created['user']['login'] ?? null, + 'body' => $created['body'] ?? '', + 'created_at' => $created['created_at'] ?? null, + ], + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)$#', $route, $m) && $method === 'PATCH') { + $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $changes = []; + foreach (['title', 'body', 'state', 'assignees', 'milestone', 'due_date', 'unset_due_date'] as $k) { + if (array_key_exists($k, $input)) $changes[$k] = $input[$k]; + } + if (!$changes) giteaJson(['error' => 'no changes'], 400); + if (isset($changes['state']) && !in_array($changes['state'], ['open', 'closed'], true)) { + giteaJson(['error' => 'invalid state'], 400); + } + try { + $client = giteaOpenClient($row); + $updated = $client->editIssue($owner, $repo, $index, $changes); + giteaJson([ + 'ok' => true, + 'issue' => [ + 'title' => $updated['title'] ?? '', + 'body' => $updated['body'] ?? '', + 'state' => $updated['state'] ?? 'open', + 'updated_at' => $updated['updated_at'] ?? null, + 'closed_at' => $updated['closed_at'] ?? null, + ], + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + if (preg_match('#^/api/gitea/issue/(\d+)/([^/]+)/([^/]+)/(\d+)/labels$#', $route, $m) && $method === 'PUT') { + $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); $index = (int)$m[4]; + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $labels = $input['labels'] ?? []; + if (!is_array($labels)) giteaJson(['error' => 'labels array required'], 400); + try { + $client = giteaOpenClient($row); + $resulting = $client->replaceIssueLabels($owner, $repo, $index, $labels); + giteaJson([ + 'ok' => true, + 'labels' => array_map(fn($l) => [ + 'id' => $l['id'] ?? null, + 'name' => $l['name'] ?? '', + 'color' => $l['color'] ?? '888888', + ], $resulting), + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + // ── Attachment proxy ── + // Gitea attachments live under /attachments/ on the gitea instance + // and require the auth token (or session cookie). The browser can't fetch + // them directly (CORS + no token), so hyprcrd proxies them. + + if (preg_match('#^/api/gitea/attachment/(\d+)/(.+)$#', $route, $m) && $method === 'GET') { + $accId = (int)$m[1]; + $path = $m[2]; // e.g. "attachments/abc-123-uuid" or "user/repo/raw/branch/file.png" + $row = giteaGetAccount($accId); + if (!$row) { http_response_code(404); exit; } + + // Build target URL on the gitea instance + $url = rtrim($row['base_url'], '/') . '/' . ltrim($path, '/'); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . giteaDecrypt($row['token']), + 'User-Agent: hyprcrd-gitea-plugin/1.0', + ], + ]); + $body = curl_exec($ch); + if ($body === false) { + $err = curl_error($ch); + curl_close($ch); + http_response_code(502); + header('Content-Type: text/plain'); + echo "Could not reach gitea: $err"; + exit; + } + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'application/octet-stream'; + curl_close($ch); + + if ($code >= 400) { + http_response_code($code); + header('Content-Type: text/plain'); + echo "Gitea returned HTTP $code"; + exit; + } + + // Strip any default headers PHP might emit so we can replace them + header_remove('Pragma'); + header_remove('Expires'); + header('Content-Type: ' . $contentType); + header('Cache-Control: private, max-age=3600'); + header('Content-Length: ' . strlen($body)); + echo $body; + exit; + } + + // ── Create issue ── + + if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/issues$#', $route, $m) && $method === 'POST') { + $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $title = trim((string)($input['title'] ?? '')); + if ($title === '') giteaJson(['error' => 'title is required'], 400); + $opts = ['title' => $title]; + if (array_key_exists('body', $input)) $opts['body'] = (string)$input['body']; + if (array_key_exists('labels', $input) && is_array($input['labels'])) { + $opts['labels'] = array_map('intval', $input['labels']); + } + if (array_key_exists('assignees', $input) && is_array($input['assignees'])) { + $opts['assignees'] = array_values(array_filter(array_map('strval', $input['assignees']))); + } + if (!empty($input['milestone'])) $opts['milestone'] = (int)$input['milestone']; + if (!empty($input['due_date'])) $opts['due_date'] = (string)$input['due_date']; + + try { + $client = giteaOpenClient($row); + $created = $client->createIssue($owner, $repo, $opts); + giteaJson([ + 'ok' => true, + 'issue' => [ + 'id' => $created['id'] ?? null, + 'number' => $created['number'] ?? null, + 'title' => $created['title'] ?? '', + 'state' => $created['state'] ?? 'open', + 'html_url' => $created['html_url'] ?? null, + 'owner' => $owner, + 'repo_name' => $repo, + ], + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + // ── User repos (for the repo picker) ── + + if ($route === '/api/gitea/repos' && $method === 'GET') { + $accId = (int)($_GET['account'] ?? 0); + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + try { + $client = giteaOpenClient($row); + $repos = $client->listMyRepos(1, 100); + $mapped = array_map(fn($r) => [ + 'id' => $r['id'] ?? null, + 'full_name' => $r['full_name'] ?? null, + 'name' => $r['name'] ?? null, + 'owner' => $r['owner']['login'] ?? null, + 'description' => $r['description'] ?? '', + 'private' => $r['private'] ?? false, + 'archived' => $r['archived'] ?? false, + 'permissions' => $r['permissions'] ?? null, + ], $repos); + // Filter out archived repos and ones without push permission + $mapped = array_values(array_filter($mapped, function ($r) { + if ($r['archived']) return false; + $p = $r['permissions'] ?? null; + if ($p && isset($p['push'])) return $p['push'] || $p['admin'] ?? false; + return true; // unknown permissions = include + })); + giteaJson(['ok' => true, 'repos' => $mapped]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + + // ── Repo metadata ── + + if (preg_match('#^/api/gitea/repo/(\d+)/([^/]+)/([^/]+)/labels$#', $route, $m) && $method === 'GET') { + $accId = (int)$m[1]; $owner = urldecode($m[2]); $repo = urldecode($m[3]); + $row = giteaGetAccount($accId); + if (!$row) giteaJson(['error' => 'account not found'], 404); + try { + $client = giteaOpenClient($row); + $labels = $client->listRepoLabels($owner, $repo); + giteaJson([ + 'ok' => true, + 'labels' => array_map(fn($l) => [ + 'id' => $l['id'] ?? null, + 'name' => $l['name'] ?? '', + 'color' => $l['color'] ?? '888888', + 'description' => $l['description'] ?? '', + ], $labels), + ]); + } catch (GiteaApiException $e) { + giteaJson(['ok' => false, 'error' => "HTTP {$e->statusCode}: " . substr($e->body, 0, 300)], 200); + } catch (\Throwable $e) { + giteaJson(['ok' => false, 'error' => $e->getMessage()], 200); + } + } + if (preg_match('#^/api/gitea/accounts/(\d+)/test$#', $route, $m) && $method === 'POST') { $id = (int)$m[1]; $row = giteaGetAccount($id); diff --git a/public/plugins/gitea/gitea.css b/public/plugins/gitea/gitea.css index 32bf6f2..97ecdfb 100644 --- a/public/plugins/gitea/gitea.css +++ b/public/plugins/gitea/gitea.css @@ -35,6 +35,118 @@ gap: 4px; } +/* Issue list rows */ +.gitea-issue-row { + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s; + border-left: 2px solid transparent; +} +.gitea-issue-row:hover { background: var(--t-raised); } +.gitea-issue-row.active { + background: var(--t-raised); + border-left-color: var(--t-accent); +} +.gitea-issue-row .gitea-issue-repo { + font-size: 9px; + font-family: var(--t-font-pixel); + color: var(--t-dim); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +.gitea-issue-row .gitea-issue-title { + font-size: 12px; + color: var(--t-text); + margin-top: 2px; + line-height: 1.3; +} +.gitea-issue-row.state-closed .gitea-issue-title { color: var(--t-muted); } +.gitea-issue-row .gitea-issue-meta { + font-size: 9px; + font-family: var(--t-font-pixel); + color: var(--t-dim); + margin-top: 4px; + display: flex; + gap: 8px; + align-items: center; +} +.gitea-issue-row .gitea-issue-labels { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 4px; +} + +/* State badge (open/closed) */ +.gitea-state-badge { + display: inline-block; + font-family: var(--t-font-pixel); + font-size: 8px; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 2px; + letter-spacing: 0.5px; +} +.gitea-state-badge.open { background: #22c55e; color: #000; } +.gitea-state-badge.closed { background: #a855f7; color: #fff; } + +/* Label chip (mirrors gitea label colors) */ +.gitea-label { + display: inline-block; + font-family: var(--t-font-pixel); + font-size: 8px; + padding: 1px 5px; + border-radius: 2px; + letter-spacing: 0.3px; + border: 1px solid rgba(0,0,0,0.2); +} + +/* Comment block */ +.gitea-comment { + border: 1px solid var(--t-border); + background: var(--t-surface); + border-radius: 2px; + padding: 10px 12px; +} +.gitea-comment-header { + font-family: var(--t-font-pixel); + font-size: 9px; + color: var(--t-dim); + margin-bottom: 6px; + display: flex; + gap: 6px; +} +.gitea-comment-author { color: var(--t-bright); } +.gitea-comment-body { + font-size: 12px; + color: var(--t-text); +} + +/* Issue list — no text selection so dragging doesn't highlight rows */ +#gitea-issue-list, #gitea-issue-list * { + user-select: none; + -webkit-user-select: none; +} +#gitea-divider:hover > div { background: var(--t-accent); width: 1px; } + +/* Label editor checkboxes */ +.gitea-label-checkbox { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 2px 4px; + border-radius: 2px; + transition: background 0.1s; +} +.gitea-label-checkbox:hover { background: var(--t-raised); } +.gitea-label-checkbox input { accent-color: var(--t-accent); } +.gitea-label-checkbox .gitea-label { cursor: pointer; } + +/* Title input matches the h3 sizing so swap is seamless */ +#gitea-title-input { font-size: 16px; font-weight: bold; line-height: 1.3; } + /* Override Chrome/Safari autofill on the gitea token field */ #gitea-acc-token:-webkit-autofill, #gitea-acc-token:-webkit-autofill:hover, diff --git a/public/plugins/gitea/gitea.html b/public/plugins/gitea/gitea.html index 3f10400..fe5f813 100644 --- a/public/plugins/gitea/gitea.html +++ b/public/plugins/gitea/gitea.html @@ -33,13 +33,158 @@ - +