Compare commits

..

9 Commits

Author SHA1 Message Date
agent-company 37ddfb128b fix: vendor htmx.min.js locally instead of loading from CDN
Download htmx.org v1.9.10 into static/htmx.min.js and update all
references (layout.html, handlers.go fallback page, sw.js precache
list) to use the local copy. This enables the PWA to work fully
offline since the service worker can now cache htmx from the same
origin.

Bump service worker cache version to v2 so existing installations
pick up the new asset list.

Closes leeworks-agents/gitea-mobile#17

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:03:46 +00:00
AI-Manager cf841ac5d9 Merge pull request 'feat: implement mobile-first HTMX templates and CSS' (#15) from feature/templates-css into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 05:05:32 +00:00
AI-Manager 43d621e688 Merge pull request 'feat: add PWA manifest and service worker' (#14) from feature/pwa into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 05:05:20 +00:00
AI-Manager 4a25f5fac4 Merge pull request 'feat: add Dockerfile and CI workflow' (#13) from feature/dockerfile-ci into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 05:05:14 +00:00
AI-Manager 180fd9b65c Merge pull request 'feat: add HTTP handlers and health endpoint' (#12) from feature/http-handlers into master 2026-03-26 05:05:01 +00:00
AI-Manager f464e11b00 Merge pull request 'feat: implement Gitea aggregation layer with concurrent fetching' (#11) from feature/gitea-aggregation into master 2026-03-26 05:04:46 +00:00
AI-Manager 24b44debf0 Merge pull request 'feat: add env-based configuration and token-in-cookie auth' (#10) from feature/config-auth into master 2026-03-26 05:04:23 +00:00
agent-company 81496c775e feat: implement mobile-first HTMX templates and CSS
Build all frontend views using Go html/template with HTMX interactions
and mobile-first CSS with dark mode and iPhone safe areas.

Templates:
- layout.html: base layout with fixed bottom nav (Dashboard, Issues, PRs, Settings)
- dashboard.html: card-based triage view with priority indicators
- issues.html: issue list with filter bar (org, state) and infinite scroll
- pulls.html: PR list with diff stats and merge status
- issue_detail.html: issue detail with comments, label assignment
- pull_detail.html: PR detail with diff stats and review form
- create_issue.html: issue creation form with searchable repo selector

CSS (static/style.css):
- Mobile-first dark mode with CSS variables
- iPhone safe areas via env(safe-area-inset-bottom)
- Responsive: 2-column grid at 640px+ breakpoint
- Light mode support via prefers-color-scheme
- Card, label, badge, form, button component styles
- HTMX loading indicator and spinner animation
- Reduced motion support

HTMX patterns:
- hx-get with hx-target for SPA-like navigation
- hx-trigger="revealed" for infinite scroll
- hx-post with hx-swap for inline form actions
- Filter bar with hx-trigger="change" and hx-include

Closes leeworks-agents/gitea-mobile#5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 04:15:06 +00:00
agent-company 712dc5632c feat: add PWA manifest and service worker
Add Progressive Web App support for installable mobile experience
on iOS Safari and Android Chrome.

- static/manifest.json: app name, standalone display, dark theme,
  icon references for 192px and 512px
- static/sw.js: service worker with cache-first strategy for static
  assets, network-first for HTML/HTMX, offline fallback to cache
- static/icon-192.png, static/icon-512.png: placeholder app icons
- Apple meta tags: apple-mobile-web-app-capable, status bar style,
  apple-touch-icon for iOS Add to Home Screen
- Service worker registration in base layout template

Closes leeworks-agents/gitea-mobile#6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 04:12:20 +00:00
16 changed files with 887 additions and 41 deletions
+14 -41
View File
@@ -97,48 +97,14 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title> <title>{{.Title}} — Gitea Mobile</title>
<style> <link rel="stylesheet" href="/static/style.css">
* { box-sizing: border-box; margin: 0; padding: 0; } <script src="/static/htmx.min.js"></script>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding-bottom: calc(60px + env(safe-area-inset-bottom));
}
.content { padding: 1rem; padding-top: max(1rem, env(safe-area-inset-top)); }
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 0.75rem; margin-bottom: 0.5rem;
}
.card-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
.card-meta { font-size: 0.75rem; color: #8b949e; }
.label {
display: inline-block; font-size: 0.7rem; padding: 2px 6px;
border-radius: 10px; font-weight: 500; margin-right: 4px;
}
.type-badge {
font-size: 0.65rem; text-transform: uppercase; font-weight: 700;
padding: 1px 5px; border-radius: 4px; margin-right: 4px;
}
.type-issue { background: #1f6feb22; color: #58a6ff; border: 1px solid #1f6feb44; }
.type-pull { background: #23863622; color: #3fb950; border: 1px solid #23863644; }
.empty { text-align: center; color: #8b949e; padding: 2rem 1rem; }
.bottom-nav {
position: fixed; bottom: 0; left: 0; right: 0;
background: #161b22; border-top: 1px solid #30363d;
display: flex; justify-content: space-around; align-items: center;
height: 56px;
padding-bottom: env(safe-area-inset-bottom);
}
.bottom-nav a {
color: #8b949e; text-decoration: none; font-size: 0.7rem;
display: flex; flex-direction: column; align-items: center; padding: 4px 0;
}
.bottom-nav a.active { color: #58a6ff; }
.bottom-nav svg { width: 22px; height: 22px; margin-bottom: 2px; }
</style>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>
<div class="content" id="main-content"> <div class="content" id="main-content">
@@ -162,6 +128,13 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
Settings Settings
</a> </a>
</nav> </nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body> </body>
</html>`)) </html>`))
View File
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<h1>Create Issue</h1>
<form hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group">
<label for="repo-select">Repository</label>
<select id="repo-select" name="owner_repo" required>
<option value="">Select a repository...</option>
{{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}}
</optgroup>
{{end}}
</select>
<input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input">
</div>
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required>
</div>
<div class="form-group">
<label for="body">Description</label>
<textarea id="body" name="body" placeholder="Describe the issue..."></textarea>
</div>
<button type="submit" class="btn btn-primary">Create Issue</button>
</form>
<script>
// Split owner/repo from select into hidden fields.
document.getElementById('repo-select').addEventListener('change', function() {
var parts = this.value.split('/');
document.getElementById('owner-input').value = parts[0] || '';
document.getElementById('repo-input').value = parts[1] || '';
});
</script>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{define "content"}}
<h1>Dashboard</h1>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Items}}
<p class="empty">No items need attention. Nice work!</p>
{{else}}
<div class="card-grid">
{{range .Items}}
<div class="card" hx-get="/{{if eq .Type "pull"}}pulls{{else}}issues{{end}}/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">
{{if eq .Type "pull"}}<span class="type-badge type-pull">PR</span>{{else}}<span class="type-badge type-issue">issue</span>{{end}}
{{.Title}}
</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label">{{.}}</span>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "content"}}
<h1>{{.Issue.Title}}</h1>
<div class="card">
<div class="card-meta">
<span class="state-open">{{.Issue.State}}</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
{{if .Issue.Body}}
<div class="card-body">{{.Issue.Body}}</div>
{{end}}
</div>
{{if .Comments}}
<h2>Comments</h2>
{{range .Comments}}
<div class="comment">
<div class="comment-header">
<strong>{{.User}}</strong>
<span>{{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
{{end}}
<div class="card" style="margin-top:1rem;">
<h2>Actions</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
<div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="label_id">
{{range .AvailableLabels}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
<button type="submit" class="btn btn-secondary" style="width:auto;padding:0.5rem 1rem;">Apply Label</button>
</div>
</form>
</div>
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
<h1>Issues</h1>
<div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Issues}}
<p class="empty">No issues found.</p>
{{else}}
<div id="issue-list">
{{range .Issues}}
<div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">{{.Title}}</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<span>{{.Assignee.Login}}</span>
{{end}}
</div>
</div>
{{end}}
{{if .HasMore}}
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
</div>
{{end}}
{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="content" id="main-content">
{{template "content" .}}
</div>
<nav class="bottom-nav">
<a href="/" {{if eq .ActiveTab "dashboard"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/></svg>
Dashboard
</a>
<a href="/issues" {{if eq .ActiveTab "issues"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Issues
</a>
<a href="/pulls" {{if eq .ActiveTab "pulls"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
PRs
</a>
<a href="/settings" {{if eq .ActiveTab "settings"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
</nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body>
</html>{{end}}
+47
View File
@@ -0,0 +1,47 @@
{{define "content"}}
<h1>{{.Pull.Title}}</h1>
<div class="card">
<div class="card-meta">
<span class="type-badge type-pull">PR</span>
<span class="state-open">{{.Pull.State}}</span>
<span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
<div class="card-meta" style="margin-top:0.5rem;">
<span class="diff-add">+{{.Pull.Additions}}</span>
<span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div>
{{if .Pull.Body}}
<div class="card-body">{{.Pull.Body}}</div>
{{end}}
</div>
<div class="card" style="margin-top:1rem;">
<h2>Submit Review</h2>
<form hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/review" hx-swap="outerHTML">
<div class="form-group">
<label for="review-body">Comment</label>
<textarea id="review-body" name="body" placeholder="Leave a review comment..."></textarea>
</div>
<div class="review-options">
<label class="review-option">
<input type="radio" name="event" value="COMMENT" checked>
<span>Comment</span>
</label>
<label class="review-option">
<input type="radio" name="event" value="APPROVED">
<span>Approve</span>
</label>
<label class="review-option">
<input type="radio" name="event" value="REQUEST_CHANGES">
<span>Request Changes</span>
</label>
</div>
<button type="submit" class="btn btn-primary">Submit Review</button>
</form>
</div>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "content"}}
<h1>Pull Requests</h1>
<div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Pulls}}
<p class="empty">No open pull requests found.</p>
{{else}}
<div id="pull-list">
{{range .Pulls}}
<div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">
<span class="type-badge type-pull">PR</span>
{{.Title}}
</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
<span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span>
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
View File
+1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "Gitea Mobile",
"short_name": "Gitea",
"description": "Mobile-first PWA for managing Gitea issues and pull requests",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#161b22",
"orientation": "portrait",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+477
View File
@@ -0,0 +1,477 @@
/* Gitea Mobile — Mobile-first CSS (~5KB target) */
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* CSS Variables */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-link: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-yellow: #d29922;
--accent-blue: #58a6ff;
--accent-purple: #bc8cff;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--radius: 8px;
--radius-sm: 6px;
--radius-pill: 10px;
--nav-height: 56px;
--font-sm: 0.75rem;
--font-base: 0.875rem;
--font-lg: 1rem;
--font-xl: 1.25rem;
}
/* Base */
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Content area */
.content {
padding: var(--spacing-lg);
padding-top: max(var(--spacing-lg), env(safe-area-inset-top));
max-width: 640px;
margin: 0 auto;
}
/* Typography */
h1 {
font-size: var(--font-xl);
font-weight: 700;
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: var(--font-lg);
font-weight: 600;
margin-bottom: var(--spacing-md);
}
a {
color: var(--text-link);
text-decoration: none;
}
a:active {
opacity: 0.7;
}
/* Cards */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-md);
margin-bottom: var(--spacing-sm);
transition: background 0.15s ease;
}
.card:active {
background: var(--bg-tertiary);
}
.card-title {
font-weight: 600;
font-size: var(--font-base);
margin-bottom: var(--spacing-xs);
line-height: 1.3;
}
.card-meta {
font-size: var(--font-sm);
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-xs);
}
.card-body {
font-size: var(--font-base);
color: var(--text-secondary);
margin-top: var(--spacing-sm);
line-height: 1.5;
}
/* Labels / badges */
.label {
display: inline-block;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: var(--radius-pill);
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
}
.type-badge {
font-size: 0.65rem;
text-transform: uppercase;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
}
.type-issue {
background: rgba(31, 111, 235, 0.13);
color: var(--accent-blue);
border: 1px solid rgba(31, 111, 235, 0.27);
}
.type-pull {
background: rgba(35, 134, 54, 0.13);
color: var(--accent-green);
border: 1px solid rgba(35, 134, 54, 0.27);
}
.state-open {
color: var(--accent-green);
}
.state-closed {
color: var(--accent-red);
}
.state-merged {
color: var(--accent-purple);
}
/* Priority labels */
.priority-p1 { color: var(--accent-red); border-color: var(--accent-red); }
.priority-p2 { color: var(--accent-yellow); border-color: var(--accent-yellow); }
.priority-p3 { color: var(--accent-blue); border-color: var(--accent-blue); }
/* Diff stats */
.diff-add { color: var(--accent-green); font-size: var(--font-sm); }
.diff-del { color: var(--accent-red); font-size: var(--font-sm); }
/* Bottom navigation */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
align-items: center;
height: var(--nav-height);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.bottom-nav a {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.7rem;
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 0;
min-width: 64px;
-webkit-tap-highlight-color: transparent;
}
.bottom-nav a.active {
color: var(--accent-blue);
}
.bottom-nav svg {
width: 22px;
height: 22px;
margin-bottom: 2px;
}
/* Filter bar */
.filter-bar {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: var(--spacing-xs);
}
.filter-bar::-webkit-scrollbar {
display: none;
}
.filter-bar select,
.filter-bar input {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-base);
min-width: 0;
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
}
.filter-bar select:focus,
.filter-bar input:focus {
outline: none;
border-color: var(--accent-blue);
}
/* Forms */
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-size: var(--font-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-lg);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
}
.form-group textarea {
min-height: 120px;
resize: vertical;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-blue);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md) var(--spacing-lg);
font-size: var(--font-lg);
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s ease;
}
.btn-primary {
background: #238636;
color: #fff;
width: 100%;
}
.btn-primary:active {
background: #2ea043;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
width: 100%;
}
.btn-secondary:active {
background: var(--border);
}
.btn-danger {
background: #da363322;
color: var(--accent-red);
border: 1px solid #da363366;
width: 100%;
}
/* Review form */
.review-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.review-option {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.review-option input[type="radio"] {
accent-color: var(--accent-blue);
}
/* Comments thread */
.comment {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--spacing-sm);
overflow: hidden;
}
.comment-header {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-tertiary);
font-size: var(--font-sm);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.comment-body {
padding: var(--spacing-md);
font-size: var(--font-base);
line-height: 1.6;
}
/* Avatar */
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
}
/* Empty state */
.empty {
text-align: center;
color: var(--text-secondary);
padding: 3rem var(--spacing-lg);
font-size: var(--font-base);
}
/* Messages */
.message {
padding: var(--spacing-md);
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-lg);
font-size: var(--font-base);
}
.message.success {
background: #0d2818;
border: 1px solid #238636;
color: var(--accent-green);
}
.message.error {
background: #2d1117;
border: 1px solid #da3633;
color: var(--accent-red);
}
.message.info {
background: #0c1d2e;
border: 1px solid #1f6feb;
color: var(--accent-blue);
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Infinite scroll sentinel */
.scroll-sentinel {
height: 1px;
margin-bottom: var(--spacing-lg);
}
/* Tablet breakpoint: 2-column grid */
@media (min-width: 640px) {
.content {
max-width: 960px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.card-grid .card {
margin-bottom: 0;
}
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Dark mode is default; light mode override if needed */
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #eaeef2;
--border: #d0d7de;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-link: #0969da;
}
}
+86
View File
@@ -0,0 +1,86 @@
// Service Worker for Gitea Mobile PWA
// Caches the app shell for offline/fast loading.
const CACHE_NAME = 'gitea-mobile-v2';
const APP_SHELL = [
'/',
'/static/style.css',
'/static/manifest.json',
'/static/icon-192.png',
'/static/icon-512.png',
'/static/htmx.min.js'
];
// Install: cache app shell resources.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(APP_SHELL);
})
);
// Activate immediately.
self.skipWaiting();
});
// Activate: clean up old caches.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
// Take control of all clients immediately.
self.clients.claim();
});
// Fetch: network-first for API/HTML, cache-first for static assets.
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache-first for static assets.
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
// Cache the fetched response for next time.
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
return;
}
// Network-first for HTML/API requests.
if (event.request.headers.get('accept')?.includes('text/html') ||
event.request.headers.get('HX-Request')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful GET responses.
if (event.request.method === 'GET' && response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fallback to cache if network fails.
return caches.match(event.request);
})
);
return;
}
// Default: network only.
event.respondWith(fetch(event.request));
});