Add static files and HTML templates for web UI
Embedded Tailwind CSS and login/generator templates. Self-contained with no external dependencies.
This commit is contained in:
21
internal/static/static.go
Normal file
21
internal/static/static.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Package static provides embedded static files for the web UI.
|
||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.js
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
// FS returns the embedded filesystem containing static files.
|
||||||
|
func FS() fs.FS {
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns an http.Handler that serves static files.
|
||||||
|
func Handler() http.Handler {
|
||||||
|
return http.FileServer(http.FS(files))
|
||||||
|
}
|
||||||
83
internal/static/tailwind.js
Normal file
83
internal/static/tailwind.js
Normal file
File diff suppressed because one or more lines are too long
179
internal/templates/generator.html
Normal file
179
internal/templates/generator.html
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pixa - URL Generator</title>
|
||||||
|
<script src="/static/tailwind.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<div class="max-w-2xl mx-auto py-8 px-4">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">Pixa URL Generator</h1>
|
||||||
|
<a href="/logout" class="text-sm text-gray-600 hover:text-gray-800 underline">
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .GeneratedURL}}
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||||
|
<h2 class="text-sm font-medium text-green-800 mb-2">Generated URL</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value="{{.GeneratedURL}}"
|
||||||
|
id="generated-url"
|
||||||
|
class="flex-1 px-3 py-2 bg-white border border-green-300 rounded-md text-sm font-mono"
|
||||||
|
onclick="this.select()"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick="navigator.clipboard.writeText(document.getElementById('generated-url').value)"
|
||||||
|
class="px-3 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-green-600 mt-2">
|
||||||
|
Expires: {{.ExpiresAt}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Source URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="url"
|
||||||
|
name="url"
|
||||||
|
required
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
value="{{.FormURL}}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="width" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Width
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="width"
|
||||||
|
name="width"
|
||||||
|
min="0"
|
||||||
|
max="10000"
|
||||||
|
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
||||||
|
placeholder="0 = original"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="height" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Height
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="height"
|
||||||
|
name="height"
|
||||||
|
min="0"
|
||||||
|
max="10000"
|
||||||
|
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
||||||
|
placeholder="0 = original"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="format" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Format
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="format"
|
||||||
|
name="format"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="orig" {{if eq .FormFormat "orig"}}selected{{end}}>Original</option>
|
||||||
|
<option value="webp" {{if eq .FormFormat "webp"}}selected{{end}}>WebP</option>
|
||||||
|
<option value="jpeg" {{if eq .FormFormat "jpeg"}}selected{{end}}>JPEG</option>
|
||||||
|
<option value="png" {{if eq .FormFormat "png"}}selected{{end}}>PNG</option>
|
||||||
|
<option value="avif" {{if eq .FormFormat "avif"}}selected{{end}}>AVIF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="quality" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Quality
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="quality"
|
||||||
|
name="quality"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value="{{if .FormQuality}}{{.FormQuality}}{{else}}85{{end}}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="fit" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Fit Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="fit"
|
||||||
|
name="fit"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="cover" {{if eq .FormFit "cover"}}selected{{end}}>Cover</option>
|
||||||
|
<option value="contain" {{if eq .FormFit "contain"}}selected{{end}}>Contain</option>
|
||||||
|
<option value="fill" {{if eq .FormFit "fill"}}selected{{end}}>Fill</option>
|
||||||
|
<option value="inside" {{if eq .FormFit "inside"}}selected{{end}}>Inside</option>
|
||||||
|
<option value="outside" {{if eq .FormFit "outside"}}selected{{end}}>Outside</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ttl" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Expires In
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ttl"
|
||||||
|
name="ttl"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="3600" {{if eq .FormTTL "3600"}}selected{{end}}>1 hour</option>
|
||||||
|
<option value="86400" {{if eq .FormTTL "86400"}}selected{{end}}>1 day</option>
|
||||||
|
<option value="604800" {{if eq .FormTTL "604800"}}selected{{end}}>1 week</option>
|
||||||
|
<option value="2592000" {{if or (eq .FormTTL "2592000") (eq .FormTTL "")}}selected{{end}}>30 days</option>
|
||||||
|
<option value="31536000" {{if eq .FormTTL "31536000"}}selected{{end}}>1 year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Generate Encrypted URL
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 mt-4 text-center">
|
||||||
|
Generated URLs are encrypted and cannot be modified. They will expire at the specified time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
internal/templates/login.html
Normal file
44
internal/templates/login.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pixa - Login</title>
|
||||||
|
<script src="/static/tailwind.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 mb-6 text-center">Pixa Image Proxy</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
{{.Error}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="POST" action="/" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Signing Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="key"
|
||||||
|
name="key"
|
||||||
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter your signing key"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
internal/templates/templates.go
Normal file
32
internal/templates/templates.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Package templates provides embedded HTML templates for the web UI.
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed *.html
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
//nolint:gochecknoglobals // intentional lazy initialization cache
|
||||||
|
var (
|
||||||
|
parsed *template.Template
|
||||||
|
parseOne sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get returns the parsed templates, parsing them on first call.
|
||||||
|
func Get() *template.Template {
|
||||||
|
parseOne.Do(func() {
|
||||||
|
parsed = template.Must(template.ParseFS(files, "*.html"))
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders a template by name to the given writer.
|
||||||
|
func Render(w io.Writer, name string, data any) error {
|
||||||
|
return Get().ExecuteTemplate(w, name, data)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user