add app review system with validation pipeline and admin htmx UI
This commit is contained in:
@@ -2,15 +2,20 @@ package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/omixlab/mosis-portal/internal/database"
|
||||
"github.com/omixlab/mosis-portal/internal/review"
|
||||
"github.com/omixlab/mosis-portal/internal/storage"
|
||||
)
|
||||
|
||||
// Handler handles web page requests
|
||||
type Handler struct {
|
||||
db *database.DB
|
||||
templates *Templates
|
||||
store *storage.Storage
|
||||
review *review.Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new web handler
|
||||
@@ -22,9 +27,15 @@ func NewHandler(db *database.DB) (*Handler, error) {
|
||||
return &Handler{
|
||||
db: db,
|
||||
templates: templates,
|
||||
review: review.New(db),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetStorage sets the storage instance for the handler
|
||||
func (h *Handler) SetStorage(store *storage.Storage) {
|
||||
h.store = store
|
||||
}
|
||||
|
||||
// PageData is the base data structure for all pages
|
||||
type PageData struct {
|
||||
Title string
|
||||
@@ -192,3 +203,236 @@ func getDeveloperFromContext(r *http.Request) *database.Developer {
|
||||
developer, _ := r.Context().Value("developer").(*database.Developer)
|
||||
return developer
|
||||
}
|
||||
|
||||
// AdminReviewQueue renders the admin review queue page
|
||||
func (h *Handler) AdminReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
developer := getDeveloperFromContext(r)
|
||||
if developer == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Add admin role check here
|
||||
pending, approved, rejected, _ := h.db.GetReviewStats(r.Context())
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Stats struct {
|
||||
Pending int
|
||||
Approved int
|
||||
Rejected int
|
||||
}
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "Review Queue",
|
||||
ActiveNav: "admin",
|
||||
Developer: developer,
|
||||
},
|
||||
}
|
||||
data.Stats.Pending = pending
|
||||
data.Stats.Approved = approved
|
||||
data.Stats.Rejected = rejected
|
||||
|
||||
h.render(w, "admin_review_queue", data)
|
||||
}
|
||||
|
||||
// AdminReviewDetail renders the review detail page
|
||||
func (h *Handler) AdminReviewDetail(w http.ResponseWriter, r *http.Request) {
|
||||
developer := getDeveloperFromContext(r)
|
||||
if developer == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
vwa, err := h.db.GetVersionWithApp(r.Context(), versionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Version not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Run validation if package exists
|
||||
var validationResult *review.FullValidationResult
|
||||
if h.store != nil && vwa.Version.PackageURL != "" {
|
||||
packagePath := h.store.GetPackagePath(vwa.Version.PackageURL)
|
||||
result, err := h.review.ValidatePackage(packagePath)
|
||||
if err == nil {
|
||||
validationResult = result
|
||||
}
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
App *database.App
|
||||
Version *database.AppVersion
|
||||
DeveloperName string
|
||||
DeveloperEmail string
|
||||
Validation *review.FullValidationResult
|
||||
}{
|
||||
PageData: PageData{
|
||||
Title: "Review: " + vwa.App.Name,
|
||||
ActiveNav: "admin",
|
||||
Developer: developer,
|
||||
},
|
||||
App: vwa.App,
|
||||
Version: vwa.Version,
|
||||
DeveloperName: vwa.DeveloperName,
|
||||
DeveloperEmail: vwa.DeveloperEmail,
|
||||
Validation: validationResult,
|
||||
}
|
||||
|
||||
h.render(w, "admin_review_detail", data)
|
||||
}
|
||||
|
||||
// AdminReviewQueuePartial renders the review queue list partial for htmx
|
||||
func (h *Handler) AdminReviewQueuePartial(w http.ResponseWriter, r *http.Request) {
|
||||
developer := getDeveloperFromContext(r)
|
||||
if developer == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit := 20
|
||||
offset := (page - 1) * limit
|
||||
|
||||
items, total, err := h.db.GetVersionsInReview(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load queue", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Items []database.VersionWithApp
|
||||
Pagination struct {
|
||||
Page int
|
||||
Limit int
|
||||
Total int
|
||||
TotalPages int
|
||||
}
|
||||
}{
|
||||
Items: items,
|
||||
}
|
||||
data.Pagination.Page = page
|
||||
data.Pagination.Limit = limit
|
||||
data.Pagination.Total = total
|
||||
data.Pagination.TotalPages = (total + limit - 1) / limit
|
||||
|
||||
h.renderPartial(w, "review_queue_list", data)
|
||||
}
|
||||
|
||||
// AdminApprove handles approval of a version from the admin UI
|
||||
func (h *Handler) AdminApprove(w http.ResponseWriter, r *http.Request) {
|
||||
developer := getDeveloperFromContext(r)
|
||||
if developer == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
notes := r.FormValue("notes")
|
||||
|
||||
// Verify version exists and is in review
|
||||
version, err := h.db.GetVersion(r.Context(), versionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Version not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if version.Status != "in_review" {
|
||||
http.Error(w, "Version is not in review", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Approve the version
|
||||
if err := h.review.ApproveVersion(r.Context(), versionID, notes); err != nil {
|
||||
http.Error(w, "Failed to approve version: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to queue via htmx
|
||||
w.Header().Set("HX-Redirect", "/admin/review-queue")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// AdminReject handles rejection of a version from the admin UI
|
||||
func (h *Handler) AdminReject(w http.ResponseWriter, r *http.Request) {
|
||||
developer := getDeveloperFromContext(r)
|
||||
if developer == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
reason := r.FormValue("reason")
|
||||
message := r.FormValue("message")
|
||||
|
||||
if reason == "" {
|
||||
http.Error(w, "Rejection reason is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify version exists and is in review
|
||||
version, err := h.db.GetVersion(r.Context(), versionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Version not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if version.Status != "in_review" {
|
||||
http.Error(w, "Version is not in review", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Reject the version
|
||||
feedback := &review.RejectionFeedback{
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
CanResubmit: true,
|
||||
}
|
||||
if err := h.review.RejectVersion(r.Context(), versionID, feedback); err != nil {
|
||||
http.Error(w, "Failed to reject version: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect back to queue via htmx
|
||||
w.Header().Set("HX-Redirect", "/admin/review-queue")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// AdminValidate runs validation and returns HTML partial with results
|
||||
func (h *Handler) AdminValidate(w http.ResponseWriter, r *http.Request) {
|
||||
developer := getDeveloperFromContext(r)
|
||||
if developer == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
versionID := chi.URLParam(r, "versionID")
|
||||
|
||||
// Get version with package info
|
||||
version, err := h.db.GetVersion(r.Context(), versionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Version not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if version.PackageURL == "" {
|
||||
http.Error(w, "Version has no uploaded package", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Run validation
|
||||
packagePath := h.store.GetPackagePath(version.PackageURL)
|
||||
result, err := h.review.ValidatePackage(packagePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Validation error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render validation results partial
|
||||
h.renderPartial(w, "validation_results", result)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,28 @@ type Templates struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
// Template helper functions
|
||||
var templateFuncs = template.FuncMap{
|
||||
"divFloat": func(a int64, b int) float64 {
|
||||
return float64(a) / float64(b)
|
||||
},
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"mul": func(a, b int) int {
|
||||
return a * b
|
||||
},
|
||||
"min": func(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
},
|
||||
}
|
||||
|
||||
// NewTemplates creates and parses all templates
|
||||
func NewTemplates() (*Templates, error) {
|
||||
t := &Templates{
|
||||
@@ -47,7 +69,7 @@ func NewTemplates() (*Templates, error) {
|
||||
files := append([]string{pageFile}, baseFiles...)
|
||||
|
||||
// Read and parse all files
|
||||
tmpl := template.New(filepath.Base(pageFile))
|
||||
tmpl := template.New(filepath.Base(pageFile)).Funcs(templateFuncs)
|
||||
for _, file := range files {
|
||||
content, err := templateFS.ReadFile(file)
|
||||
if err != nil {
|
||||
@@ -70,7 +92,7 @@ func NewTemplates() (*Templates, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl, err := template.New(filepath.Base(partialFile)).Parse(string(content))
|
||||
tmpl, err := template.New(filepath.Base(partialFile)).Funcs(templateFuncs).Parse(string(content))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
206
portal/internal/web/templates/pages/admin_review_detail.html
Normal file
206
portal/internal/web/templates/pages/admin_review_detail.html
Normal file
@@ -0,0 +1,206 @@
|
||||
{{define "content"}}
|
||||
<div class="mb-8">
|
||||
<a href="/admin/review-queue" class="text-indigo-600 hover:text-indigo-800 text-sm mb-2 inline-flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Queue
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Review: {{.App.Name}}</h1>
|
||||
<p class="text-gray-600 mt-1">Version {{.Version.VersionName}} ({{.Version.VersionCode}})</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- App Info -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">App Information</h2>
|
||||
<dl class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Package ID</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{.App.PackageID}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Category</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{if .App.Category}}{{.App.Category}}{{else}}Uncategorized{{end}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Developer</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{.DeveloperName}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Developer Email</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{.DeveloperEmail}}</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-sm text-gray-600">Description</dt>
|
||||
<dd class="text-sm text-gray-900">{{if .App.Description}}{{.App.Description}}{{else}}<span class="text-gray-400">No description</span>{{end}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Version Info -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Version Details</h2>
|
||||
<dl class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Version</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{.Version.VersionName}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Version Code</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{.Version.VersionCode}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Package Size</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{printf "%.2f" (divFloat .Version.PackageSize 1048576)}} MB</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-600">Submitted</dt>
|
||||
<dd class="text-sm font-medium text-gray-900">{{.Version.CreatedAt.Format "Jan 2, 2006 3:04 PM"}}</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-sm text-gray-600">Release Notes</dt>
|
||||
<dd class="text-sm text-gray-900">{{if .Version.ReleaseNotes}}{{.Version.ReleaseNotes}}{{else}}<span class="text-gray-400">No release notes</span>{{end}}</dd>
|
||||
</div>
|
||||
{{if .Version.Permissions}}
|
||||
<div class="col-span-2">
|
||||
<dt class="text-sm text-gray-600 mb-2">Permissions</dt>
|
||||
<dd class="flex flex-wrap gap-2">
|
||||
{{range .Version.Permissions}}
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded">{{.}}</span>
|
||||
{{end}}
|
||||
</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Validation Results -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Validation Results</h2>
|
||||
<button
|
||||
hx-get="/admin/review/{{.Version.ID}}/validate"
|
||||
hx-target="#validation-results"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
Re-run Validation
|
||||
</button>
|
||||
</div>
|
||||
<div id="validation-results">
|
||||
{{if .Validation}}
|
||||
{{if .Validation.Valid}}
|
||||
<div class="flex items-center text-green-600 mb-4">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Package is valid
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="flex items-center text-red-600 mb-4">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Package validation failed
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Validation.Flags}}
|
||||
<div class="space-y-3">
|
||||
{{range .Validation.Flags}}
|
||||
<div class="flex items-start p-3 rounded-lg {{if eq .Severity "critical"}}bg-red-50 border border-red-200{{else if eq .Severity "warning"}}bg-yellow-50 border border-yellow-200{{else}}bg-blue-50 border border-blue-200{{end}}">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded {{if eq .Severity "critical"}}bg-red-100 text-red-800{{else if eq .Severity "warning"}}bg-yellow-100 text-yellow-800{{else}}bg-blue-100 text-blue-800{{end}}">
|
||||
{{.Severity}}
|
||||
</span>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-900">{{.Reason}}</p>
|
||||
{{if .File}}
|
||||
<p class="text-xs text-gray-500 mt-1">{{.File}}{{if .Line}}:{{.Line}}{{end}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No issues found.</p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">Validation not yet run. Click "Re-run Validation" to check the package.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Actions -->
|
||||
<div class="space-y-6">
|
||||
<!-- Status -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Status</h2>
|
||||
<div class="flex items-center">
|
||||
<span class="w-3 h-3 rounded-full {{if eq .Version.Status "in_review"}}bg-yellow-500{{else if eq .Version.Status "published"}}bg-green-500{{else if eq .Version.Status "rejected"}}bg-red-500{{else}}bg-gray-500{{end}}"></span>
|
||||
<span class="ml-2 text-sm font-medium text-gray-900 capitalize">{{.Version.Status}}</span>
|
||||
</div>
|
||||
{{if .Validation}}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-600">
|
||||
{{if .Validation.AutoApprovable}}
|
||||
<span class="text-green-600">Auto-approvable</span>
|
||||
{{else}}
|
||||
<span class="text-yellow-600">Requires manual review</span>
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
{{if eq .Version.Status "in_review"}}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Actions</h2>
|
||||
|
||||
<!-- Approve -->
|
||||
<form hx-post="/admin/review/{{.Version.ID}}/approve" hx-swap="none" class="mb-4">
|
||||
<label class="block text-sm text-gray-600 mb-2">Approval Notes (optional)</label>
|
||||
<textarea name="notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3" placeholder="Any notes for the developer..."></textarea>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors">
|
||||
Approve & Publish
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Reject -->
|
||||
<form hx-post="/admin/review/{{.Version.ID}}/reject" hx-swap="none">
|
||||
<label class="block text-sm text-gray-600 mb-2">Rejection Reason *</label>
|
||||
<select name="reason" required class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3">
|
||||
<option value="">Select reason...</option>
|
||||
<option value="security">Security Issue</option>
|
||||
<option value="quality">Quality Standards</option>
|
||||
<option value="content">Content Policy Violation</option>
|
||||
<option value="functionality">Functionality Issues</option>
|
||||
<option value="metadata">Metadata Issues</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<label class="block text-sm text-gray-600 mb-2">Additional Details</label>
|
||||
<textarea name="message" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm mb-3" placeholder="Specific feedback for the developer..."></textarea>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors">
|
||||
Reject
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Download Package -->
|
||||
{{if .Version.PackageURL}}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Package</h2>
|
||||
<a href="/downloads/{{.Version.PackageURL}}" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Download Package
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
70
portal/internal/web/templates/pages/admin_review_queue.html
Normal file
70
portal/internal/web/templates/pages/admin_review_queue.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{{define "content"}}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Review Queue</h1>
|
||||
<p class="text-gray-600 mt-1">Apps pending review and approval.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Pending Review</p>
|
||||
<p class="text-3xl font-bold text-yellow-600 mt-1">{{.Stats.Pending}}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Approved</p>
|
||||
<p class="text-3xl font-bold text-green-600 mt-1">{{.Stats.Approved}}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Rejected</p>
|
||||
<p class="text-3xl font-bold text-red-600 mt-1">{{.Stats.Rejected}}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review Queue -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Pending Reviews</h2>
|
||||
</div>
|
||||
|
||||
<div id="review-queue" hx-get="/admin/partials/review-queue" hx-trigger="load" class="divide-y divide-gray-200">
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<div class="htmx-indicator inline-flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading review queue...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{if .Items}}
|
||||
{{range .Items}}
|
||||
<a href="/admin/review/{{.Version.ID}}" class="block px-6 py-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{.App.Name}}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{.App.PackageID}} - v{{.Version.VersionName}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-gray-500">{{.DeveloperName}}</p>
|
||||
<p class="text-xs text-gray-400">{{.Version.CreatedAt.Format "Jan 2, 2006"}}</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs font-medium rounded bg-yellow-100 text-yellow-800">In Review</span>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{if gt .Pagination.TotalPages 1}}
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">
|
||||
Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} results
|
||||
</p>
|
||||
<div class="flex space-x-2">
|
||||
{{if gt .Pagination.Page 1}}
|
||||
<button
|
||||
hx-get="/admin/partials/review-queue?page={{sub .Pagination.Page 1}}"
|
||||
hx-target="#review-queue"
|
||||
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50">
|
||||
Previous
|
||||
</button>
|
||||
{{end}}
|
||||
{{if lt .Pagination.Page .Pagination.TotalPages}}
|
||||
<button
|
||||
hx-get="/admin/partials/review-queue?page={{add .Pagination.Page 1}}"
|
||||
hx-target="#review-queue"
|
||||
class="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50">
|
||||
Next
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">No apps pending review.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">All caught up!</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "validation_results"}}
|
||||
{{if .Valid}}
|
||||
<div class="flex items-center text-green-600 mb-4">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Package is valid
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="flex items-center text-red-600 mb-4">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Package validation failed
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Flags}}
|
||||
<div class="space-y-3">
|
||||
{{range .Flags}}
|
||||
<div class="flex items-start p-3 rounded-lg {{if eq .Severity "critical"}}bg-red-50 border border-red-200{{else if eq .Severity "warning"}}bg-yellow-50 border border-yellow-200{{else}}bg-blue-50 border border-blue-200{{end}}">
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded {{if eq .Severity "critical"}}bg-red-100 text-red-800{{else if eq .Severity "warning"}}bg-yellow-100 text-yellow-800{{else}}bg-blue-100 text-blue-800{{end}}">
|
||||
{{.Severity}}
|
||||
</span>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-gray-900">{{.Reason}}</p>
|
||||
{{if .File}}
|
||||
<p class="text-xs text-gray-500 mt-1">{{.File}}{{if .Line}}:{{.Line}}{{end}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No issues found.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .AutoApprovable}}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<p class="text-sm text-green-600">Package is auto-approvable</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user