Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cd3c2d965 | ||
|
|
b66703082f |
No files matched your search
Generated
+1
-1
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[](https://github.com/thakares/bzod)
|
||||
[](https://codeberg.org/thakares/bzod)
|
||||
@@ -35,6 +35,8 @@ Core capabilities include:
|
||||
- Multi-User Platform
|
||||
- REST API
|
||||
- Role-Based Access Control (RBAC)
|
||||
- Responsive UI (Desktop, Tablet, Mobile)
|
||||
- Dark/Light Theme Support
|
||||
- Global Namespace Registry
|
||||
- Registry Validation
|
||||
- Transaction-safe Registry Repair
|
||||
@@ -44,6 +46,7 @@ Core capabilities include:
|
||||
- User Backup & Restore
|
||||
- Disaster Recovery
|
||||
- Upgrade Validation
|
||||
- Build Metadata (Version & Git Commit)
|
||||
- Single Binary Deployment
|
||||
|
||||
The result is a platform that is easy to deploy, lightweight to operate, and entirely controlled by its owner.
|
||||
@@ -90,7 +93,7 @@ No recurring subscription fees.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Efficiency (v0.6.0)
|
||||
## Runtime Efficiency (v0.7.0)
|
||||
|
||||
| Metric | Value |
|
||||
|---------|------:|
|
||||
@@ -1135,16 +1138,37 @@ Highlights include:
|
||||
- Upgrade Validation
|
||||
- Backup Manifest
|
||||
- Transaction-safe Maintenance
|
||||
- Modular Admin Architecture (v0.6.0)
|
||||
- Redirect Handler Hardening (v0.6.0)
|
||||
- Modular Admin Architecture (v0.5.3)
|
||||
- Redirect Handler Hardening (v0.5.3)
|
||||
|
||||
---
|
||||
|
||||
### v0.6
|
||||
|
||||
- Legacy Backup Restore Compatibility
|
||||
- CLI Version Reporting (`--version` / `-V`)
|
||||
- Deploy Script Modernization
|
||||
|
||||
---
|
||||
|
||||
### v0.7
|
||||
|
||||
Responsive UI and theme refactor.
|
||||
|
||||
Highlights include:
|
||||
|
||||
- Responsive Admin & User Registry Panels
|
||||
- Desktop, Laptop, Tablet, and Mobile Layouts
|
||||
- Dark/Light Theme Support with Persistence
|
||||
- Table-to-Card Responsive Behavior
|
||||
- Build Metadata (Version & Git Commit)
|
||||
- Root Landing Page Runtime/Embedded Fallback
|
||||
|
||||
---
|
||||
|
||||
# Future Roadmap
|
||||
|
||||
## v0.6
|
||||
|
||||
Planned features include:
|
||||
Planned features and improvements:
|
||||
|
||||
- Webhooks
|
||||
- OpenAPI Documentation
|
||||
@@ -1155,13 +1179,6 @@ Planned features include:
|
||||
- Bulk Export
|
||||
- Custom Domains
|
||||
- Better QR Styling
|
||||
|
||||
---
|
||||
|
||||
## v0.7
|
||||
|
||||
Planned improvements:
|
||||
|
||||
- Email Notifications
|
||||
- Team Workspaces
|
||||
- Shared Projects
|
||||
@@ -1320,7 +1337,7 @@ Community feedback helps guide future development.
|
||||
|
||||
**Current Version**
|
||||
|
||||
**v0.5.3**
|
||||
**v0.7.0**
|
||||
|
||||
Production Ready
|
||||
|
||||
@@ -1336,12 +1353,15 @@ Production Ready
|
||||
- ✅ Analytics
|
||||
- ✅ REST API
|
||||
- ✅ Role-Based Access Control
|
||||
- ✅ Responsive UI (Desktop, Tablet, Mobile)
|
||||
- ✅ Dark/Light Theme Support
|
||||
- ✅ Registry Validator
|
||||
- ✅ Registry Repair Framework
|
||||
- ✅ Health Diagnostics
|
||||
- ✅ Backup & Restore
|
||||
- ✅ Disaster Recovery
|
||||
- ✅ Upgrade Validation
|
||||
- ✅ Build Metadata (Version & Git Commit)
|
||||
- ✅ Extensive Automated Test Suite
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
|
||||
if let Ok(output) = Command::new("git")
|
||||
.args(["rev-parse", "--short=12", "HEAD"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !commit.is_empty() {
|
||||
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BZOD_VERSION="0.6.0"
|
||||
BZOD_VERSION="0.7.0"
|
||||
|
||||
SERVICE_USER="bzod"
|
||||
INSTALL_PATH="/usr/local/bin/bzod"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ services:
|
||||
- PORT=8654
|
||||
- RUST_LOG=info
|
||||
hostname: bzod
|
||||
image: nx9-url-shortener:v0.6.0
|
||||
image: nx9-url-shortener:v0.7.0
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 8654
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# BZOD Administrator Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
Applies To: BZOD Multi-User Platform
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,51 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
---
|
||||
|
||||
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
## Added
|
||||
|
||||
### Responsive UI
|
||||
|
||||
* Responsive layouts for admin and user URL registry panels
|
||||
* Responsive layouts for admin and user landing page registry panels
|
||||
* Desktop, laptop, tablet, and mobile layout support
|
||||
* Table-to-card responsive behavior for registry panels
|
||||
* Resolved horizontal scrolling issues in registry panels
|
||||
|
||||
### Theme Support
|
||||
|
||||
* Dark/light theme toggle
|
||||
* Theme persistence across sessions
|
||||
* Responsive theme behavior across device sizes
|
||||
|
||||
### Build Metadata
|
||||
|
||||
* Introduced `build.rs` build script for compile-time metadata
|
||||
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
|
||||
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
|
||||
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
|
||||
* Graceful fallback to `"unknown"` when Git metadata is unavailable
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
|
||||
* Public/runtime www assets supported by the deployment layout
|
||||
|
||||
## Changed
|
||||
|
||||
* Documentation updated to reflect v0.7.0 current state
|
||||
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
|
||||
|
||||
## Notes
|
||||
|
||||
* No API behavior changes
|
||||
* No database schema changes
|
||||
* No authentication or security behavior changes
|
||||
* Existing redirect, routing, and tenant isolation behavior preserved
|
||||
|
||||
---
|
||||
|
||||
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
||||
|
||||
The current command list for BZOD v0.6.0 is:
|
||||
The current command list for BZOD v0.7.0 is:
|
||||
|
||||
```text
|
||||
$ bzod --help
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# BZOD v0.6.0 vs Self-Hosted URL Management Platforms
|
||||
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
|
||||
|
||||
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# BZOD Database Architecture
|
||||
|
||||
BZOD v0.6.0 uses SQLite exclusively.
|
||||
BZOD v0.7.0 uses SQLite exclusively.
|
||||
|
||||
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# BZOD Installation Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
||||
Example:
|
||||
|
||||
```bash
|
||||
wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz
|
||||
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.6.0-linux-amd64.tar.gz
|
||||
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Install:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# BZOD Multi-User Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
Release Date: 2026-08-11
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
|
||||
|
||||
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
|
||||
|
||||
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
|
||||
|
||||
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
|
||||
|
||||
## User-Visible Changes
|
||||
|
||||
- Admin URL registry panel is responsive across all device sizes
|
||||
- Admin landing page registry panel is responsive across all device sizes
|
||||
- User URL registry panel is responsive across all device sizes
|
||||
- User landing page registry panel is responsive across all device sizes
|
||||
- Dark/light theme toggle available in the UI
|
||||
- Theme preference persists across sessions
|
||||
- Registry tables switch to card layouts on tablet and mobile viewports
|
||||
- Horizontal scrolling eliminated from registry panels
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Introduced `build.rs` build script for compile-time metadata extraction
|
||||
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
|
||||
- Version and Git commit now available to system status and admin settings endpoints
|
||||
- Root `/` serves `www/index.html` with runtime/embedded fallback
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- Direct upgrade from v0.6.0 with no migration required
|
||||
- No database schema changes
|
||||
- No API changes
|
||||
- No configuration changes
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
Release Date: 2026-08-09
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# BZOD Security Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
|
||||
* Disaster recovery
|
||||
* Operational simplicity
|
||||
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.6.0.
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
@@ -620,7 +620,7 @@ If compromise is suspected:
|
||||
|
||||
# Security Testing
|
||||
|
||||
BZOD v0.6.0 includes tests covering:
|
||||
BZOD v0.7.0 includes tests covering:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
@@ -665,7 +665,7 @@ These may be addressed in future releases.
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.6.0 provides:
|
||||
BZOD v0.7.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Secure session management
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Upgrade Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.7.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
|
||||
Some(commit) => commit,
|
||||
None => "unknown",
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod build_info;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
|
||||
@@ -773,8 +773,8 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
urls,
|
||||
};
|
||||
|
||||
@@ -825,8 +825,8 @@ pub async fn user_status_get(State(state): State<AppState>, jar: CookieJar) -> R
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
urls,
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -79,8 +79,8 @@ pub async fn status_endpoint(
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+1242
-72
File diff suppressed because it is too large.
Load diff
+53
-50
@@ -13,8 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Landing Page Form -->
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -24,7 +23,7 @@
|
||||
<form action="/admin/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
@@ -36,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
@@ -49,7 +48,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state">
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
@@ -59,16 +58,15 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="html_content">Raw HTML Document *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="font-family: monospace; font-size: 0.85rem; height: 300px; background-color: #05070f; border-color: rgba(255,255,255,0.05);" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Landing Pages Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Registered Pages
|
||||
@@ -76,69 +74,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -147,8 +151,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
+105
-88
@@ -4,6 +4,14 @@
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
@media (max-width: 720px) {
|
||||
#utm_fields > div {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}Short URL Registry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,9 +21,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Link Form -->
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Shorten a New URL
|
||||
@@ -97,9 +104,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Links Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
Registered Links
|
||||
@@ -113,7 +119,15 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -121,108 +135,112 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No shortened URLs registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;">
|
||||
{{ url.title.as_deref().unwrap_or("") }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<a href="{{ url.destination }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem;" title="{{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||
{{ desc }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div style="font-size: 0.75rem; color: #38bdf8; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 150px;">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary); text-decoration: none; font-size: 0.7rem; border: 1px solid var(--border-color);">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ url.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -230,8 +248,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
+77
-67
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -23,40 +23,44 @@
|
||||
<form action="/user/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="html_content">HTML Content *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Page</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
My Landing Pages
|
||||
@@ -64,69 +68,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -135,7 +145,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
|
||||
+117
-28
@@ -13,8 +13,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create a New URL
|
||||
@@ -47,20 +47,34 @@
|
||||
<label for="tags">Tags (comma-separated, optional)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Link</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
My Short Links
|
||||
</h3>
|
||||
{% if let Some(tag) = tag_filter %}
|
||||
<span class="badge badge-healthy" style="text-transform: none;">
|
||||
Filtered by tag: {{ tag }}
|
||||
<a href="/user/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -68,36 +82,111 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No short URLs created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
||||
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>{{ url.status }}</td>
|
||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||
<td>
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ url.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
|
||||
</form>
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/user/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.75rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -106,10 +195,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
<a href="/user/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||
@@ -119,13 +208,13 @@
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
<a href="/user/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
<a href="/user/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
|
||||
Reference in new issue
Block a user