mirror of
https://github.com/tronbyt/server.git
synced 2026-08-31 06:57:10 +02:00
UI styling overhaul; sleep and dim mode toggles (#794)
Build and test / Lint & Quality Checks (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-darwin-arm64, arm64, darwin, macos-26) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-amd64, amd64, linux, ubuntu-24.04) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-arm64, arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-windows-amd64.exe, amd64, windows, windows-2025) (push) Has been cancelled
Build and test / Create Release (push) Has been cancelled
Create and publish a container image / build-and-push-image (push) Has been cancelled
Build and test / Lint & Quality Checks (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-darwin-arm64, arm64, darwin, macos-26) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-amd64, amd64, linux, ubuntu-24.04) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-linux-arm64, arm64, linux, ubuntu-24.04-arm) (push) Has been cancelled
Build and test / build-and-test (tronbyt-server-windows-amd64.exe, amd64, windows, windows-2025) (push) Has been cancelled
Build and test / Create Release (push) Has been cancelled
Create and publish a container image / build-and-push-image (push) Has been cancelled
* heiarchy checkpoint * manual toggles for night and dim * Style disabled app cards more clearly * remove log out button in single user mode * some styling fixes * add passkeys https note * styling adjustments * styling adjustments * fix pr lint failures * fix pr lint failures * fix djlint failures * chore: ignore and remove Go cache directories * fix button brightness in dark mode * Fix linting issues in Go and HTML templates * Make OIDC settings collapsible in admin UI * Refine OIDC settings toggle UI and state management * Simplify OIDC settings UI with subtle text toggle * Document new modern design system for settings framework
This commit is contained in:
@@ -27,3 +27,7 @@ messages.pot
|
||||
.terraform.lock.hcl
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
# Go cache
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Tronbyt Design System (Settings Framework)
|
||||
|
||||
This document describes the modern design language and component library introduced in the `sleep-and-ui` branch. This framework is primarily used for the Settings and Admin interfaces to provide a clean, consistent, and responsive user experience.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Card-Based Layout**: Group related settings into visually distinct cards.
|
||||
- **Consistent Spacing**: Use a standardized grid for labels and controls.
|
||||
- **Subtle Interactions**: Provide clear hover states and transitions without being loud.
|
||||
- **Hierarchical Information**: Use eyebrows, headers, and help text to guide the user.
|
||||
|
||||
## CSS Framework
|
||||
|
||||
All styles are defined in `web/static/css/settings-framework.css`.
|
||||
|
||||
### Layout Containers
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `.settings-page` | The main wrapper for a settings view. Provides vertical spacing between sections. |
|
||||
| `.settings-two-pane` | A responsive two-column grid (Label/Description on left, Control on right). |
|
||||
| `.settings-control-stack` | Vertically stacks multiple controls or inputs. |
|
||||
|
||||
### Cards
|
||||
|
||||
Cards are the primary unit of organization.
|
||||
|
||||
```html
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>Section Title</h2>
|
||||
<p>Description text for the entire card.</p>
|
||||
</div>
|
||||
<!-- Field list or content goes here -->
|
||||
</section>
|
||||
```
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `.settings-card` | The main card container with rounded corners, border, and subtle shadow. |
|
||||
| `.settings-card-header` | Wrapper for the card title (`h2`) and description (`p`). |
|
||||
| `.settings-card-subsection` | Used for dividing a single card into smaller titled sections. |
|
||||
|
||||
### Forms and Field Lists
|
||||
|
||||
Used to align labels and controls in a consistent grid.
|
||||
|
||||
```html
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="id">Label Text</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="text" id="id" class="settings-input">
|
||||
<p class="settings-help">Helper text goes here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `.settings-field-list` | Container for multiple field rows. Adds dividers between rows automatically. |
|
||||
| `.settings-field-row` | A grid row containing a label and a control. Responsive (stacks on mobile). |
|
||||
| `.settings-field-label` | The left-hand side of a row. Uses bold weight and consistent padding. |
|
||||
| `.settings-field-control` | The right-hand side of a row for inputs, toggles, or displays. |
|
||||
|
||||
### Typography
|
||||
|
||||
| Class | Element | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `.settings-eyebrow` | `p` | Small, uppercase text above the main page header. |
|
||||
| `.settings-page-copy` | `p` | Lead text at the top of a page. |
|
||||
| `.settings-help` | `p` | Small, muted text placed under a control to explain it. |
|
||||
| `.settings-caption` | `span/p`| Small, muted text used for metadata or subtle links (e.g., "Expand"). |
|
||||
| `.settings-tag` | `span` | Pills for statuses like "Admin" or counts. |
|
||||
|
||||
### Inputs and Controls
|
||||
|
||||
Standardized sizing for inputs.
|
||||
|
||||
- `.settings-input`: Default width (max 28rem).
|
||||
- `.settings-input-wide`: Extended width (max 40rem).
|
||||
- `.settings-input-xwide`: Extra wide (max 52rem).
|
||||
- `.settings-input-code`: Monospace font for keys or technical values.
|
||||
|
||||
### Navigation
|
||||
|
||||
The sub-navigation bar used at the top of the settings area.
|
||||
|
||||
```html
|
||||
<nav class="settings-subnav">
|
||||
<a href="..." class="settings-subnav-link active">Account</a>
|
||||
<a href="..." class="settings-subnav-link">Admin</a>
|
||||
</nav>
|
||||
```
|
||||
|
||||
### Dialogs
|
||||
|
||||
Modern modal dialogs using the native `<dialog>` element.
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `.settings-dialog` | Modern modal style with backdrop support and deep shadows. |
|
||||
| `.settings-dialog-actions`| Flex container for buttons at the bottom of a dialog. |
|
||||
|
||||
### Interactive Elements
|
||||
|
||||
- **Subtle Toggles**: Use `.settings-caption` with inline styles (`cursor: pointer; user-select: none; text-decoration: none;`) for links that reveal advanced sections (e.g., "Expand").
|
||||
- **Actions Area**: Use `.settings-actions` for groups of buttons (Save, Cancel, Delete).
|
||||
+200
-59
@@ -564,25 +564,29 @@ type App struct {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `gorm:"primaryKey" json:"id"` // 8-char hex
|
||||
Username string `gorm:"index" json:"username"`
|
||||
Name string `json:"name"`
|
||||
Type DeviceType `gorm:"type:text" json:"type"`
|
||||
APIKey string `gorm:"uniqueIndex" json:"api_key"`
|
||||
ImgURL string `json:"img_url"`
|
||||
WsURL string `json:"ws_url"`
|
||||
Notes string `json:"notes"`
|
||||
Brightness Brightness `gorm:"default:20" json:"brightness"` // 0-100
|
||||
CustomBrightnessScale string `json:"custom_brightness_scale"`
|
||||
NightModeEnabled bool `json:"night_mode_enabled"`
|
||||
NightModeApp string `json:"night_mode_app"`
|
||||
NightStart string `json:"night_start"` // HH:MM
|
||||
NightEnd string `json:"night_end"` // HH:MM
|
||||
NightBrightness Brightness `gorm:"default:0" json:"night_brightness"`
|
||||
DimModeEnabled bool `json:"dim_mode_enabled"`
|
||||
DimTime *string `json:"dim_time"`
|
||||
DimBrightness *Brightness `json:"dim_brightness"`
|
||||
DefaultInterval int `gorm:"default:15" json:"default_interval"`
|
||||
ID string `gorm:"primaryKey" json:"id"` // 8-char hex
|
||||
Username string `gorm:"index" json:"username"`
|
||||
Name string `json:"name"`
|
||||
Type DeviceType `gorm:"type:text" json:"type"`
|
||||
APIKey string `gorm:"uniqueIndex" json:"api_key"`
|
||||
ImgURL string `json:"img_url"`
|
||||
WsURL string `json:"ws_url"`
|
||||
Notes string `json:"notes"`
|
||||
Brightness Brightness `gorm:"default:20" json:"brightness"` // 0-100
|
||||
CustomBrightnessScale string `json:"custom_brightness_scale"`
|
||||
NightModeEnabled bool `json:"night_mode_enabled"`
|
||||
NightModeApp string `json:"night_mode_app"`
|
||||
NightStart string `json:"night_start"` // HH:MM
|
||||
NightEnd string `json:"night_end"` // HH:MM
|
||||
NightBrightness Brightness `gorm:"default:0" json:"night_brightness"`
|
||||
NightModeOverride *bool `json:"night_mode_override,omitempty"`
|
||||
NightModeOverrideUntil *time.Time `json:"night_mode_override_until,omitempty"`
|
||||
DimModeEnabled bool `json:"dim_mode_enabled"`
|
||||
DimModeOverride *bool `json:"dim_mode_override,omitempty"`
|
||||
DimModeOverrideUntil *time.Time `json:"dim_mode_override_until,omitempty"`
|
||||
DimTime *string `json:"dim_time"`
|
||||
DimBrightness *Brightness `json:"dim_brightness"`
|
||||
DefaultInterval int `gorm:"default:15" json:"default_interval"`
|
||||
|
||||
Timezone *string `json:"timezone"`
|
||||
Locale *string `json:"locale"`
|
||||
@@ -688,13 +692,7 @@ func (d *Device) GetTimezone() string {
|
||||
return "Local"
|
||||
}
|
||||
|
||||
// GetNightModeIsActive checks if night mode is currently active for a device.
|
||||
func (d Device) GetNightModeIsActive() bool {
|
||||
if !d.NightModeEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get Device Timezone
|
||||
func (d Device) getLocation() *time.Location {
|
||||
loc := time.Local
|
||||
if d.Timezone != nil {
|
||||
if l, err := time.LoadLocation(*d.Timezone); err == nil {
|
||||
@@ -705,10 +703,10 @@ func (d Device) GetNightModeIsActive() bool {
|
||||
loc = l
|
||||
}
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
currentTime := time.Now().In(loc)
|
||||
currentHM := currentTime.Format("15:04")
|
||||
|
||||
func (d Device) getNightScheduleBounds() (string, string) {
|
||||
start := "22:00"
|
||||
if d.NightStart != "" {
|
||||
start = d.NightStart
|
||||
@@ -717,11 +715,176 @@ func (d Device) GetNightModeIsActive() bool {
|
||||
if d.NightEnd != "" {
|
||||
end = d.NightEnd
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
if start > end {
|
||||
return currentHM >= start || currentHM <= end
|
||||
func clockToMinutes(value string) (int, bool) {
|
||||
parts := strings.Split(value, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
return currentHM >= start && currentHM <= end
|
||||
hours, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hours < 0 || hours > 23 {
|
||||
return 0, false
|
||||
}
|
||||
minutes, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minutes < 0 || minutes > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return hours*60 + minutes, true
|
||||
}
|
||||
|
||||
func (d Device) GetScheduledNightModeIsActiveAt(now time.Time) bool {
|
||||
if !d.NightModeEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
start, end := d.getNightScheduleBounds()
|
||||
currentMinutes := now.Hour()*60 + now.Minute()
|
||||
startMinutes, startOK := clockToMinutes(start)
|
||||
endMinutes, endOK := clockToMinutes(end)
|
||||
if !startOK || !endOK {
|
||||
return false
|
||||
}
|
||||
|
||||
if startMinutes > endMinutes {
|
||||
return currentMinutes >= startMinutes || currentMinutes <= endMinutes
|
||||
}
|
||||
return currentMinutes >= startMinutes && currentMinutes <= endMinutes
|
||||
}
|
||||
|
||||
func (d Device) GetNightModeNextChangeAt(now time.Time) *time.Time {
|
||||
if !d.NightModeEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
start, end := d.getNightScheduleBounds()
|
||||
startParsed, err := time.Parse("15:04", start)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
endParsed, err := time.Parse("15:04", end)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var next *time.Time
|
||||
for dayOffset := 0; dayOffset <= 2; dayOffset++ {
|
||||
day := now.AddDate(0, 0, dayOffset)
|
||||
candidates := []time.Time{
|
||||
time.Date(day.Year(), day.Month(), day.Day(), startParsed.Hour(), startParsed.Minute(), 0, 0, now.Location()),
|
||||
time.Date(day.Year(), day.Month(), day.Day(), endParsed.Hour(), endParsed.Minute(), 0, 0, now.Location()),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if !candidate.After(now) {
|
||||
continue
|
||||
}
|
||||
if next == nil || candidate.Before(*next) {
|
||||
candidateCopy := candidate
|
||||
next = &candidateCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (d Device) GetNightModeOverrideActiveAt(now time.Time) bool {
|
||||
if d.NightModeOverride == nil || d.NightModeOverrideUntil == nil {
|
||||
return false
|
||||
}
|
||||
return now.Before(d.NightModeOverrideUntil.In(now.Location()))
|
||||
}
|
||||
|
||||
func (d Device) getDimScheduleBounds() (string, string, bool) {
|
||||
if d.DimTime == nil || *d.DimTime == "" {
|
||||
return "", "", false
|
||||
}
|
||||
start := *d.DimTime
|
||||
end := "06:00"
|
||||
if d.NightEnd != "" {
|
||||
end = d.NightEnd
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
func (d Device) GetScheduledDimModeIsActiveAt(now time.Time) bool {
|
||||
if !d.DimModeEnabled {
|
||||
return false
|
||||
}
|
||||
start, end, ok := d.getDimScheduleBounds()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
currentMinutes := now.Hour()*60 + now.Minute()
|
||||
startMinutes, startOK := clockToMinutes(start)
|
||||
endMinutes, endOK := clockToMinutes(end)
|
||||
if !startOK || !endOK {
|
||||
return false
|
||||
}
|
||||
|
||||
if startMinutes > endMinutes {
|
||||
return currentMinutes >= startMinutes || currentMinutes <= endMinutes
|
||||
}
|
||||
return currentMinutes >= startMinutes && currentMinutes <= endMinutes
|
||||
}
|
||||
|
||||
func (d Device) GetDimModeNextChangeAt(now time.Time) *time.Time {
|
||||
if !d.DimModeEnabled {
|
||||
return nil
|
||||
}
|
||||
start, end, ok := d.getDimScheduleBounds()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
startParsed, err := time.Parse("15:04", start)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
endParsed, err := time.Parse("15:04", end)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var next *time.Time
|
||||
for dayOffset := 0; dayOffset <= 2; dayOffset++ {
|
||||
day := now.AddDate(0, 0, dayOffset)
|
||||
candidates := []time.Time{
|
||||
time.Date(day.Year(), day.Month(), day.Day(), startParsed.Hour(), startParsed.Minute(), 0, 0, now.Location()),
|
||||
time.Date(day.Year(), day.Month(), day.Day(), endParsed.Hour(), endParsed.Minute(), 0, 0, now.Location()),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if !candidate.After(now) {
|
||||
continue
|
||||
}
|
||||
if next == nil || candidate.Before(*next) {
|
||||
candidateCopy := candidate
|
||||
next = &candidateCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (d Device) GetDimModeOverrideActiveAt(now time.Time) bool {
|
||||
if d.DimModeOverride == nil || d.DimModeOverrideUntil == nil {
|
||||
return false
|
||||
}
|
||||
return now.Before(d.DimModeOverrideUntil.In(now.Location()))
|
||||
}
|
||||
|
||||
// GetNightModeIsActive checks if night mode is currently active for a device.
|
||||
func (d Device) GetNightModeIsActive() bool {
|
||||
if !d.NightModeEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
currentTime := time.Now().In(d.getLocation())
|
||||
if d.GetNightModeOverrideActiveAt(currentTime) {
|
||||
return d.NightModeOverride != nil && *d.NightModeOverride
|
||||
}
|
||||
return d.GetScheduledNightModeIsActiveAt(currentTime)
|
||||
}
|
||||
|
||||
// GetDimModeIsActive checks if dim mode is active (dimming without full night mode).
|
||||
@@ -729,36 +892,14 @@ func (d Device) GetDimModeIsActive() bool {
|
||||
if !d.DimModeEnabled {
|
||||
return false
|
||||
}
|
||||
dimTime := d.DimTime
|
||||
if dimTime == nil || *dimTime == "" {
|
||||
currentTime := time.Now().In(d.getLocation())
|
||||
if d.GetNightModeIsActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get Device Timezone
|
||||
loc := time.Local
|
||||
if d.Timezone != nil {
|
||||
if l, err := time.LoadLocation(*d.Timezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
} else if d.Location.Timezone != "" {
|
||||
if l, err := time.LoadLocation(d.Location.Timezone); err == nil {
|
||||
loc = l
|
||||
}
|
||||
if d.GetDimModeOverrideActiveAt(currentTime) {
|
||||
return d.DimModeOverride != nil && *d.DimModeOverride
|
||||
}
|
||||
|
||||
currentTime := time.Now().In(loc)
|
||||
currentHM := currentTime.Format("15:04")
|
||||
|
||||
start := *dimTime
|
||||
end := "06:00" // Default
|
||||
if d.NightEnd != "" {
|
||||
end = d.NightEnd
|
||||
}
|
||||
|
||||
if start > end {
|
||||
return currentHM >= start || currentHM <= end
|
||||
}
|
||||
return currentHM >= start && currentHM <= end
|
||||
return d.GetScheduledDimModeIsActiveAt(currentTime)
|
||||
}
|
||||
|
||||
// GetEffectiveDwellTime returns the display duration for an app, falling back to the device default.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeviceGetScheduledNightModeIsActiveAt(t *testing.T) {
|
||||
device := Device{
|
||||
NightModeEnabled: true,
|
||||
NightStart: "22:00",
|
||||
NightEnd: "06:00",
|
||||
}
|
||||
now := time.Date(2026, time.April, 24, 23, 30, 0, 0, time.UTC)
|
||||
|
||||
assert.True(t, device.GetScheduledNightModeIsActiveAt(now))
|
||||
}
|
||||
|
||||
func TestDeviceGetNightModeNextChangeAt(t *testing.T) {
|
||||
device := Device{
|
||||
NightModeEnabled: true,
|
||||
NightStart: "22:00",
|
||||
NightEnd: "06:00",
|
||||
}
|
||||
now := time.Date(2026, time.April, 24, 23, 30, 0, 0, time.UTC)
|
||||
|
||||
nextChange := device.GetNightModeNextChangeAt(now)
|
||||
require.NotNil(t, nextChange)
|
||||
assert.Equal(t, time.Date(2026, time.April, 25, 6, 0, 0, 0, time.UTC), *nextChange)
|
||||
}
|
||||
|
||||
func TestDeviceGetNightModeIsActiveUsesManualOverride(t *testing.T) {
|
||||
override := false
|
||||
overrideUntil := time.Now().Add(30 * time.Minute)
|
||||
device := Device{
|
||||
NightModeEnabled: true,
|
||||
NightStart: "00:00",
|
||||
NightEnd: "23:59",
|
||||
NightModeOverride: &override,
|
||||
NightModeOverrideUntil: &overrideUntil,
|
||||
}
|
||||
|
||||
assert.False(t, device.GetNightModeIsActive())
|
||||
}
|
||||
|
||||
func TestDeviceGetDimModeIsActiveUsesManualOverride(t *testing.T) {
|
||||
override := true
|
||||
overrideUntil := time.Now().Add(30 * time.Minute)
|
||||
dimTime := "18:00"
|
||||
device := Device{
|
||||
DimModeEnabled: true,
|
||||
DimTime: &dimTime,
|
||||
DimModeOverride: &override,
|
||||
DimModeOverrideUntil: &overrideUntil,
|
||||
}
|
||||
|
||||
assert.True(t, device.GetDimModeIsActive())
|
||||
}
|
||||
+13
-31
@@ -9,23 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Server) handleAdminSettingsGet(w http.ResponseWriter, r *http.Request) {
|
||||
// Check admin
|
||||
user := GetUser(r)
|
||||
if user == nil || !user.IsAdmin {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, "settings", TemplateData{
|
||||
OIDCEnabled: s.Config.OIDCEnabled,
|
||||
OIDCIssuerURL: s.Config.OIDCIssuerURL,
|
||||
OIDCClientID: s.Config.OIDCClientID,
|
||||
OIDCClientSecret: s.Config.OIDCClientSecret,
|
||||
OIDCAllowAutoCreate: s.Config.OIDCAllowAutoCreate,
|
||||
OIDCUsernameClaim: s.Config.OIDCUsernameClaim,
|
||||
OIDCAdminGroupClaim: s.Config.OIDCAdminGroupClaim,
|
||||
OIDCAdminGroupValue: s.Config.OIDCAdminGroupValue,
|
||||
})
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -76,14 +60,18 @@ func (s *Server) handleAdminSettingsPost(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
|
||||
// Reinitialize OIDC provider if enabled
|
||||
if oidcEnabled && oidcIssuerURL != "" {
|
||||
prov, err := s.setupOIDCProvider(context.Background())
|
||||
if err != nil {
|
||||
slog.Warn("Failed to reinitialize OIDC provider", "error", err)
|
||||
s.renderTemplate(w, r, "settings", TemplateData{
|
||||
Flashes: []string{"Failed to initialize OIDC provider: " + err.Error()},
|
||||
})
|
||||
session.AddFlash("Failed to initialize OIDC provider: " + err.Error())
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session after OIDC provider error", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.OIDCProvider = prov
|
||||
@@ -91,17 +79,11 @@ func (s *Server) handleAdminSettingsPost(w http.ResponseWriter, r *http.Request)
|
||||
s.OIDCProvider = nil
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, "settings", TemplateData{
|
||||
Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "OIDC settings saved."})},
|
||||
OIDCEnabled: s.Config.OIDCEnabled,
|
||||
OIDCIssuerURL: s.Config.OIDCIssuerURL,
|
||||
OIDCClientID: s.Config.OIDCClientID,
|
||||
OIDCClientSecret: s.Config.OIDCClientSecret,
|
||||
OIDCAllowAutoCreate: s.Config.OIDCAllowAutoCreate,
|
||||
OIDCUsernameClaim: s.Config.OIDCUsernameClaim,
|
||||
OIDCAdminGroupClaim: s.Config.OIDCAdminGroupClaim,
|
||||
OIDCAdminGroupValue: s.Config.OIDCAdminGroupValue,
|
||||
})
|
||||
session.AddFlash(localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "OIDC settings saved."}))
|
||||
if err := s.saveSession(w, r, session); err != nil {
|
||||
slog.Error("Failed to save session after settings update", "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func boolToString(b bool) string {
|
||||
|
||||
+93
-51
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"log/slog"
|
||||
"tronbyt-server/internal/auth"
|
||||
@@ -13,6 +14,63 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Server) loadSettingsUser(r *http.Request) (*data.User, bool) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := gorm.G[data.User](s.DB).Preload("Credentials", nil).Preload("OIDCIdentities", nil).Where("username = ?", username).First(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch user for settings", "username", username, "error", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &user, true
|
||||
}
|
||||
|
||||
func (s *Server) getSettingsContentData(user *data.User) TemplateData {
|
||||
var firmwareVersion string
|
||||
if user.IsAdmin {
|
||||
firmwareVersion = s.GetLatestFirmwareVersion()
|
||||
}
|
||||
if firmwareVersion == "" {
|
||||
firmwareVersion = "unknown"
|
||||
}
|
||||
|
||||
var systemRepoInfo *gitutils.RepoInfo
|
||||
if s.Config.SystemAppsRepo != "" {
|
||||
path := filepath.Join(s.DataDir, "system-apps")
|
||||
info, err := gitutils.GetRepoInfo(path, s.Config.SystemAppsRepo)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get system repo info", "error", err)
|
||||
} else {
|
||||
systemRepoInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
var userRepoInfo *gitutils.RepoInfo
|
||||
if user.AppRepoURL != "" {
|
||||
path := filepath.Join(s.DataDir, "users", user.Username, "repo")
|
||||
info, err := gitutils.GetRepoInfo(path, user.AppRepoURL)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get user repo info", "error", err)
|
||||
} else {
|
||||
userRepoInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
return TemplateData{
|
||||
User: user,
|
||||
FirmwareVersion: firmwareVersion,
|
||||
SystemRepoInfo: systemRepoInfo,
|
||||
UserRepoInfo: userRepoInfo,
|
||||
GlobalSystemRepoURL: s.Config.SystemAppsRepo,
|
||||
SettingsSection: "content",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Debug("handleLoginGet called")
|
||||
|
||||
@@ -273,62 +331,34 @@ func (s *Server) handleRegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleEditUserGet(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := s.Store.Get(r, "session-name")
|
||||
username, ok := session.Values["username"].(string)
|
||||
http.Redirect(w, r, "/settings/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsAccountGet(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.loadSettingsUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := gorm.G[data.User](s.DB).Preload("Credentials", nil).Preload("OIDCIdentities", nil).Where("username = ?", username).First(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("Failed to fetch user for edit", "username", username, "error", err)
|
||||
s.renderTemplate(w, r, "settings_account", TemplateData{
|
||||
User: user,
|
||||
SettingsSection: "account",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSettingsContentGet(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.loadSettingsUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle admin view specific data
|
||||
var firmwareVersion string
|
||||
if user.IsAdmin {
|
||||
firmwareVersion = s.GetLatestFirmwareVersion()
|
||||
}
|
||||
if firmwareVersion == "" {
|
||||
firmwareVersion = "unknown"
|
||||
}
|
||||
|
||||
var systemRepoInfo *gitutils.RepoInfo
|
||||
if s.Config.SystemAppsRepo != "" {
|
||||
path := filepath.Join(s.DataDir, "system-apps")
|
||||
info, err := gitutils.GetRepoInfo(path, s.Config.SystemAppsRepo)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get system repo info", "error", err)
|
||||
} else {
|
||||
systemRepoInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
var userRepoInfo *gitutils.RepoInfo
|
||||
if user.AppRepoURL != "" {
|
||||
path := filepath.Join(s.DataDir, "users", user.Username, "repo")
|
||||
info, err := gitutils.GetRepoInfo(path, user.AppRepoURL)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get user repo info", "error", err)
|
||||
} else {
|
||||
userRepoInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
s.renderTemplate(w, r, "edit", TemplateData{
|
||||
User: &user,
|
||||
FirmwareVersion: firmwareVersion,
|
||||
SystemRepoInfo: systemRepoInfo,
|
||||
UserRepoInfo: userRepoInfo,
|
||||
GlobalSystemRepoURL: s.Config.SystemAppsRepo,
|
||||
})
|
||||
s.renderTemplate(w, r, "settings_content", s.getSettingsContentData(user))
|
||||
}
|
||||
|
||||
func (s *Server) handleEditUserPost(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -347,7 +377,7 @@ func (s *Server) handleEditUserPost(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
oldPassword := r.FormValue("old_password")
|
||||
newPassword := r.FormValue("password")
|
||||
email := r.FormValue("email")
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
|
||||
needsSave := false
|
||||
|
||||
@@ -355,7 +385,11 @@ func (s *Server) handleEditUserPost(w http.ResponseWriter, r *http.Request) {
|
||||
valid, _, err := auth.VerifyPassword(user.Password, oldPassword)
|
||||
if err != nil || !valid {
|
||||
localizer := s.getLocalizer(r)
|
||||
s.renderTemplate(w, r, "edit", TemplateData{User: &user, Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Invalid old password"})}})
|
||||
s.renderTemplate(w, r, "settings_account", TemplateData{
|
||||
User: &user,
|
||||
Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Invalid old password"})},
|
||||
SettingsSection: "account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -397,13 +431,18 @@ func (s *Server) handleEditUserPost(w http.ResponseWriter, r *http.Request) {
|
||||
AddAppsToTop: user.AddAppsToTop,
|
||||
}
|
||||
if _, err := gorm.G[data.User](s.DB).Where("username = ?", user.Username).Select("Password", "Email", "AddAppsToTop").Updates(r.Context(), updates); err != nil {
|
||||
localizer := s.getLocalizer(r)
|
||||
slog.Error("Failed to update user profile", "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
s.renderTemplate(w, r, "settings_account", TemplateData{
|
||||
User: &user,
|
||||
Flashes: []string{localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "That email address is already in use."})},
|
||||
SettingsSection: "account",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleGenerateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -426,7 +465,7 @@ func (s *Server) handleGenerateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -439,7 +478,7 @@ func (s *Server) handleSetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
apiKey := r.FormValue("api_key")
|
||||
if apiKey == "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther) // Should redirect to edit page?
|
||||
http.Redirect(w, r, "/settings/account", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -447,7 +486,7 @@ func (s *Server) handleSetAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Error("Failed to update API key", "error", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/account", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) SetupAuthRoutes() {
|
||||
@@ -458,6 +497,9 @@ func (s *Server) SetupAuthRoutes() {
|
||||
s.Router.HandleFunc("POST /auth/register", s.handleRegisterPost)
|
||||
s.Router.HandleFunc("GET /auth/edit", s.handleEditUserGet)
|
||||
s.Router.HandleFunc("POST /auth/edit", s.handleEditUserPost)
|
||||
s.Router.HandleFunc("GET /settings/account", s.handleSettingsAccountGet)
|
||||
s.Router.HandleFunc("POST /settings/account", s.handleEditUserPost)
|
||||
s.Router.HandleFunc("GET /settings/content", s.handleSettingsContentGet)
|
||||
s.Router.HandleFunc("POST /auth/generate_api_key", s.handleGenerateAPIKey)
|
||||
s.Router.HandleFunc("POST /auth/set_api_key", s.handleSetAPIKey)
|
||||
|
||||
|
||||
@@ -25,10 +25,12 @@ type DeviceUpdate struct {
|
||||
Brightness *int `json:"brightness"`
|
||||
IntervalSec *int `json:"intervalSec"`
|
||||
NightModeEnabled *bool `json:"nightModeEnabled"`
|
||||
NightModeActive *bool `json:"nightModeActive"`
|
||||
NightModeApp *string `json:"nightModeApp"`
|
||||
NightModeBrightness *int `json:"nightModeBrightness"`
|
||||
NightModeStartTime *string `json:"nightModeStartTime"`
|
||||
NightModeEndTime *string `json:"nightModeEndTime"`
|
||||
DimModeActive *bool `json:"dimModeActive"`
|
||||
DimModeStartTime *string `json:"dimModeStartTime"`
|
||||
DimModeBrightness *int `json:"dimModeBrightness"`
|
||||
PinnedApp *string `json:"pinnedApp"`
|
||||
@@ -54,17 +56,22 @@ type DevicePayload struct {
|
||||
|
||||
// NightMode represents night mode settings in the API payload.
|
||||
type NightMode struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
App string `json:"app"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Brightness int `json:"brightness"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
App string `json:"app"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Brightness int `json:"brightness"`
|
||||
OverrideUntil *string `json:"overrideUntil,omitempty"`
|
||||
}
|
||||
|
||||
// DimMode represents dim mode settings in the API payload.
|
||||
type DimMode struct {
|
||||
StartTime *string `json:"startTime"`
|
||||
Brightness *int `json:"brightness"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
StartTime *string `json:"startTime"`
|
||||
Brightness *int `json:"brightness"`
|
||||
OverrideUntil *string `json:"overrideUntil,omitempty"`
|
||||
}
|
||||
|
||||
// Interstitial represents interstitial app settings in the API payload.
|
||||
@@ -95,6 +102,7 @@ type DeviceInfo struct {
|
||||
|
||||
// toDevicePayload converts a data.Device model to a DevicePayload for API responses.
|
||||
func (s *Server) toDevicePayload(d *data.Device) DevicePayload {
|
||||
now := deviceTimeNow(d)
|
||||
info := DeviceInfo{
|
||||
FirmwareVersion: d.Info.FirmwareVersion,
|
||||
FirmwareType: d.Info.FirmwareType,
|
||||
@@ -126,6 +134,17 @@ func (s *Server) toDevicePayload(d *data.Device) DevicePayload {
|
||||
dimBrightnessPtr = &val
|
||||
}
|
||||
|
||||
var nightModeOverrideUntil *string
|
||||
if d.GetNightModeOverrideActiveAt(now) && d.NightModeOverrideUntil != nil {
|
||||
formatted := d.NightModeOverrideUntil.In(now.Location()).Format(time.RFC3339)
|
||||
nightModeOverrideUntil = &formatted
|
||||
}
|
||||
var dimModeOverrideUntil *string
|
||||
if d.GetDimModeOverrideActiveAt(now) && d.DimModeOverrideUntil != nil {
|
||||
formatted := d.DimModeOverrideUntil.In(now.Location()).Format(time.RFC3339)
|
||||
dimModeOverrideUntil = &formatted
|
||||
}
|
||||
|
||||
return DevicePayload{
|
||||
ID: d.ID,
|
||||
Type: d.Type,
|
||||
@@ -134,15 +153,20 @@ func (s *Server) toDevicePayload(d *data.Device) DevicePayload {
|
||||
IntervalSec: d.DefaultInterval,
|
||||
Brightness: int(d.Brightness),
|
||||
NightMode: NightMode{
|
||||
Enabled: d.NightModeEnabled,
|
||||
App: d.NightModeApp,
|
||||
StartTime: d.NightStart,
|
||||
EndTime: d.NightEnd,
|
||||
Brightness: int(d.NightBrightness),
|
||||
Enabled: d.NightModeEnabled,
|
||||
Active: d.GetNightModeIsActive(),
|
||||
App: d.NightModeApp,
|
||||
StartTime: d.NightStart,
|
||||
EndTime: d.NightEnd,
|
||||
Brightness: int(d.NightBrightness),
|
||||
OverrideUntil: nightModeOverrideUntil,
|
||||
},
|
||||
DimMode: DimMode{
|
||||
StartTime: d.DimTime,
|
||||
Brightness: dimBrightnessPtr,
|
||||
Enabled: d.DimModeEnabled,
|
||||
Active: d.GetDimModeIsActive(),
|
||||
StartTime: d.DimTime,
|
||||
Brightness: dimBrightnessPtr,
|
||||
OverrideUntil: dimModeOverrideUntil,
|
||||
},
|
||||
PinnedApp: d.PinnedApp,
|
||||
Interstitial: Interstitial{
|
||||
@@ -525,6 +549,14 @@ func (s *Server) handlePatchDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if update.IntervalSec != nil {
|
||||
device.DefaultInterval = *update.IntervalSec
|
||||
}
|
||||
nightModeWasEnabled := device.NightModeEnabled
|
||||
nightStartWas := device.NightStart
|
||||
nightEndWas := device.NightEnd
|
||||
dimModeWasEnabled := device.DimModeEnabled
|
||||
var dimTimeWas string
|
||||
if device.DimTime != nil {
|
||||
dimTimeWas = *device.DimTime
|
||||
}
|
||||
if update.NightModeEnabled != nil {
|
||||
device.NightModeEnabled = *update.NightModeEnabled
|
||||
}
|
||||
@@ -571,6 +603,29 @@ func (s *Server) handlePatchDevice(w http.ResponseWriter, r *http.Request) {
|
||||
device.DimBrightness = &val
|
||||
}
|
||||
|
||||
if !device.NightModeEnabled || nightModeWasEnabled != device.NightModeEnabled || nightStartWas != device.NightStart || nightEndWas != device.NightEnd {
|
||||
clearNightModeOverride(device)
|
||||
}
|
||||
if update.NightModeActive != nil {
|
||||
if _, err := setNightModeOverride(device, *update.NightModeActive); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
currentDimTime := ""
|
||||
if device.DimTime != nil {
|
||||
currentDimTime = *device.DimTime
|
||||
}
|
||||
if !device.DimModeEnabled || dimModeWasEnabled != device.DimModeEnabled || dimTimeWas != currentDimTime {
|
||||
clearDimModeOverride(device)
|
||||
}
|
||||
if update.DimModeActive != nil {
|
||||
if _, err := setDimModeOverride(device, *update.DimModeActive); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.DB.Omit("Apps").Save(device).Error; err != nil {
|
||||
http.Error(w, "Failed to update device", http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"tronbyt-server/internal/config"
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -480,6 +482,72 @@ func TestHandlePatchDevice(t *testing.T) {
|
||||
s.ServeHTTP(rr, req)
|
||||
}
|
||||
|
||||
func TestHandlePatchDeviceNightModeActive(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
|
||||
device, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).First(context.Background())
|
||||
require.NoError(t, err)
|
||||
device.NightModeEnabled = true
|
||||
device.NightStart = "22:00"
|
||||
device.NightEnd = "06:00"
|
||||
require.NoError(t, s.DB.Save(device).Error)
|
||||
|
||||
active := true
|
||||
update := DeviceUpdate{NightModeActive: &active}
|
||||
body, _ := json.Marshal(update)
|
||||
req := newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s", deviceID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var payload DevicePayload
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&payload))
|
||||
assert.True(t, payload.NightMode.Active)
|
||||
require.NotNil(t, payload.NightMode.OverrideUntil)
|
||||
|
||||
updatedDevice, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).First(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedDevice.NightModeOverride)
|
||||
require.NotNil(t, updatedDevice.NightModeOverrideUntil)
|
||||
assert.True(t, *updatedDevice.NightModeOverride)
|
||||
}
|
||||
|
||||
func TestHandlePatchDeviceDimModeActive(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
deviceID := "testdevice"
|
||||
|
||||
device, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).First(context.Background())
|
||||
require.NoError(t, err)
|
||||
dimTime := "18:00"
|
||||
device.DimModeEnabled = true
|
||||
device.DimTime = &dimTime
|
||||
require.NoError(t, s.DB.Save(device).Error)
|
||||
|
||||
active := true
|
||||
update := DeviceUpdate{DimModeActive: &active}
|
||||
body, _ := json.Marshal(update)
|
||||
req := newAPIRequest("PATCH", fmt.Sprintf("/v0/devices/%s", deviceID), apiKey, body)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var payload DevicePayload
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&payload))
|
||||
assert.True(t, payload.DimMode.Active)
|
||||
require.NotNil(t, payload.DimMode.OverrideUntil)
|
||||
|
||||
updatedDevice, err := gorm.G[data.Device](s.DB).Where("id = ?", deviceID).First(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedDevice.DimModeOverride)
|
||||
require.NotNil(t, updatedDevice.DimModeOverrideUntil)
|
||||
assert.True(t, *updatedDevice.DimModeOverride)
|
||||
}
|
||||
|
||||
func TestHandlePatchInstallation(t *testing.T) {
|
||||
s := newTestServerAPI(t)
|
||||
apiKey := "test_api_key"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
@@ -445,6 +446,9 @@ func (s *Server) handleUpdateDevicePost(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// 5. Night Mode
|
||||
nightModeWasEnabled := device.NightModeEnabled
|
||||
nightStartWas := device.NightStart
|
||||
nightEndWas := device.NightEnd
|
||||
device.NightModeEnabled = r.FormValue("night_mode_enabled") == "on"
|
||||
|
||||
nightStart := r.FormValue("night_start")
|
||||
@@ -496,7 +500,16 @@ func (s *Server) handleUpdateDevicePost(w http.ResponseWriter, r *http.Request)
|
||||
device.NightColorFilter = nil
|
||||
}
|
||||
|
||||
if !device.NightModeEnabled || nightModeWasEnabled != device.NightModeEnabled || nightStartWas != device.NightStart || nightEndWas != device.NightEnd {
|
||||
clearNightModeOverride(device)
|
||||
}
|
||||
|
||||
// 6. Dim Mode
|
||||
dimModeWasEnabled := device.DimModeEnabled
|
||||
var dimTimeWas string
|
||||
if device.DimTime != nil {
|
||||
dimTimeWas = *device.DimTime
|
||||
}
|
||||
device.DimModeEnabled = r.FormValue("dim_mode_enabled") == "on"
|
||||
|
||||
dimTime := r.FormValue("dim_time")
|
||||
@@ -524,6 +537,14 @@ func (s *Server) handleUpdateDevicePost(w http.ResponseWriter, r *http.Request)
|
||||
device.DimColorFilter = nil
|
||||
}
|
||||
|
||||
currentDimTime := ""
|
||||
if device.DimTime != nil {
|
||||
currentDimTime = *device.DimTime
|
||||
}
|
||||
if !device.DimModeEnabled || dimModeWasEnabled != device.DimModeEnabled || dimTimeWas != currentDimTime {
|
||||
clearDimModeOverride(device)
|
||||
}
|
||||
|
||||
// 7. Location & Locale
|
||||
locationJSON := r.FormValue("location")
|
||||
if locationJSON != "" && locationJSON != "{}" {
|
||||
@@ -859,6 +880,93 @@ func (s *Server) handleUpdateInterval(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetNightModeOverride(w http.ResponseWriter, r *http.Request) {
|
||||
s.setModeOverride(w, r, "night")
|
||||
}
|
||||
|
||||
func (s *Server) handleSetDimModeOverride(w http.ResponseWriter, r *http.Request) {
|
||||
s.setModeOverride(w, r, "dim")
|
||||
}
|
||||
|
||||
func (s *Server) setModeOverride(w http.ResponseWriter, r *http.Request, mode string) {
|
||||
device := GetDevice(r)
|
||||
|
||||
active, err := strconv.ParseBool(r.FormValue("active"))
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid %s mode state", mode), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var enabled bool
|
||||
var overrideUntil *time.Time
|
||||
var updates map[string]any
|
||||
var wsPrefix string
|
||||
var displayMode string
|
||||
|
||||
switch mode {
|
||||
case "night":
|
||||
enabled = device.NightModeEnabled
|
||||
wsPrefix = "nightMode"
|
||||
displayMode = "Night"
|
||||
case "dim":
|
||||
enabled = device.DimModeEnabled
|
||||
wsPrefix = "dimMode"
|
||||
displayMode = "Dim"
|
||||
default:
|
||||
http.Error(w, "Invalid mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
http.Error(w, fmt.Sprintf("%s mode is not enabled for this device", displayMode), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "night":
|
||||
overrideUntil, err = setNightModeOverride(device, active)
|
||||
updates = map[string]any{
|
||||
"night_mode_override": device.NightModeOverride,
|
||||
"night_mode_override_until": device.NightModeOverrideUntil,
|
||||
}
|
||||
case "dim":
|
||||
overrideUntil, err = setDimModeOverride(device, active)
|
||||
updates = map[string]any{
|
||||
"dim_mode_override": device.DimModeOverride,
|
||||
"dim_mode_override_until": device.DimModeOverrideUntil,
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintf("Failed to set %s mode override", mode), "device", device.ID, "error", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DB.Model(&data.Device{ID: device.ID}).Updates(updates).Error; err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to persist %s mode override", mode), "device", device.ID, "error", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := GetUser(r)
|
||||
s.notifyDashboard(user.Username, WSEvent{
|
||||
Type: "device_updated",
|
||||
DeviceID: device.ID,
|
||||
Payload: map[string]any{
|
||||
wsPrefix + "Active": active,
|
||||
wsPrefix + "Until": overrideUntil.Format(time.RFC3339),
|
||||
wsPrefix + "Managed": true,
|
||||
},
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"active": active,
|
||||
"overrideUntil": overrideUntil.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleImportNewDeviceConfig(w http.ResponseWriter, r *http.Request) {
|
||||
user := GetUser(r)
|
||||
|
||||
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -283,3 +286,81 @@ func TestHandleUpdateInterval_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSetNightModeOverride(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
|
||||
user := data.User{Username: "testuser"}
|
||||
s.DB.Create(&user)
|
||||
device := data.Device{
|
||||
ID: "testdevice",
|
||||
Username: "testuser",
|
||||
Name: "Test Device",
|
||||
NightModeEnabled: true,
|
||||
NightStart: "22:00",
|
||||
NightEnd: "06:00",
|
||||
}
|
||||
s.DB.Create(&device)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("active", "true")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/devices/testdevice/set_night_mode_override", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
ctx := context.WithValue(req.Context(), userContextKey, &user)
|
||||
ctx = context.WithValue(ctx, deviceContextKey, &device)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(s.handleSetNightModeOverride)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var updatedDevice data.Device
|
||||
require.NoError(t, s.DB.First(&updatedDevice, "id = ?", "testdevice").Error)
|
||||
require.NotNil(t, updatedDevice.NightModeOverride)
|
||||
require.NotNil(t, updatedDevice.NightModeOverrideUntil)
|
||||
assert.True(t, *updatedDevice.NightModeOverride)
|
||||
assert.True(t, updatedDevice.NightModeOverrideUntil.After(time.Now().Add(-time.Minute)))
|
||||
}
|
||||
|
||||
func TestHandleSetDimModeOverride(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
|
||||
user := data.User{Username: "testuser"}
|
||||
s.DB.Create(&user)
|
||||
dimTime := "18:00"
|
||||
device := data.Device{
|
||||
ID: "testdevice",
|
||||
Username: "testuser",
|
||||
Name: "Test Device",
|
||||
DimModeEnabled: true,
|
||||
DimTime: &dimTime,
|
||||
}
|
||||
s.DB.Create(&device)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("active", "true")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, "/devices/testdevice/set_dim_mode_override", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
ctx := context.WithValue(req.Context(), userContextKey, &user)
|
||||
ctx = context.WithValue(ctx, deviceContextKey, &device)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(s.handleSetDimModeOverride)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
var updatedDevice data.Device
|
||||
require.NoError(t, s.DB.First(&updatedDevice, "id = ?", "testdevice").Error)
|
||||
require.NotNil(t, updatedDevice.DimModeOverride)
|
||||
require.NotNil(t, updatedDevice.DimModeOverrideUntil)
|
||||
assert.True(t, *updatedDevice.DimModeOverride)
|
||||
assert.True(t, updatedDevice.DimModeOverrideUntil.After(time.Now().Add(-time.Minute)))
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *Server) handleUpdateFirmware(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/content", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) checkForUpdates(ctx context.Context) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
"tronbyt-server/internal/gitutils"
|
||||
@@ -79,7 +80,19 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// We need to inject the current admin user into TemplateData for the header/nav
|
||||
s.renderTemplate(w, r, "adminindex", TemplateData{User: user, Users: users})
|
||||
s.renderTemplate(w, r, "settings_admin", TemplateData{
|
||||
User: user,
|
||||
Users: users,
|
||||
SettingsSection: "admin",
|
||||
OIDCEnabled: s.Config.OIDCEnabled,
|
||||
OIDCIssuerURL: s.Config.OIDCIssuerURL,
|
||||
OIDCClientID: s.Config.OIDCClientID,
|
||||
OIDCClientSecret: s.Config.OIDCClientSecret,
|
||||
OIDCAllowAutoCreate: s.Config.OIDCAllowAutoCreate,
|
||||
OIDCUsernameClaim: s.Config.OIDCUsernameClaim,
|
||||
OIDCAdminGroupClaim: s.Config.OIDCAdminGroupClaim,
|
||||
OIDCAdminGroupValue: s.Config.OIDCAdminGroupValue,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -153,7 +166,38 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateUserEmail(w http.ResponseWriter, r *http.Request) {
|
||||
targetUsername := r.PathValue("username")
|
||||
user := GetUser(r)
|
||||
if !user.IsAdmin {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
targetUser, err := gorm.G[data.User](s.DB).Where("username = ?", targetUsername).First(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
if email == "" {
|
||||
targetUser.Email = nil
|
||||
} else {
|
||||
targetUser.Email = &email
|
||||
}
|
||||
|
||||
updates := data.User{Email: targetUser.Email}
|
||||
if _, err := gorm.G[data.User](s.DB).Where("username = ?", targetUsername).Select("Email").Updates(r.Context(), updates); err != nil {
|
||||
slog.Error("Failed to update user email", "username", targetUsername, "error", err)
|
||||
s.flashAndRedirect(w, r, "That email address is already in use.", "/settings/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetThemePreference(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -194,7 +238,7 @@ func (s *Server) handleSetUserRepo(w http.ResponseWriter, r *http.Request) {
|
||||
repoURL := r.FormValue("app_repo_url")
|
||||
if _, err := gorm.G[data.User](s.DB).Where("username = ?", user.Username).Update(r.Context(), "app_repo_url", repoURL); err != nil {
|
||||
slog.Error("Failed to update user repo URL", "error", err)
|
||||
s.flashAndRedirect(w, r, "Failed to update repository URL.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Failed to update repository URL.", "/settings/content", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -202,18 +246,18 @@ func (s *Server) handleSetUserRepo(w http.ResponseWriter, r *http.Request) {
|
||||
if repoURL == "" {
|
||||
if err := os.RemoveAll(appsPath); err != nil {
|
||||
slog.Error("Failed to remove user repo directory", "error", err)
|
||||
s.flashAndRedirect(w, r, "Failed to remove user repository. Check server logs.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Failed to remove user repository. Check server logs.", "/settings/content", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := gitutils.EnsureRepo(appsPath, repoURL, s.Config.GitHubToken, true); err != nil {
|
||||
slog.Error("Failed to sync user repo", "error", err)
|
||||
s.flashAndRedirect(w, r, "Failed to sync user repository. Check server logs.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Failed to sync user repository. Check server logs.", "/settings/content", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.flashAndRedirect(w, r, "User repository updated successfully.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "User repository updated successfully.", "/settings/content", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRefreshUserRepo(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -223,12 +267,12 @@ func (s *Server) handleRefreshUserRepo(w http.ResponseWriter, r *http.Request) {
|
||||
appsPath := filepath.Join(s.DataDir, "users", user.Username, "repo")
|
||||
if err := gitutils.EnsureRepo(appsPath, user.AppRepoURL, s.Config.GitHubToken, true); err != nil {
|
||||
slog.Error("Failed to refresh user repo", "error", err)
|
||||
s.flashAndRedirect(w, r, "Failed to refresh user repository. Check server logs.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Failed to refresh user repository. Check server logs.", "/settings/content", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.flashAndRedirect(w, r, "User repository refreshed successfully.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "User repository refreshed successfully.", "/settings/content", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleExportUserConfig(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -295,7 +339,7 @@ func (s *Server) handleImportUserConfig(w http.ResponseWriter, r *http.Request)
|
||||
} else {
|
||||
// Both failed
|
||||
slog.Error("Failed to decode imported user JSON (both standard and legacy)", "standard_error", errStandard, "legacy_error", errLegacy)
|
||||
s.flashAndRedirect(w, r, "Invalid JSON file or unsupported format.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Invalid JSON file or unsupported format.", "/settings/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -321,7 +365,7 @@ func (s *Server) handleImportUserConfig(w http.ResponseWriter, r *http.Request)
|
||||
deviceWebpDir, err := s.ensureDeviceImageDir(d.ID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get device webp directory for cleanup", "device_id", d.ID, "error", err)
|
||||
s.flashAndRedirect(w, r, "Import failed: internal server error.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Import failed: internal server error.", "/settings/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(deviceWebpDir); err != nil {
|
||||
@@ -366,11 +410,11 @@ func (s *Server) handleImportUserConfig(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Import failed", "error", err)
|
||||
s.flashAndRedirect(w, r, "Import failed. Check server logs.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Import failed. Check server logs.", "/settings/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.flashAndRedirect(w, r, "Configuration imported successfully.", "/auth/edit", http.StatusSeeOther)
|
||||
s.flashAndRedirect(w, r, "Configuration imported successfully.", "/settings/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetSystemRepo(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -397,7 +441,7 @@ func (s *Server) handleSetSystemRepo(w http.ResponseWriter, r *http.Request) {
|
||||
slog.Error("Failed to update system repo", "error", err)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/content", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRefreshSystemRepo(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -427,5 +471,5 @@ func (s *Server) handleRefreshSystemRepo(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/edit", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/settings/content", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHandleSetThemePreference(t *testing.T) {
|
||||
@@ -40,3 +42,64 @@ func TestHandleSetThemePreference(t *testing.T) {
|
||||
t.Errorf("Theme preference not updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleEditUserPostUpdatesEmail(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
|
||||
user := data.User{Username: "testuser", Password: "hashed", APIKey: "user-api-key"}
|
||||
require.NoError(t, s.DB.Create(&user).Error)
|
||||
|
||||
seedReq := httptest.NewRequest(http.MethodGet, "/settings/account", nil)
|
||||
seedRR := httptest.NewRecorder()
|
||||
session, _ := s.Store.Get(seedReq, "session-name")
|
||||
session.Values["username"] = user.Username
|
||||
require.NoError(t, s.saveSession(seedRR, seedReq, session))
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("email", "test@example.com")
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings/account", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, cookie := range seedRR.Result().Cookies() {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(s.handleEditUserPost)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rr.Code)
|
||||
|
||||
var updatedUser data.User
|
||||
require.NoError(t, s.DB.First(&updatedUser, "username = ?", "testuser").Error)
|
||||
require.NotNil(t, updatedUser.Email)
|
||||
require.Equal(t, "test@example.com", *updatedUser.Email)
|
||||
}
|
||||
|
||||
func TestHandleAdminUpdateUserEmail(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
|
||||
admin := data.User{Username: "admin", IsAdmin: true, APIKey: "admin-api-key"}
|
||||
target := data.User{Username: "testuser", APIKey: "target-api-key"}
|
||||
require.NoError(t, s.DB.Create(&admin).Error)
|
||||
require.NoError(t, s.DB.Create(&target).Error)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("email", "updated@example.com")
|
||||
req := httptest.NewRequest(http.MethodPost, "/settings/admin/users/testuser/email", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetPathValue("username", "testuser")
|
||||
|
||||
ctx := context.WithValue(req.Context(), userContextKey, &admin)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(s.handleAdminUpdateUserEmail)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rr.Code)
|
||||
|
||||
var updatedUser data.User
|
||||
require.NoError(t, s.DB.First(&updatedUser, "username = ?", "testuser").Error)
|
||||
require.NotNil(t, updatedUser.Email)
|
||||
require.Equal(t, "updated@example.com", *updatedUser.Email)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@ type TemplateData struct {
|
||||
DeleteOnCancel bool // Indicate if app should be deleted on cancel
|
||||
URLWarning string // Warning about localhost in image URL
|
||||
ReadOnly bool // Indicate if the view should be read-only
|
||||
Partial string
|
||||
Partial string
|
||||
SettingsSection string
|
||||
|
||||
// OIDC Support
|
||||
OIDCEnabled bool
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"tronbyt-server/internal/data"
|
||||
)
|
||||
|
||||
func deviceTimeNow(device *data.Device) time.Time {
|
||||
loc := time.Local
|
||||
if tz := device.GetTimezone(); tz != "" {
|
||||
if loaded, err := time.LoadLocation(tz); err == nil {
|
||||
loc = loaded
|
||||
}
|
||||
}
|
||||
return time.Now().In(loc)
|
||||
}
|
||||
|
||||
func clearNightModeOverride(device *data.Device) {
|
||||
device.NightModeOverride = nil
|
||||
device.NightModeOverrideUntil = nil
|
||||
}
|
||||
|
||||
func clearDimModeOverride(device *data.Device) {
|
||||
device.DimModeOverride = nil
|
||||
device.DimModeOverrideUntil = nil
|
||||
}
|
||||
|
||||
func setNightModeOverride(device *data.Device, active bool) (*time.Time, error) {
|
||||
if !device.NightModeEnabled {
|
||||
return nil, fmt.Errorf("night mode is not enabled for this device")
|
||||
}
|
||||
|
||||
now := deviceTimeNow(device)
|
||||
nextChange := device.GetNightModeNextChangeAt(now)
|
||||
if nextChange == nil {
|
||||
return nil, fmt.Errorf("night mode schedule is incomplete")
|
||||
}
|
||||
|
||||
override := active
|
||||
device.NightModeOverride = &override
|
||||
device.NightModeOverrideUntil = nextChange
|
||||
return nextChange, nil
|
||||
}
|
||||
|
||||
func setDimModeOverride(device *data.Device, active bool) (*time.Time, error) {
|
||||
if !device.DimModeEnabled {
|
||||
return nil, fmt.Errorf("dim mode is not enabled for this device")
|
||||
}
|
||||
|
||||
now := deviceTimeNow(device)
|
||||
nextChange := device.GetDimModeNextChangeAt(now)
|
||||
if nextChange == nil {
|
||||
return nil, fmt.Errorf("dim mode schedule is incomplete")
|
||||
}
|
||||
|
||||
override := active
|
||||
device.DimModeOverride = &override
|
||||
device.DimModeOverrideUntil = nextChange
|
||||
return nextChange, nil
|
||||
}
|
||||
+27
-16
@@ -59,19 +59,22 @@ type Server struct {
|
||||
|
||||
// Map template names to their file paths relative to web/templates.
|
||||
var templateFiles = map[string]string{
|
||||
"index": "manager/index.html",
|
||||
"adminindex": "manager/adminindex.html",
|
||||
"login": "auth/login.html",
|
||||
"register": "auth/register.html",
|
||||
"edit": "auth/edit.html",
|
||||
"create": "manager/create.html",
|
||||
"addapp": "manager/addapp.html",
|
||||
"configapp": "manager/configapp.html",
|
||||
"uploadapp": "manager/uploadapp.html",
|
||||
"firmware": "manager/firmware.html",
|
||||
"update": "manager/update.html",
|
||||
"device_tv": "manager/device_tv.html",
|
||||
"settings": "admin/settings.html",
|
||||
"index": "manager/index.html",
|
||||
"adminindex": "manager/adminindex.html",
|
||||
"login": "auth/login.html",
|
||||
"register": "auth/register.html",
|
||||
"edit": "auth/edit.html",
|
||||
"settings_account": "settings/account.html",
|
||||
"settings_content": "settings/content.html",
|
||||
"settings_admin": "settings/admin.html",
|
||||
"create": "manager/create.html",
|
||||
"addapp": "manager/addapp.html",
|
||||
"configapp": "manager/configapp.html",
|
||||
"uploadapp": "manager/uploadapp.html",
|
||||
"firmware": "manager/firmware.html",
|
||||
"update": "manager/update.html",
|
||||
"device_tv": "manager/device_tv.html",
|
||||
"settings": "admin/settings.html",
|
||||
}
|
||||
|
||||
func NewServer(db *gorm.DB, cfg *config.Settings) *Server {
|
||||
@@ -264,10 +267,16 @@ func (s *Server) routes() {
|
||||
|
||||
// Web UI
|
||||
s.Router.HandleFunc("GET /", s.RequireLogin(s.handleIndex))
|
||||
s.Router.HandleFunc("GET /admin", s.RequireLogin(s.handleAdminIndex))
|
||||
s.Router.HandleFunc("GET /admin/settings", s.RequireLogin(s.handleAdminSettingsGet))
|
||||
s.Router.HandleFunc("POST /admin/settings", s.RequireLogin(s.handleAdminSettingsPost))
|
||||
s.Router.HandleFunc("GET /admin", s.RequireLogin(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/settings/admin", http.StatusSeeOther)
|
||||
}))
|
||||
s.Router.HandleFunc("GET /settings/admin", s.RequireLogin(s.handleAdminIndex))
|
||||
s.Router.HandleFunc("POST /settings/admin/oidc", s.RequireLogin(s.handleAdminSettingsPost))
|
||||
s.Router.HandleFunc("GET /admin/settings", s.RequireLogin(s.handleAdminSettingsGet)) // Keep old path just in case
|
||||
s.Router.HandleFunc("POST /admin/settings", s.RequireLogin(s.handleAdminSettingsPost)) // Keep old path just in case
|
||||
s.Router.HandleFunc("DELETE /admin/users/{username}", s.RequireLogin(s.handleDeleteUser))
|
||||
s.Router.HandleFunc("DELETE /settings/admin/users/{username}", s.RequireLogin(s.handleDeleteUser))
|
||||
s.Router.HandleFunc("POST /settings/admin/users/{username}/email", s.RequireLogin(s.handleAdminUpdateUserEmail))
|
||||
|
||||
s.Router.HandleFunc("GET /devices/create", s.RequireLogin(s.handleCreateDeviceGet))
|
||||
s.Router.HandleFunc("POST /devices/create", s.RequireLogin(s.handleCreateDevicePost))
|
||||
@@ -275,6 +284,8 @@ func (s *Server) routes() {
|
||||
|
||||
s.Router.HandleFunc("POST /devices/{id}/update_brightness", s.RequireLogin(s.RequireDevice(s.handleUpdateBrightness)))
|
||||
s.Router.HandleFunc("POST /devices/{id}/update_interval", s.RequireLogin(s.RequireDevice(s.handleUpdateInterval)))
|
||||
s.Router.HandleFunc("POST /devices/{id}/set_night_mode_override", s.RequireLogin(s.RequireDevice(s.handleSetNightModeOverride)))
|
||||
s.Router.HandleFunc("POST /devices/{id}/set_dim_mode_override", s.RequireLogin(s.RequireDevice(s.handleSetDimModeOverride)))
|
||||
|
||||
s.Router.HandleFunc("GET /devices/{id}/addapp", s.RequireLogin(s.RequireDevice(s.handleAddAppGet)))
|
||||
s.Router.HandleFunc("POST /devices/{id}/addapp", s.RequireLogin(s.RequireDevice(s.handleAddAppPost)))
|
||||
|
||||
+191
-185
@@ -1,23 +1,21 @@
|
||||
/* Add App Page - Unique Styles Only */
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #4CAF50;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid color-mix(in srgb, var(--border-color) 75%, transparent);
|
||||
border-top: 3px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -25,33 +23,149 @@
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Hide loading indicator when content is loaded */
|
||||
.content-loaded .loading-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Optimized grid layout */
|
||||
.addapp-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.addapp-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.addapp-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-group-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-group-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.app-group-meta {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.app-group-meta a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.filter-sort-controls {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-subtle);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.filter-sort-controls-inner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-sort-controls input[type="search"] {
|
||||
margin: 0;
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.control-checkbox,
|
||||
.filter-checkbox {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
contain: layout style paint;
|
||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
border: 1px solid #d8d8d8;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-bg);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
contain: layout style paint;
|
||||
will-change: transform;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 24px var(--shadow-color);
|
||||
}
|
||||
|
||||
.app-item.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 14%, transparent);
|
||||
}
|
||||
|
||||
.app-item.installed {
|
||||
border-color: color-mix(in srgb, var(--border-color) 80%, #c58a0b 20%);
|
||||
}
|
||||
|
||||
.app-item.broken-app {
|
||||
border-color: color-mix(in srgb, var(--border-color) 70%, #c63c37 30%);
|
||||
background: color-mix(in srgb, var(--surface-bg) 94%, #c63c37 6%);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-item.broken-app:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.app-item-title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.app-item-meta {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.app-item-muted {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-img {
|
||||
@@ -59,11 +173,9 @@
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--app-img-bg);
|
||||
}
|
||||
|
||||
.app-img .lazy-image {
|
||||
@@ -81,185 +193,79 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-item.selected {
|
||||
border-color: #4CAF50;
|
||||
box-shadow: 0 0 0 2px #4CAF50;
|
||||
}
|
||||
|
||||
.app-item.installed {
|
||||
border-color: #ffa500;
|
||||
.installed-badge,
|
||||
.supports-2x-badge,
|
||||
.broken-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
z-index: 10;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.installed-badge {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
z-index: 10;
|
||||
right: 10px;
|
||||
background: color-mix(in srgb, var(--surface-bg) 84%, #c58a0b 16%);
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.supports-2x-badge {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
z-index: 10;
|
||||
left: 10px;
|
||||
background: color-mix(in srgb, var(--surface-bg) 84%, #4a78d8 16%);
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.app-item.broken-app {
|
||||
border: 2px solid #f44336;
|
||||
background-color: rgba(244, 67, 54, 0.05);
|
||||
cursor: not-allowed;
|
||||
.broken-badge {
|
||||
position: static;
|
||||
align-self: flex-start;
|
||||
background: color-mix(in srgb, var(--surface-bg) 84%, #c63c37 16%);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.app-item.broken-app:hover {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.filter-sort-controls select {
|
||||
padding: 2px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.filter-sort-container {
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.filter-sort-wrapper {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
.delete-upload-btn,
|
||||
.mark-broken-btn,
|
||||
.unmark-broken-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.filter-sort-controls {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.filter-sort-controls-inner {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.filter-sort-controls input[type="search"] {
|
||||
margin-bottom: 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.filter-sort-controls .control-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Performance optimizations */
|
||||
.app-grid {
|
||||
transform: translateZ(0); /* Force hardware acceleration */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
transform: translateZ(0); /* Force hardware acceleration */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Reduce paint operations during scrolling */
|
||||
.app-item img {
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Optimize text rendering */
|
||||
.app-item p {
|
||||
text-rendering: optimizeSpeed;
|
||||
font-smooth: never;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
|
||||
/* Use higher specificity to override global link styles without !important */
|
||||
.app-item a.delete-upload-btn {
|
||||
display: inline-block;
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--button-bg);
|
||||
color: var(--page-text);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-item a.delete-upload-btn:hover {
|
||||
background-color: #d32f2f;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
.delete-upload-btn {
|
||||
color: var(--danger-text);
|
||||
border-color: color-mix(in srgb, var(--input-border) 70%, #c63c37 30%);
|
||||
}
|
||||
|
||||
.app-item a.delete-upload-btn:visited {
|
||||
color: white;
|
||||
.mark-broken-btn,
|
||||
.unmark-broken-btn {
|
||||
border: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.app-item a.delete-upload-btn:active {
|
||||
color: white;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.filter-sort-controls-inner {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.app-item a.delete-upload-btn:focus {
|
||||
color: white;
|
||||
outline: 2px solid #d32f2f;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.filter-sort-controls input[type="search"] {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* App group styling */
|
||||
.app-group h3 {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-group span {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.app-group a {
|
||||
color: #4CAF50;
|
||||
text-decoration: none;
|
||||
.app-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+108
-149
@@ -1,32 +1,88 @@
|
||||
/* Base Template Additional Styles */
|
||||
/* Global responsive styles */
|
||||
.w3-card-4 {
|
||||
margin: 10px;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Make inputs more touch-friendly on mobile */
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: 30px;
|
||||
/* Larger touch target */
|
||||
max-width: 320px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Responsive buttons */
|
||||
.w3-button {
|
||||
margin: 5px;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
width: auto;
|
||||
border-radius: 8px;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.62rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.w3-button:hover {
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.w3-button.w3-blue,
|
||||
.w3-button.w3-teal,
|
||||
.w3-button.w3-purple,
|
||||
.w3-button.w3-green,
|
||||
.w3-button.w3-orange,
|
||||
.w3-button.w3-deep-orange,
|
||||
.w3-button.w3-red,
|
||||
.w3-button.w3-gray {
|
||||
color: var(--button-text) !important;
|
||||
background: var(--button-bg) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
}
|
||||
|
||||
.w3-button.w3-blue,
|
||||
.w3-button.w3-teal,
|
||||
.w3-button.w3-purple,
|
||||
.w3-button.w3-green {
|
||||
background: var(--button-primary-bg) !important;
|
||||
color: var(--button-primary-text) !important;
|
||||
border-color: var(--button-primary-bg) !important;
|
||||
}
|
||||
|
||||
.w3-button.w3-blue:hover,
|
||||
.w3-button.w3-teal:hover,
|
||||
.w3-button.w3-purple:hover,
|
||||
.w3-button.w3-green:hover {
|
||||
background: var(--button-primary-hover) !important;
|
||||
border-color: var(--button-primary-hover) !important;
|
||||
}
|
||||
|
||||
.w3-button.w3-gray {
|
||||
background: var(--surface-subtle) !important;
|
||||
}
|
||||
|
||||
.w3-button.w3-orange,
|
||||
.w3-button.w3-deep-orange {
|
||||
background: color-mix(in srgb, var(--button-bg) 88%, #c9691a 12%) !important;
|
||||
}
|
||||
|
||||
.w3-button.w3-red {
|
||||
background: color-mix(in srgb, var(--button-bg) 88%, #c63c37 12%) !important;
|
||||
color: var(--danger-text) !important;
|
||||
border-color: color-mix(in srgb, var(--input-border) 70%, #c63c37 30%) !important;
|
||||
}
|
||||
|
||||
/* Left side hamburger menu styles */
|
||||
.hamburger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
padding: 0.45rem;
|
||||
background: none;
|
||||
border: none;
|
||||
z-index: 1002;
|
||||
@@ -34,31 +90,27 @@ input[type="range"] {
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
order: -1; /* Ensure it appears first */
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.hamburger span {
|
||||
width: 25px;
|
||||
height: 3px;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--page-text);
|
||||
margin: 3px 0;
|
||||
transition: 0.3s;
|
||||
border-radius: 2px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hamburger.active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile menu overlay */
|
||||
.mobile-menu-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -66,20 +118,20 @@ input[type="range"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Left side sliding panel */
|
||||
.mobile-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -300px;
|
||||
width: 300px;
|
||||
left: -320px;
|
||||
width: 320px;
|
||||
height: 100vh;
|
||||
background: var(--content-bg);
|
||||
background: var(--surface-bg);
|
||||
z-index: 1001;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
transition: left 0.3s ease-in-out;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||
transition: left 0.25s ease-in-out;
|
||||
box-shadow: 16px 0 40px var(--shadow-strong);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-menu.active {
|
||||
@@ -90,15 +142,14 @@ input[type="range"] {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-menu h1 {
|
||||
margin: 0;
|
||||
color: var(--header-text-color);
|
||||
font-size: 1.2em;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mobile-menu-close {
|
||||
@@ -107,7 +158,7 @@ input[type="range"] {
|
||||
font-size: 1.5rem;
|
||||
color: var(--page-text);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@@ -118,97 +169,69 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
.mobile-menu ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.mobile-menu ul li a {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
color: var(--link-color);
|
||||
padding: 0.8rem 0.9rem;
|
||||
color: var(--page-text);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-menu ul li a:hover {
|
||||
background: var(--secondary-color);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.mobile-menu .theme-toggle-container {
|
||||
margin-top: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mobile-menu .theme-toggle {
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0;
|
||||
padding: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Media queries for different screen sizes */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* General mobile improvements */
|
||||
body {
|
||||
padding: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
padding: 1rem 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Show hamburger menu on mobile */
|
||||
.hamburger {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Hide desktop navigation on mobile */
|
||||
nav ul {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keep nav title visible and centered */
|
||||
nav h1 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing between hamburger and title */
|
||||
nav {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.w3-button {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.w3-padding {
|
||||
padding: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
#brightness {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#brightness,
|
||||
#default_interval {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 10px 0;
|
||||
max-width: 320px;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
/* Auto-Login Mode Indicator */
|
||||
.auto-login-indicator {
|
||||
display: inline-block;
|
||||
margin-right: 0.3rem;
|
||||
@@ -216,96 +239,32 @@ input[type="range"] {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Auto-Login Warning Banner (for register_owner page) */
|
||||
.auto-login-warning-banner {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%);
|
||||
color: white;
|
||||
background: color-mix(in srgb, var(--surface-bg) 88%, #c63c37 12%);
|
||||
color: var(--page-text);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 4px solid #d63031;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 75%, #c63c37 25%);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 1.5rem;
|
||||
animation: pulse-warning 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.auto-login-warning-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
gap: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.auto-login-warning-icon {
|
||||
font-size: 1.5rem;
|
||||
animation: bounce-warning 1s ease-in-out infinite;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.auto-login-warning-subtext {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: normal;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-warning {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.auto-login-warning-content {
|
||||
font-size: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.auto-login-warning-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.auto-login-warning-subtext {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Authenticator Icon Switching */
|
||||
.auth-icon-dark { display: none; }
|
||||
.auth-icon-light { display: block; }
|
||||
|
||||
[data-theme="dark"] .auth-icon-light { display: none; }
|
||||
[data-theme="dark"] .auth-icon-dark { display: block; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-theme="system"] .auth-icon-light { display: none; }
|
||||
[data-theme="system"] .auth-icon-dark { display: block; }
|
||||
}
|
||||
|
||||
.authenticator-icon-container {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.authenticator-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
+22
-33
@@ -126,57 +126,46 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 15px;
|
||||
max-width: 300px;
|
||||
margin-bottom: 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.brightness-btn {
|
||||
flex: 0 0 40px;
|
||||
padding: 8px 4px;
|
||||
border: 2px solid #ccc;
|
||||
border: 1px solid var(--input-border);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Light theme - progressively brighter backgrounds */
|
||||
.brightness-btn[data-brightness="0"] { background-color: #333; color: white; border-color: #222; }
|
||||
.brightness-btn[data-brightness="1"] { background-color: #666; color: white; border-color: #555; }
|
||||
.brightness-btn[data-brightness="2"] { background-color: #888; color: white; border-color: #777; }
|
||||
.brightness-btn[data-brightness="3"] { background-color: #aaa; color: white; border-color: #999; }
|
||||
.brightness-btn[data-brightness="4"] { background-color: #ccc; color: #333; border-color: #bbb; }
|
||||
.brightness-btn[data-brightness="5"] { background-color: #eee; color: #333; border-color: #ddd; }
|
||||
.brightness-btn[data-brightness="0"] { background-color: #1f2937; color: #fff; }
|
||||
.brightness-btn[data-brightness="1"] { background-color: #374151; color: #fff; }
|
||||
.brightness-btn[data-brightness="2"] { background-color: #4b5563; color: #fff; }
|
||||
.brightness-btn[data-brightness="3"] { background-color: #6b7280; color: #fff; }
|
||||
.brightness-btn[data-brightness="4"] { background-color: #d1d5db; color: #111827; }
|
||||
.brightness-btn[data-brightness="5"] { background-color: #f3f4f6; color: #111827; }
|
||||
|
||||
.brightness-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.brightness-btn.active {
|
||||
border-color: #377ba8;
|
||||
border-width: 3px;
|
||||
box-shadow: 0 0 8px rgba(55, 123, 168, 0.5);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
/* Dark theme - progressively brighter backgrounds */
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="0"] { background-color: #1a1a1a; color: #666; border-color: #222; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="1"] { background-color: #333; color: #aaa; border-color: #444; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="2"] { background-color: #555; color: #ccc; border-color: #666; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="3"] { background-color: #777; color: #eee; border-color: #888; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="4"] { background-color: #999; color: #fff; border-color: #aaa; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="5"] { background-color: #bbb; color: #fff; border-color: #ccc; }
|
||||
|
||||
[data-theme="dark"] .brightness-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(255,255,255,0.2);
|
||||
}
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="0"] { background-color: #101418; color: #a4adb8; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="1"] { background-color: #20262d; color: #ccd3dc; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="2"] { background-color: #343c45; color: #e2e7ee; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="3"] { background-color: #4b5560; color: #f5f7fa; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="4"] { background-color: #7a8592; color: #111315; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="5"] { background-color: #c8d0da; color: #111315; }
|
||||
|
||||
[data-theme="dark"] .brightness-btn.active {
|
||||
border-color: #5a9cd1;
|
||||
border-width: 3px;
|
||||
box-shadow: 0 0 8px rgba(90, 156, 209, 0.5);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
|
||||
+112
-131
@@ -62,9 +62,10 @@
|
||||
}
|
||||
|
||||
.device-container {
|
||||
border: 4px solid #000000;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 18px;
|
||||
background: var(--surface-bg);
|
||||
box-shadow: 0 12px 32px var(--shadow-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -107,6 +108,10 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .slider:before {
|
||||
background-color: var(--button-primary-bg);
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #009688;
|
||||
}
|
||||
@@ -143,14 +148,13 @@ input:checked + .slider:before {
|
||||
.brightness-btn[data-brightness="5"] { background-color: #f3f4f6; color: #1f2937; border-color: #d1d5db; }
|
||||
|
||||
.brightness-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.brightness-btn.active {
|
||||
border-color: #2563eb;
|
||||
border-width: 3px;
|
||||
box-shadow: 0 0 8px rgba(37, 99, 235, 0.5);
|
||||
border-color: var(--primary-color);
|
||||
border-width: 1px;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
/* Dark theme - progressively brighter backgrounds with better contrast */
|
||||
@@ -161,15 +165,8 @@ input:checked + .slider:before {
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="4"] { background-color: #64748b; color: #f1f5f9; border-color: #94a3b8; }
|
||||
[data-theme="dark"] .brightness-btn[data-brightness="5"] { background-color: #94a3b8; color: #0f172a; border-color: #cbd5e1; }
|
||||
|
||||
[data-theme="dark"] .brightness-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 4px rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .brightness-btn.active {
|
||||
border-color: #60a5fa;
|
||||
border-width: 3px;
|
||||
box-shadow: 0 0 8px rgba(96, 165, 250, 0.5);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Make ordering buttons narrower and prevent overflow */
|
||||
@@ -234,26 +231,26 @@ button.action.w3-button:focus {
|
||||
/* Ensure specific button colors work correctly */
|
||||
.action.w3-button.w3-blue,
|
||||
button.action.w3-button.w3-blue {
|
||||
background-color: #2196F3 !important;
|
||||
color: white !important;
|
||||
background-color: var(--button-primary-bg) !important;
|
||||
color: var(--button-primary-text) !important;
|
||||
}
|
||||
|
||||
.action.w3-button.w3-green,
|
||||
button.action.w3-button.w3-green {
|
||||
background-color: #4CAF50 !important;
|
||||
color: white !important;
|
||||
background-color: var(--button-primary-bg) !important;
|
||||
color: var(--button-primary-text) !important;
|
||||
}
|
||||
|
||||
.action.w3-button.w3-orange,
|
||||
button.action.w3-button.w3-orange {
|
||||
background-color: #ff9800 !important;
|
||||
color: white !important;
|
||||
background-color: color-mix(in srgb, var(--button-bg) 88%, #c9691a 12%) !important;
|
||||
color: var(--page-text) !important;
|
||||
}
|
||||
|
||||
.action.w3-button.w3-red,
|
||||
button.action.w3-button.w3-red {
|
||||
background-color: #f44336 !important;
|
||||
color: white !important;
|
||||
background-color: color-mix(in srgb, var(--button-bg) 88%, #c63c37 12%) !important;
|
||||
color: var(--danger-text) !important;
|
||||
}
|
||||
|
||||
/* App card buttons with no hover effects */
|
||||
@@ -280,8 +277,46 @@ button.action.w3-button.w3-red {
|
||||
/* App Card Layout Styles */
|
||||
.app-card {
|
||||
margin-bottom: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-bg);
|
||||
box-shadow: 0 8px 24px var(--shadow-color);
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.app-card[data-status="enabled"] {
|
||||
border-left: 3px solid color-mix(in srgb, var(--primary-color) 30%, var(--border-color));
|
||||
}
|
||||
|
||||
.app-card[data-status="disabled"] {
|
||||
background: color-mix(in srgb, var(--surface-subtle) 88%, var(--surface-bg));
|
||||
border-color: color-mix(in srgb, var(--disabled-text) 35%, var(--border-color));
|
||||
border-left: 3px solid color-mix(in srgb, var(--disabled-text) 60%, var(--border-color));
|
||||
box-shadow: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.app-card[data-status="disabled"] .app-preview .app-img img {
|
||||
opacity: 0.42;
|
||||
filter: grayscale(0.65) saturate(0.7);
|
||||
}
|
||||
|
||||
.app-card[data-status="disabled"] .app-preview .app-img {
|
||||
background: color-mix(in srgb, var(--surface-subtle) 92%, transparent);
|
||||
}
|
||||
|
||||
.app-card[data-status="disabled"] .app-header h3,
|
||||
.app-card[data-status="disabled"] .app-details p {
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.app-card[data-status="disabled"] .app-render-info {
|
||||
color: color-mix(in srgb, var(--muted-text) 88%, transparent);
|
||||
}
|
||||
|
||||
.app-card[data-status="disabled"] .app-actions {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.app-card-content {
|
||||
@@ -303,7 +338,7 @@ button.action.w3-button.w3-red {
|
||||
margin-bottom: 8px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
width: 256px;
|
||||
height: 128px;
|
||||
@@ -313,7 +348,6 @@ button.action.w3-button.w3-red {
|
||||
.app-preview .app-img img {
|
||||
width: 256px;
|
||||
height: 128px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
@@ -332,18 +366,17 @@ button.action.w3-button.w3-red {
|
||||
}
|
||||
|
||||
.inactive-badge-overlay {
|
||||
background-color: #6b7280;
|
||||
color: white;
|
||||
background-color: rgba(255, 255, 255, 0.88);
|
||||
color: #111827;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .inactive-badge-overlay {
|
||||
background-color: #9ca3af;
|
||||
color: #0f172a;
|
||||
background-color: rgba(17, 19, 21, 0.88);
|
||||
color: #f5f7fa;
|
||||
}
|
||||
|
||||
.app-render-info {
|
||||
@@ -376,8 +409,8 @@ button.action.w3-button.w3-red {
|
||||
|
||||
.app-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@@ -387,109 +420,56 @@ button.action.w3-button.w3-red {
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.status-badge.pinned {
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
background-color: color-mix(in srgb, var(--surface-bg) 84%, #c58a0b 16%);
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.pinned-badge {
|
||||
font-size: 0.5em;
|
||||
vertical-align: middle;
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
background-color: color-mix(in srgb, var(--surface-bg) 84%, #c58a0b 16%);
|
||||
color: var(--page-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-radius: 999px;
|
||||
margin-left: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pinned-badge {
|
||||
background-color: #fbbf24;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.night-mode-badge,
|
||||
.dim-mode-badge {
|
||||
font-size: 0.5em;
|
||||
vertical-align: middle;
|
||||
color: white;
|
||||
color: var(--page-text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-radius: 999px;
|
||||
margin-left: 10px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .night-mode-badge,
|
||||
[data-theme="dark"] .dim-mode-badge {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.night-mode-badge {
|
||||
background-color: #3f51b5;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .night-mode-badge {
|
||||
background-color: #7986cb;
|
||||
background-color: color-mix(in srgb, var(--surface-bg) 86%, #5a69c8 14%);
|
||||
}
|
||||
|
||||
.dim-mode-badge {
|
||||
background-color: #607d8b;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dim-mode-badge {
|
||||
background-color: #90a4ae;
|
||||
}
|
||||
|
||||
.status-badge.enabled {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.disabled {
|
||||
background-color: #6b7280;
|
||||
color: white;
|
||||
background-color: color-mix(in srgb, var(--surface-bg) 86%, #728896 14%);
|
||||
}
|
||||
|
||||
.status-badge.autopin {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
background-color: color-mix(in srgb, var(--surface-bg) 84%, #4a78d8 16%);
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.status-badge.inactive {
|
||||
background-color: #f97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Dark mode status badges */
|
||||
[data-theme="dark"] .status-badge.pinned {
|
||||
background-color: #fbbf24;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-badge.enabled {
|
||||
background-color: #34d399;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-badge.autopin {
|
||||
background-color: #60a5fa;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-badge.disabled {
|
||||
background-color: #9ca3af;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-badge.inactive {
|
||||
background-color: #fb923c;
|
||||
color: #0f172a;
|
||||
background-color: color-mix(in srgb, var(--surface-bg) 84%, #c9691a 16%);
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.app-details {
|
||||
@@ -500,15 +480,10 @@ button.action.w3-button.w3-red {
|
||||
.app-details p {
|
||||
margin: 6px 0;
|
||||
font-size: 0.9em;
|
||||
color: #374151;
|
||||
color: var(--page-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Dark mode app details text */
|
||||
[data-theme="dark"] .app-details p {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -528,7 +503,7 @@ button.action.w3-button.w3-red {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
opacity: 0.8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Standardized button sizes for all app actions - both buttons and anchor tags */
|
||||
@@ -559,9 +534,9 @@ button.action.w3-button {
|
||||
.app-actions-secondary .w3-button.w3-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background-color: #f5f5f5 !important;
|
||||
color: #999 !important;
|
||||
border-color: #ddd !important;
|
||||
background-color: var(--surface-subtle) !important;
|
||||
color: var(--muted-text) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -573,27 +548,27 @@ button.action.w3-button {
|
||||
|
||||
@keyframes moveHighlight {
|
||||
0% {
|
||||
background-color: #dbeafe;
|
||||
border: 2px solid #2563eb;
|
||||
box-shadow: 0 0 20px rgba(37, 99, 235, 0.5), 0 4px 10px 0 rgba(0,0,0,0.2), 0 4px 20px 0 rgba(0,0,0,0.19);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
30% {
|
||||
background-color: #e0f2fe;
|
||||
border: 2px solid #2563eb;
|
||||
box-shadow: 0 0 15px rgba(37, 99, 235, 0.3), 0 4px 10px 0 rgba(0,0,0,0.2), 0 4px 20px 0 rgba(0,0,0,0.19);
|
||||
background-color: var(--primary-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 40%, var(--border-color));
|
||||
box-shadow: 0 12px 30px var(--shadow-color);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
70% {
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 0 8px rgba(37, 99, 235, 0.1), 0 4px 10px 0 rgba(0,0,0,0.2), 0 4px 20px 0 rgba(0,0,0,0.19);
|
||||
30% {
|
||||
background-color: var(--primary-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color) 28%, var(--border-color));
|
||||
box-shadow: 0 10px 24px var(--shadow-color);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
70% {
|
||||
background-color: var(--surface-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 10px 24px var(--shadow-color);
|
||||
transform: scale(1.002);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: 0 4px 10px 0 rgba(0,0,0,0.2), 0 4px 20px 0 rgba(0,0,0,0.19);
|
||||
background-color: var(--surface-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 8px 24px var(--shadow-color);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1035,6 +1010,12 @@ button.action.w3-button {
|
||||
min-height: 60px; /* Ensure drop target exists when empty */
|
||||
}
|
||||
|
||||
.apps-list-view.collapsed {
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.drag-over-container {
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
border: 2px dashed #2196F3;
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
.settings-subnav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 0 0 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color, #d6d6d6);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-subnav-link {
|
||||
color: var(--muted-text, #666);
|
||||
text-decoration: none;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.settings-subnav-link.active {
|
||||
color: var(--page-text);
|
||||
font-weight: 600;
|
||||
background: var(--surface-bg, #fff);
|
||||
border-color: var(--border-color, #d6d6d6);
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 4px 2px 10px;
|
||||
}
|
||||
|
||||
.settings-eyebrow {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted-text, #666);
|
||||
}
|
||||
|
||||
.settings-page-header h1,
|
||||
.settings-card-header h2,
|
||||
.settings-card-subsection h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-page-copy,
|
||||
.settings-card-header p,
|
||||
.settings-help {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted-text, #666);
|
||||
line-height: 1.45;
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: 1px solid var(--border-color, #d6d6d6);
|
||||
background: var(--surface-bg, #fff);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow: 0 10px 30px var(--shadow-color, rgba(15, 23, 42, 0.06));
|
||||
}
|
||||
|
||||
.settings-card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-card-subsection {
|
||||
border-top: 1px solid var(--border-color, #d6d6d6);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.settings-form,
|
||||
.settings-field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.settings-field-list > .settings-field-row + .settings-field-row {
|
||||
border-top: 1px solid color-mix(in srgb, var(--border-color, #d6d6d6) 75%, transparent);
|
||||
}
|
||||
|
||||
.settings-field-label {
|
||||
font-weight: 600;
|
||||
padding-top: 10px;
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.settings-field-control {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-inline-group,
|
||||
.settings-actions,
|
||||
.settings-inline-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.settings-inline-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-toggle-row {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.settings-toggle-hint {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.settings-control-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
}
|
||||
|
||||
.settings-input-wide {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.settings-input-xwide {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.settings-input-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.settings-file-input {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.settings-two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-preview-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-caption {
|
||||
margin: 0;
|
||||
color: var(--muted-text, #666);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.settings-view-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-view-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-view-label {
|
||||
font-weight: 600;
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.settings-note-danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.settings-grid-compact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-meta-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--border-color, #d6d6d6);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-subtle, #f7f7f8);
|
||||
}
|
||||
|
||||
.settings-list-item,
|
||||
.settings-list-empty {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-color, #d6d6d6);
|
||||
background: var(--surface-bg, #fff);
|
||||
}
|
||||
|
||||
.settings-list li:first-child,
|
||||
.settings-list-item:first-child,
|
||||
.settings-list-empty:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.settings-list-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-user-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-user-email-form {
|
||||
margin-top: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-user-email-form .settings-input {
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.settings-api-key-row {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.settings-api-key-row .settings-input {
|
||||
flex: 1 1 32rem;
|
||||
}
|
||||
|
||||
.settings-user-actions {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
width: min(42rem, calc(100vw - 2rem));
|
||||
border: 1px solid var(--border-color, #d6d6d6);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
background: var(--surface-bg, #fff);
|
||||
box-shadow: 0 24px 60px var(--shadow-color, rgba(15, 23, 42, 0.18));
|
||||
}
|
||||
|
||||
.settings-dialog::backdrop {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.settings-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings-tag {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-color, #d6d6d6);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-text, #666);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.settings-page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-field-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-two-pane {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-user-actions {
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
+258
-120
@@ -1,148 +1,288 @@
|
||||
/* Define :root variables for light theme (default) */
|
||||
:root {
|
||||
--page-bg: #f5f5f5; /* Slightly darker for better contrast */
|
||||
--page-text: #1a1a1a; /* Darker text for better contrast */
|
||||
--content-bg: #ffffff;
|
||||
--primary-color: #2563eb; /* Improved blue with better contrast */
|
||||
--secondary-color: #e5e7eb; /* Better contrast than lightgray */
|
||||
--flash-bg: #dbeafe; /* Light blue with better contrast */
|
||||
--flash-border: #2563eb;
|
||||
--flash-text: #1e40af; /* Darker blue text for better contrast */
|
||||
--link-color: #2563eb;
|
||||
--header-text-color: #2563eb;
|
||||
--post-about-text: #6b7280; /* Better contrast than slategray */
|
||||
color-scheme: light;
|
||||
--font-sans: "Manrope", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--page-bg: #f4f5f7;
|
||||
--page-text: #171717;
|
||||
--muted-text: #666c75;
|
||||
--content-bg: #fbfbfc;
|
||||
--surface-bg: #ffffff;
|
||||
--surface-subtle: #f7f7f8;
|
||||
--primary-color: #2563eb;
|
||||
--primary-soft: #eff6ff;
|
||||
--link-color: #1f4fd1;
|
||||
--header-text-color: #111827;
|
||||
--flash-bg: #eff6ff;
|
||||
--flash-border: #bfdbfe;
|
||||
--flash-text: #1e3a8a;
|
||||
--input-bg: #ffffff;
|
||||
--input-text: #1a1a1a;
|
||||
--input-border: #d1d5db; /* Better contrast than #ccc */
|
||||
--button-bg: #f3f4f6; /* Slightly darker for better contrast */
|
||||
--button-text: #1a1a1a;
|
||||
--submit-bg: #2563eb;
|
||||
--input-text: #111827;
|
||||
--input-border: #d7dbe1;
|
||||
--button-bg: #ffffff;
|
||||
--button-text: #111827;
|
||||
--button-primary-bg: #2f3742;
|
||||
--button-primary-hover: #3a4350;
|
||||
--button-primary-text: #ffffff;
|
||||
--submit-bg: #111827;
|
||||
--submit-text: #ffffff;
|
||||
--danger-text: #dc2626; /* Better contrast red */
|
||||
--enabled-text: #059669; /* Better contrast green */
|
||||
--disabled-text: #dc2626; /* Consistent with danger */
|
||||
--app-img-bg: #000000;
|
||||
--app-img-border: #d1d5db; /* Better contrast */
|
||||
--danger-text: #c63c37;
|
||||
--enabled-text: #1f7a52;
|
||||
--disabled-text: #7b8088;
|
||||
--app-img-bg: #0b0c0f;
|
||||
--app-img-border: #d7dbe1;
|
||||
--select-bg: #ffffff;
|
||||
--select-text: #1a1a1a; /* Better contrast */
|
||||
--select-border: #d1d5db;
|
||||
--border-color: #e5e7eb; /* New variable for consistent borders */
|
||||
--shadow-color: rgba(0, 0, 0, 0.1); /* New variable for shadows */
|
||||
--select-text: #111827;
|
||||
--select-border: #d7dbe1;
|
||||
--border-color: #e3e6ea;
|
||||
--shadow-color: rgba(15, 23, 42, 0.06);
|
||||
--shadow-strong: rgba(15, 23, 42, 0.12);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 18px;
|
||||
}
|
||||
|
||||
/* Define variables for dark theme */
|
||||
[data-theme="dark"] {
|
||||
--page-bg: #0f172a; /* Darker background for better contrast */
|
||||
--page-text: #f8fafc; /* High contrast white text */
|
||||
--content-bg: #1e293b; /* Darker content background */
|
||||
--primary-color: #60a5fa; /* Brighter blue for dark mode */
|
||||
--secondary-color: #475569; /* Better contrast secondary */
|
||||
--flash-bg: #1e3a8a; /* Dark blue background for flash messages */
|
||||
--flash-border: #60a5fa;
|
||||
--flash-text: #dbeafe; /* Light blue text for flash messages */
|
||||
--link-color: #60a5fa;
|
||||
--header-text-color: #60a5fa;
|
||||
--post-about-text: #94a3b8; /* Better contrast gray */
|
||||
--input-bg: #334155; /* Darker input background */
|
||||
--input-text: #f8fafc;
|
||||
--input-border: #64748b; /* Better contrast border */
|
||||
--button-bg: #475569; /* Darker button background */
|
||||
--button-text: #f8fafc;
|
||||
--submit-bg: #2563eb; /* Keep same blue for consistency */
|
||||
--submit-text: #ffffff;
|
||||
--danger-text: #f87171; /* Brighter red for dark mode */
|
||||
--enabled-text: #34d399; /* Brighter green for dark mode */
|
||||
--disabled-text: #f87171; /* Consistent with danger */
|
||||
--app-img-bg: #000000;
|
||||
--app-img-border: #64748b; /* Better contrast */
|
||||
--select-bg: #334155;
|
||||
--select-text: #f8fafc;
|
||||
--select-border: #64748b;
|
||||
--border-color: #475569; /* Dark mode border color */
|
||||
--shadow-color: rgba(0, 0, 0, 0.3); /* Darker shadows for dark mode */
|
||||
color-scheme: dark;
|
||||
--page-bg: #111315;
|
||||
--page-text: #f5f7fa;
|
||||
--muted-text: #a5adb8;
|
||||
--content-bg: #17191c;
|
||||
--surface-bg: #1c1f23;
|
||||
--surface-subtle: #22262b;
|
||||
--primary-color: #76a8ff;
|
||||
--primary-soft: rgba(118, 168, 255, 0.14);
|
||||
--link-color: #8db5ff;
|
||||
--header-text-color: #f5f7fa;
|
||||
--flash-bg: rgba(118, 168, 255, 0.14);
|
||||
--flash-border: rgba(118, 168, 255, 0.35);
|
||||
--flash-text: #dce8ff;
|
||||
--input-bg: #15181c;
|
||||
--input-text: #f5f7fa;
|
||||
--input-border: #353b44;
|
||||
--button-bg: #20242a;
|
||||
--button-text: #f5f7fa;
|
||||
--button-primary-bg: #c8d0da;
|
||||
--button-primary-hover: #d9e0ea;
|
||||
--button-primary-text: #111315;
|
||||
--submit-bg: #c8d0da;
|
||||
--submit-text: #111315;
|
||||
--danger-text: #ff8c86;
|
||||
--enabled-text: #72c596;
|
||||
--disabled-text: #9aa3af;
|
||||
--app-img-border: #353b44;
|
||||
--select-bg: #15181c;
|
||||
--select-text: #f5f7fa;
|
||||
--select-border: #353b44;
|
||||
--border-color: #2c3138;
|
||||
--shadow-color: rgba(0, 0, 0, 0.22);
|
||||
--shadow-strong: rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
/* Apply variables to existing styles */
|
||||
html { font-family: sans-serif; background: var(--page-bg); color: var(--page-text); padding: 1rem; }
|
||||
body { max-width: 960px; margin: 0 auto; background: var(--content-bg); color: var(--page-text); }
|
||||
h1 { font-family: sans-serif; color: var(--header-text-color); margin: 1rem 0; }
|
||||
a { color: var(--link-color); }
|
||||
hr { border: none; border-top: 1px solid var(--secondary-color); }
|
||||
nav { background: var(--secondary-color); display: flex; align-items: center; padding: 0 0.5rem; }
|
||||
nav h1 { flex: auto; margin: 0; }
|
||||
nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; color: var(--header-text-color); } /* Ensure nav h1 link uses header text color or link color */
|
||||
nav ul { display: flex; list-style: none; margin: 0; padding: 0; }
|
||||
nav ul li a, nav ul li span, nav header .action { display: block; padding: 0.5rem; color: var(--link-color); } /* Ensure nav links use link color */
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
background: linear-gradient(180deg, var(--page-bg) 0%, var(--content-bg) 100%);
|
||||
color: var(--page-text);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
background: transparent;
|
||||
color: var(--page-text);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--header-text-color);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 2.4vw, 2.3rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
nav {
|
||||
background: color-mix(in srgb, var(--surface-bg) 88%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 30px var(--shadow-color);
|
||||
}
|
||||
|
||||
nav h1 {
|
||||
flex: auto;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
nav h1 a {
|
||||
text-decoration: none;
|
||||
color: var(--header-text-color);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li a,
|
||||
nav ul li span,
|
||||
nav header .action {
|
||||
display: block;
|
||||
padding: 0.55rem 0.75rem;
|
||||
color: var(--muted-text);
|
||||
text-decoration: none;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
nav ul li a:hover {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.25rem 0 1rem;
|
||||
}
|
||||
|
||||
.content > header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.content { padding: 0 1rem 1rem; }
|
||||
.content > header { display: flex; align-items:flex-end; }
|
||||
.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; } /* Uses h1 style */
|
||||
.flash {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--flash-bg);
|
||||
border: 1px solid var(--flash-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--flash-text);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.post > header { display: flex; align-items: flex-end; font-size: 0.85em; } /* Text color will be inherited from body */
|
||||
.post > header > div:first-of-type { flex: auto; }
|
||||
.post > header h1 { font-size: 1.5em; margin-bottom: 0; } /* Uses h1 style */
|
||||
.post .about { color: var(--post-about-text); font-style: italic; }
|
||||
.post .body { white-space: pre-line; } /* Text color will be inherited */
|
||||
.content:last-child { margin-bottom: 0; }
|
||||
.content form { margin: 1em 0; display: flex; flex-direction: column; } /* Text color will be inherited */
|
||||
.content label { font-weight: bold; margin-bottom: 0.5em; color: var(--page-text); } /* Ensure labels use page text */
|
||||
.content input, .content button, .content textarea {
|
||||
margin-bottom: 1em;
|
||||
background: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
border: 1px solid var(--input-border); /* Add border for consistency */
|
||||
padding: 0.5em; /* Add some padding */
|
||||
|
||||
.post > header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.post > header > div:first-of-type {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.post .about {
|
||||
color: var(--muted-text);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.post .body {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.content form {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4em;
|
||||
color: var(--page-text);
|
||||
}
|
||||
|
||||
.content input,
|
||||
.content button,
|
||||
.content textarea,
|
||||
.content select {
|
||||
background: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
border: 1px solid var(--input-border);
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.content input:focus,
|
||||
.content textarea:focus,
|
||||
.content select:focus,
|
||||
.content button:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--primary-color) 45%, var(--input-border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 16%, transparent);
|
||||
}
|
||||
|
||||
.content textarea {
|
||||
min-height: 10em;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Reset styles for checkboxes and radio buttons */
|
||||
.content input[type="checkbox"],
|
||||
.content input[type="radio"] {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: initial;
|
||||
border: initial;
|
||||
appearance: auto;
|
||||
-webkit-appearance: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
.content button { /* Style generic buttons */
|
||||
background: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border: 1px solid var(--input-border); /* Or a specific button border variable */
|
||||
}
|
||||
.content textarea { min-height: 12em; resize: vertical; }
|
||||
input.danger { color: var(--danger-text); background: transparent; border: none; } /* Make danger text inputs stand out */
|
||||
input[type=submit] {
|
||||
align-self: start;
|
||||
min-width: 10em;
|
||||
background: var(--submit-bg);
|
||||
color: var(--submit-text);
|
||||
border: none; /* Submit buttons often don't have a border or have a matching one */
|
||||
}
|
||||
/* Use consistent classes: .text-enabled and .text-disabled */
|
||||
.text-enabled { color: var(--enabled-text); }
|
||||
.text-disabled { color: var(--disabled-text); }
|
||||
|
||||
.text-enabled {
|
||||
color: var(--enabled-text);
|
||||
}
|
||||
|
||||
.text-disabled {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
/* app-img styles with variables */
|
||||
.app-img {
|
||||
position: relative;
|
||||
background: var(--app-img-bg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-img-border);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.app-img img {
|
||||
aspect-ratio: 2 / 1;
|
||||
background: var(--app-img-bg); /* Match parent background */
|
||||
background: var(--app-img-bg);
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
@@ -155,21 +295,20 @@ input[type=submit] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-img.is-2x img {
|
||||
-webkit-mask-image: url('/dots?w=128&h=64&r=0.4');
|
||||
mask-image: url('/dots?w=128&h=64&r=0.4');
|
||||
}
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #0009 25%, #fff3 50%, #0009 75%);
|
||||
background: linear-gradient(90deg, transparent 15%, rgba(255, 255, 255, 0.18) 50%, transparent 85%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
animation: loading 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
@@ -177,34 +316,33 @@ input[type=submit] {
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Hide skeleton when image loads */
|
||||
.app-img img.loaded + .skeleton-loader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Theme Toggle Styles - now using variables */
|
||||
.theme-toggle-container {
|
||||
display: inline-block;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 0.5rem;
|
||||
margin-left: 6px;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.theme-toggle label {
|
||||
margin-right: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--page-text); /* Ensure label color matches theme */
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.theme-toggle select {
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--select-border);
|
||||
background-color: var(--select-bg);
|
||||
font-size: 0.9em;
|
||||
color: var(--select-text);
|
||||
}
|
||||
|
||||
/* Theming is now handled by [data-theme] attribute set via JavaScript */
|
||||
|
||||
@@ -102,6 +102,70 @@ function updateInterval(deviceId, interval) {
|
||||
});
|
||||
}
|
||||
|
||||
function setNightModeOverride(deviceId, active, input) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('active', active);
|
||||
|
||||
if (input) {
|
||||
input.disabled = true;
|
||||
}
|
||||
|
||||
fetch(`/devices/${deviceId}/set_night_mode_override`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString()
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || 'Failed to update night mode');
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
if (input) {
|
||||
input.checked = !active;
|
||||
input.disabled = false;
|
||||
}
|
||||
alert(error.message || 'Failed to update night mode');
|
||||
});
|
||||
}
|
||||
|
||||
function setDimModeOverride(deviceId, active, input) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('active', active);
|
||||
|
||||
if (input) {
|
||||
input.disabled = true;
|
||||
}
|
||||
|
||||
fetch(`/devices/${deviceId}/set_dim_mode_override`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: formData.toString()
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || 'Failed to update dim mode');
|
||||
}
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Unexpected error:', error);
|
||||
if (input) {
|
||||
input.checked = !active;
|
||||
input.disabled = false;
|
||||
}
|
||||
alert(error.message || 'Failed to update dim mode');
|
||||
});
|
||||
}
|
||||
|
||||
// Function to toggle the visibility of the device details
|
||||
function toggleDetails(deviceId) {
|
||||
const details = document.getElementById(`details-${deviceId}`);
|
||||
@@ -1552,6 +1616,7 @@ function restoreDevicePreferences(deviceId) {
|
||||
appsList.classList.remove('apps-grid-view');
|
||||
appsList.classList.add('apps-list-view', 'collapsed');
|
||||
appsList.style.maxHeight = "0";
|
||||
appsList.style.minHeight = "0";
|
||||
appsList.style.overflow = "hidden";
|
||||
appsList.style.padding = "0";
|
||||
} else {
|
||||
@@ -1583,6 +1648,7 @@ function switchToListView(deviceId) {
|
||||
appsList.classList.remove('apps-grid-view', 'collapsed');
|
||||
appsList.classList.add('apps-list-view');
|
||||
appsList.style.maxHeight = "none";
|
||||
appsList.style.minHeight = "";
|
||||
appsList.style.overflow = "visible";
|
||||
appsList.style.padding = "";
|
||||
|
||||
@@ -1611,6 +1677,7 @@ function switchToGridView(deviceId) {
|
||||
appsList.classList.remove('apps-list-view', 'collapsed');
|
||||
appsList.classList.add('apps-grid-view');
|
||||
appsList.style.maxHeight = "none";
|
||||
appsList.style.minHeight = "";
|
||||
appsList.style.overflow = "visible";
|
||||
appsList.style.padding = "";
|
||||
|
||||
@@ -1639,6 +1706,7 @@ function switchToCollapsedView(deviceId) {
|
||||
appsList.classList.remove('apps-grid-view');
|
||||
appsList.classList.add('apps-list-view', 'collapsed');
|
||||
appsList.style.maxHeight = "0";
|
||||
appsList.style.minHeight = "0";
|
||||
appsList.style.overflow = "hidden";
|
||||
appsList.style.padding = "0";
|
||||
|
||||
|
||||
+60
-64
@@ -21,11 +21,16 @@
|
||||
</head>
|
||||
<title>{{ block "title" . }}{{ end }} - {{ t .Localizer "Tronbyt Manager" }}</title>
|
||||
<link rel="icon" href="/static/favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;650;700;800&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/w3.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/base.css">
|
||||
<link rel="stylesheet" href="/static/css/common.css">
|
||||
<link rel="stylesheet" href="/static/css/partials.css">
|
||||
<link rel="stylesheet" href="/static/css/settings-framework.css">
|
||||
<link rel="stylesheet" href="/static/css/fontawesome.min.css">
|
||||
<link rel="stylesheet" href="/static/css/brands.min.css">
|
||||
<link rel="stylesheet" href="/static/css/solid.min.css">
|
||||
@@ -43,26 +48,14 @@
|
||||
<li>
|
||||
<a href="/">{{ t .Localizer "Home" }}</a>
|
||||
</li>
|
||||
{{ if .User.IsAdmin }}
|
||||
<li>
|
||||
<a href="/auth/register">{{ t .Localizer "Create User" }}</a>
|
||||
<a href="/settings/account">{{ t .Localizer "System Settings" }}</a>
|
||||
</li>
|
||||
{{ if not .IsAutoLoginActive }}
|
||||
<li>
|
||||
<a href="/auth/logout">{{ t .Localizer "Log Out" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li>
|
||||
<a href="/auth/edit">{{ .User.Username }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/auth/logout"
|
||||
{{ if .IsAutoLoginActive }}
|
||||
onclick="event.preventDefault(); alert('{{ t .Localizer "Auto-login mode is enabled. You cannot log out while this mode is active. To disable auto-login, set SINGLE_USER_AUTO_LOGIN=0 in your .env file or create a second user." }}'); return false;"
|
||||
{{ end }}>
|
||||
{{ if .IsAutoLoginActive }}
|
||||
<span class="auto-login-indicator"
|
||||
title="{{ t .Localizer "Auto-login mode active" }}">⚠️</span>
|
||||
{{ end }}
|
||||
{{ t .Localizer "Log Out" }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="theme-toggle-container">
|
||||
<div class="theme-toggle">
|
||||
<label for="theme-select">{{ t .Localizer "Theme:" }}</label>
|
||||
@@ -115,62 +108,65 @@
|
||||
<li>
|
||||
<a href="/" onclick="closeMobileMenu()">{{ t .Localizer "Home" }}</a>
|
||||
</li>
|
||||
{{ if .User.IsAdmin }}
|
||||
<li>
|
||||
<a href="/settings/account" onclick="closeMobileMenu()">{{ t .Localizer "System Settings" }}</a>
|
||||
</li>
|
||||
{{ if not .IsAutoLoginActive }}
|
||||
<li>
|
||||
<a href="/auth/logout" onclick="closeMobileMenu()">{{ t .Localizer "Log Out" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li class="theme-toggle-container">
|
||||
<div class="theme-toggle">
|
||||
<label for="mobile-theme-select">{{ t .Localizer "Theme:" }}</label>
|
||||
<select id="mobile-theme-select" name="theme">
|
||||
<option value="light"
|
||||
{{ if eq (string .User.ThemePreference) "light" }}
|
||||
selected
|
||||
{{ end }}>{{ t .Localizer "Light" }}
|
||||
</option>
|
||||
<option value="dark"
|
||||
{{ if eq (string .User.ThemePreference) "dark" }}
|
||||
selected
|
||||
{{ end }}>{{ t .Localizer "Dark" }}
|
||||
</option>
|
||||
<option value="system"
|
||||
{{ if or (eq (string .User.ThemePreference) "system") (eq (string .User.ThemePreference) "") }}
|
||||
selected
|
||||
{{ end }}>{{ t .Localizer "System" }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
{{ else }}
|
||||
<li>
|
||||
<a href="/auth/login" onclick="closeMobileMenu()">{{ t .Localizer "Log In" }}</a>
|
||||
</li>
|
||||
{{ if .Config.EnableUserRegistration }}
|
||||
<li>
|
||||
<a href="/auth/register" onclick="closeMobileMenu()">{{ t .Localizer "Create User" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<li>
|
||||
<a href="/auth/edit" onclick="closeMobileMenu()">{{ .User.Username }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/auth/logout"
|
||||
onclick="{{ if .IsAutoLoginActive }}event.preventDefault(); alert('{{ t .Localizer "Auto-login mode is enabled. You cannot log out while this mode is active. To disable auto-login, set SINGLE_USER_AUTO_LOGIN=0 in your .env file or create a second user." }}'); return false;{{ else }}closeMobileMenu(){{ end }}">
|
||||
{{ if .IsAutoLoginActive }}
|
||||
<span class="auto-login-indicator"
|
||||
title="{{ t .Localizer "Auto-login mode active" }}">⚠️</span>
|
||||
{{ end }}
|
||||
{{ t .Localizer "Log Out" }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="theme-toggle-container">
|
||||
<div class="theme-toggle">
|
||||
<label for="mobile-theme-select">{{ t .Localizer "Theme:" }}</label>
|
||||
<select id="mobile-theme-select" name="theme">
|
||||
<option value="light"
|
||||
{{ if eq (string .User.ThemePreference) "light" }}
|
||||
selected
|
||||
{{ end }}>{{ t .Localizer "Light" }}
|
||||
</option>
|
||||
<option value="dark"
|
||||
{{ if eq (string .User.ThemePreference) "dark" }}
|
||||
selected
|
||||
{{ end }}>{{ t .Localizer "Dark" }}
|
||||
</option>
|
||||
<option value="system"
|
||||
{{ if or (eq (string .User.ThemePreference) "system") (eq (string .User.ThemePreference) "") }}
|
||||
selected
|
||||
{{ end }}>{{ t .Localizer "System" }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
{{ else }}
|
||||
<li>
|
||||
<a href="/auth/login" onclick="closeMobileMenu()">{{ t .Localizer "Log In" }}</a>
|
||||
</li>
|
||||
{{ if .Config.EnableUserRegistration }}
|
||||
<li>
|
||||
<a href="/auth/register" onclick="closeMobileMenu()">{{ t .Localizer "Create User" }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
<section class="content">
|
||||
<header>
|
||||
{{ block "header" . }}{{ end }}
|
||||
</header>
|
||||
{{ if .SettingsSection }}
|
||||
<nav class="settings-subnav"
|
||||
aria-label="{{ t .Localizer "System Settings" }}">
|
||||
<a href="/settings/account"
|
||||
class="settings-subnav-link {{ if eq .SettingsSection "account" }}active{{ end }}">{{ t .Localizer "Account" }}</a>
|
||||
<a href="/settings/content"
|
||||
class="settings-subnav-link {{ if eq .SettingsSection "content" }}active{{ end }}">{{ t .Localizer "Content and Firmware" }}</a>
|
||||
{{ if and .User .User.IsAdmin }}
|
||||
<a href="/settings/admin"
|
||||
class="settings-subnav-link {{ if eq .SettingsSection "admin" }}active{{ end }}">{{ t .Localizer "Admin" }}</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
{{ end }}
|
||||
{{- if .Flashes -}}
|
||||
<div class="flash">
|
||||
{{- range .Flashes -}}
|
||||
|
||||
@@ -4,67 +4,110 @@
|
||||
{{ define "title" }}{{ t .Localizer "Add App" }}{{ end }}
|
||||
{{ define "header" }}
|
||||
<link rel="stylesheet" href="/static/css/addapp-simple.css">
|
||||
<h1>{{ t .Localizer "Add App" }}</h1>
|
||||
{{ end }}
|
||||
{{ define "content" }}
|
||||
<div id="addapp_page_identifier">
|
||||
<div id="addapp_page_identifier" class="addapp-shell settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<p class="settings-eyebrow">{{ t .Localizer "Apps" }}</p>
|
||||
<h1>{{ t .Localizer "Add App" }}</h1>
|
||||
<p class="settings-page-copy">
|
||||
{{ t .Localizer "Choose an app to install on this device, then configure its timing and options." }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading-indicator" class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>{{ t .Localizer "Loading apps..." }}</p>
|
||||
</div>
|
||||
<a class="w3-button w3-purple w3-round w3-padding"
|
||||
href="/devices/{{ .Device.ID }}/uploadapp"><i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i> {{ t .Localizer "Upload app" }}</a>
|
||||
<form method="post" id="main_form">
|
||||
<label for="name">{{ t .Localizer "App Selection" }}</label>
|
||||
{{ if .CustomApps }}
|
||||
{{ template "app_list_grid" (dict
|
||||
"Title" (t .Localizer "Custom Apps")
|
||||
"SearchID" "custom_search"
|
||||
"GridID" "custom_app_grid"
|
||||
"Apps" .CustomApps
|
||||
"IsCustom" true
|
||||
"DeviceID" .Device.ID
|
||||
"Localizer" .Localizer
|
||||
"ConfigProduction" .Config.Production
|
||||
) }}
|
||||
<hr>
|
||||
{{ end }}
|
||||
{{ template "app_list_grid" (dict
|
||||
"Title" (t .Localizer "System Apps")
|
||||
"SearchID" "system_search"
|
||||
"GridID" "system_app_grid"
|
||||
"Apps" .SystemApps
|
||||
"ShowControls" true
|
||||
"ShowVersion" true
|
||||
"IsCustom" false
|
||||
"DeviceID" .Device.ID
|
||||
"Localizer" .Localizer
|
||||
"SystemRepoInfo" .SystemRepoInfo
|
||||
"ConfigProduction" .Config.Production
|
||||
) }}
|
||||
<div class="addapp-actions">
|
||||
<a class="w3-button w3-purple"
|
||||
href="/devices/{{ .Device.ID }}/uploadapp"><i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i> {{ t .Localizer "Upload app" }}</a>
|
||||
</div>
|
||||
<form method="post" id="main_form" class="addapp-config">
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "App Selection" }}</h2>
|
||||
</div>
|
||||
{{ if .CustomApps }}
|
||||
{{ template "app_list_grid" (dict
|
||||
"Title" (t .Localizer "Custom Apps")
|
||||
"SearchID" "custom_search"
|
||||
"GridID" "custom_app_grid"
|
||||
"Apps" .CustomApps
|
||||
"IsCustom" true
|
||||
"DeviceID" .Device.ID
|
||||
"Localizer" .Localizer
|
||||
"ConfigProduction" .Config.Production
|
||||
) }}
|
||||
<hr>
|
||||
{{ end }}
|
||||
{{ template "app_list_grid" (dict
|
||||
"Title" (t .Localizer "System Apps")
|
||||
"SearchID" "system_search"
|
||||
"GridID" "system_app_grid"
|
||||
"Apps" .SystemApps
|
||||
"ShowControls" true
|
||||
"ShowVersion" true
|
||||
"IsCustom" false
|
||||
"DeviceID" .Device.ID
|
||||
"Localizer" .Localizer
|
||||
"SystemRepoInfo" .SystemRepoInfo
|
||||
"ConfigProduction" .Config.Production
|
||||
) }}
|
||||
</section>
|
||||
<input type="hidden" name="name" id="selected_app">
|
||||
<input type="hidden" name="path" id="selected_app_path">
|
||||
<br>
|
||||
<label for="uinterval">{{ t .Localizer "Render Interval (minutes)" }}</label>
|
||||
<input name="uinterval"
|
||||
type="number"
|
||||
id="uinterval"
|
||||
min="0"
|
||||
value="-1"
|
||||
required>
|
||||
<label for="display_time">{{ t .Localizer "Display Time (seconds)" }}</label>
|
||||
<input name="display_time"
|
||||
type="number"
|
||||
id="display_time"
|
||||
min="0"
|
||||
value="0"
|
||||
required>
|
||||
<label for="notes">{{ t .Localizer "Notes" }}</label>
|
||||
<textarea name="notes" id="notes"></textarea>
|
||||
<button type="submit" class="w3-button w3-green">
|
||||
<i class="fa-solid fa-sliders" aria-hidden="true"></i> {{ t .Localizer "Configure" }}
|
||||
</button>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Configuration" }}</h2>
|
||||
<p>{{ t .Localizer "Set timing and notes before opening the app configuration screen." }}</p>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="uinterval">{{ t .Localizer "Render Interval (minutes)" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input name="uinterval"
|
||||
type="number"
|
||||
id="uinterval"
|
||||
min="0"
|
||||
value="-1"
|
||||
required
|
||||
class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="display_time">{{ t .Localizer "Display Time (seconds)" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input name="display_time"
|
||||
type="number"
|
||||
id="display_time"
|
||||
min="0"
|
||||
value="0"
|
||||
required
|
||||
class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="notes">{{ t .Localizer "Notes" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<textarea name="notes" id="notes" class="settings-input settings-input-wide"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="w3-button w3-green">
|
||||
<i class="fa-solid fa-sliders" aria-hidden="true"></i> {{ t .Localizer "Configure" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "device_tv"}}
|
||||
{{ define "device_tv" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -142,4 +142,4 @@
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
{{ end }}
|
||||
|
||||
+174
-178
@@ -4,119 +4,82 @@
|
||||
{{ define "title" }}{{ t .Localizer "Firmware" }}{{ end }}
|
||||
{{ define "content" }}
|
||||
<link rel="stylesheet" href="/static/css/firmware-simple.css">
|
||||
<div class="header-container">
|
||||
<h1 class="page-title">{{ t .Localizer "Generate Firmware for" }} {{ .Device.Name }} - {{ .Device.Type.String }}</h1>
|
||||
</div>
|
||||
{{ if not .FirmwareBinsAvailable }}
|
||||
<div class="w3-panel w3-yellow w3-border w3-border-amber w3-round-large">
|
||||
<h3>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ t .Localizer "Firmware Files Not Available" }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ t .Localizer "Firmware binary files have not been downloaded yet. Firmware generation will not work until the files are downloaded." }}
|
||||
</p>
|
||||
{{ if .User.IsAdmin }}
|
||||
<strong>{{ t .Localizer "Action Required:" }}</strong> {{ t .Localizer "Please go to the" }}
|
||||
<a href="/auth/edit#firmware-management"
|
||||
class="w3-text-blue"
|
||||
style="text-decoration: underline">{{ t .Localizer "Admin page" }}</a>
|
||||
{{ t .Localizer "to download the latest firmware files." }}
|
||||
{{ else }}
|
||||
<p>{{ t .Localizer "Please contact your administrator to download the firmware files." }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .FirmwareVersion }}
|
||||
<div class="firmware-info">
|
||||
<div>
|
||||
<strong>{{ t .Localizer "Firmware Image Version" }}:</strong>
|
||||
{{ if .AvailableFirmwareVersions }}
|
||||
<select name="version"
|
||||
form="firmware-form"
|
||||
class="device-settings-input"
|
||||
style="width: auto;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
padding: 2px 5px">
|
||||
{{ range .AvailableFirmwareVersions }}
|
||||
<option value="{{ . }}" {{ if eq . $.FirmwareVersion }}selected{{ end }}>{{ . }}
|
||||
</option>
|
||||
<div class="settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<p class="settings-eyebrow">{{ t .Localizer "Device Firmware" }}</p>
|
||||
<h1>{{ .Device.Name }}</h1>
|
||||
<p class="settings-page-copy">{{ .Device.Type.String }}</p>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
{{ if .User.IsAdmin }}
|
||||
<a href="/settings/content" class="w3-button w3-gray">
|
||||
<i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> {{ t .Localizer "Manage Firmware" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{ else }}
|
||||
{{ .FirmwareVersion }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if .User.IsAdmin }}
|
||||
<div>
|
||||
<a href="/auth/edit#firmware-management"
|
||||
class="w3-button w3-small w3-gray firmware-management-btn">
|
||||
<i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> {{ t .Localizer "Manage Firmware" }}
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<form method="post" id="firmware-form">
|
||||
<div class="device-settings-section">
|
||||
<h2>{{ t .Localizer "Firmware Generation" }}</h2>
|
||||
<div class="device-settings-section-divider">
|
||||
<h3>{{ t .Localizer "Flashing Instructions" }}</h3>
|
||||
<p>{{ t .Localizer "After generating and downloading the firmware file, use one of these web-based flashers:" }}</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://espressif.github.io/esptool-js/" target="_blank">Espressif Web Flasher</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://thelastoutpostworkshop.github.io/ESPConnect/"
|
||||
target="_blank">ESPConnect Web Flasher</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a href="/devices/{{ .Device.ID }}/update" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t .Localizer "Edit Device" }}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{{ if not .FirmwareBinsAvailable }}
|
||||
<section class="settings-card w3-panel w3-yellow w3-border w3-border-amber">
|
||||
<div class="settings-card-header">
|
||||
<h2>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ t .Localizer "Firmware Files Not Available" }}
|
||||
</h2>
|
||||
<p>
|
||||
<strong>{{ t .Localizer "Steps:" }}</strong>
|
||||
</p>
|
||||
<ol>
|
||||
<li>{{ t .Localizer "Connect your device to your computer with a data USB cable" }}</li>
|
||||
<li>{{ t .Localizer "Open one of the web flashers above in Chrome or Edge" }}</li>
|
||||
<li>{{ t .Localizer "Click Connect and select your device's serial port" }}</li>
|
||||
<li>{{ t .Localizer "Click Flash Tools if needed" }}</li>
|
||||
<li>
|
||||
{{ t .Localizer "Set the flash address to" }} <strong>0x0</strong>
|
||||
</li>
|
||||
<li>{{ t .Localizer "Select your downloaded firmware file and click Program/Flash" }}</li>
|
||||
<li>{{ t .Localizer "Reboot the device if it doesn't restart automatically" }}</li>
|
||||
</ol>
|
||||
<p>
|
||||
<i>{{ t .Localizer "macOS and Windows users may need to install a serial driver:" }}
|
||||
<br>
|
||||
<a href="https://www.silabs.com/developer-tools/usb-to-uart-bridge-vcp-drivers?tab=downloads"
|
||||
target="_blank">CP210x Drivers</a>
|
||||
<br>
|
||||
<a href="https://github.com/WCHSoftGroup/ch34xser_macos" target="_blank">CH34x Drivers</a>
|
||||
</i>
|
||||
{{ t .Localizer "Firmware binary files have not been downloaded yet. Firmware generation will not work until the files are downloaded." }}
|
||||
</p>
|
||||
</div>
|
||||
<input type="hidden" name="id" id="id" value="{{ .Device.ID }}">
|
||||
<div class="device-settings-section-divider">
|
||||
<h3>{{ t .Localizer "Connection Settings" }}</h3>
|
||||
{{ if .URLWarning }}
|
||||
<div class="w3-panel w3-red w3-border w3-border-red w3-round-large"
|
||||
style="margin-bottom: 10px">
|
||||
<h4>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ t .Localizer "Warning: Localhost URL Detected" }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ t .Localizer "The firmware image URL uses localhost which will not work:" }} <strong>{{ .Device.ImgURL }}</strong>. {{ t .Localizer "Either replace localhost with the IP or hostname of this server, or connect to this server over its IP or hostname, edit the device and click the refresh buttons by the device's image URLs, then return here." }}
|
||||
</p>
|
||||
{{ if .User.IsAdmin }}
|
||||
<p>
|
||||
{{ t .Localizer "Please go to the" }} <a href="/settings/content" class="w3-text-blue">{{ t .Localizer "Content and Firmware" }}</a> {{ t .Localizer "page to download the latest firmware files." }}
|
||||
</p>
|
||||
{{ else }}
|
||||
<p>{{ t .Localizer "Please contact your administrator to download the firmware files." }}</p>
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ end }}
|
||||
{{ if .URLWarning }}
|
||||
<section class="settings-card w3-panel w3-red w3-border w3-border-red">
|
||||
<div class="settings-card-header">
|
||||
<h2>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> {{ t .Localizer "Warning: Localhost URL Detected" }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ t .Localizer "The firmware image URL uses localhost which will not work:" }} <strong>{{ .Device.ImgURL }}</strong>. {{ t .Localizer "Either replace localhost with the IP or hostname of this server, or connect to this server over its IP or hostname, edit the device and click the refresh buttons by the device's image URLs, then return here." }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
<form method="post" id="firmware-form" class="settings-form">
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Firmware Configuration" }}</h2>
|
||||
<p>{{ t .Localizer "Configure the values that will be baked into the downloaded firmware file." }}</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
<table class="device-settings-table">
|
||||
<tr>
|
||||
<td>
|
||||
<label class="device-settings-label">{{ t .Localizer "Connection Type" }}</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="connection-type-container">
|
||||
<input type="hidden" name="id" id="id" value="{{ .Device.ID }}">
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Firmware Image Version" }}</div>
|
||||
<div class="settings-field-control">
|
||||
{{ if .AvailableFirmwareVersions }}
|
||||
<select name="version" form="firmware-form" class="settings-input">
|
||||
{{ range .AvailableFirmwareVersions }}
|
||||
<option value="{{ . }}" {{ if eq . $.FirmwareVersion }}selected{{ end }}>{{ . }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
{{ else }}
|
||||
<div>{{ .FirmwareVersion }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Connection Type" }}</div>
|
||||
<div class="settings-field-control">
|
||||
<div class="settings-inline-group connection-type-container">
|
||||
<label>
|
||||
<input type="radio" name="url_variant" id="variant_ws" value="ws">
|
||||
WebSocket
|
||||
@@ -130,93 +93,127 @@
|
||||
Custom
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="img_url" class="device-settings-label">{{ t .Localizer "Image URL" }}</label>
|
||||
</td>
|
||||
<td>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="img_url">{{ t .Localizer "Image URL" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input name="img_url"
|
||||
id="img_url"
|
||||
value="{{ .Device.ImgURL }}"
|
||||
class="device-settings-input">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="device-settings-section-divider">
|
||||
<h3>{{ t .Localizer "WiFi Configuration" }}</h3>
|
||||
<table class="device-settings-table">
|
||||
<tr>
|
||||
<td>
|
||||
<label for="wifi_ap" class="device-settings-label">{{ t .Localizer "WiFi Network Name (SSID) 2.4Ghz Only" }}</label>
|
||||
</td>
|
||||
<td>
|
||||
<input name="wifi_ap" id="wifi_ap" required class="device-settings-input">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="wifi_password" class="device-settings-label">{{ t .Localizer "WiFi Password" }}</label>
|
||||
</td>
|
||||
<td>
|
||||
class="settings-input settings-input-wide">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="wifi_ap">{{ t .Localizer "WiFi Network Name (SSID) 2.4Ghz Only" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input name="wifi_ap" id="wifi_ap" required class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="wifi_password">{{ t .Localizer "WiFi Password" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input name="wifi_password"
|
||||
id="wifi_password"
|
||||
required
|
||||
class="device-settings-input">
|
||||
</td>
|
||||
</tr>
|
||||
class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
{{ if eq .Device.Type.Slug "tidbyt_gen1" }}
|
||||
<tr>
|
||||
<td>
|
||||
<label for="swap_colors" class="device-settings-label">{{ t .Localizer "Swap Colors?" }}</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="swap_colors" id="swap_colors">
|
||||
</td>
|
||||
</tr>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="swap_colors">{{ t .Localizer "Swap Colors?" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<label class="settings-inline-option">
|
||||
<input type="checkbox" name="swap_colors" id="swap_colors">
|
||||
<span>{{ t .Localizer "Enable swapped color output" }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</table>
|
||||
<p class="w3-panel w3-pale-yellow w3-border w3-border-yellow w3-round">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
<strong>{{ t .Localizer "Note:" }}</strong>
|
||||
{{ t .Localizer "If you are changing WiFi credentials or the image URL on a previously flashed device, select \"Full Erase\" or \"Erase All Flash\" before flashing. Otherwise the old settings stored on the device will be used instead of the new ones." }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="device-settings-section-divider">
|
||||
<h3>{{ t .Localizer "Firmware Type" }}</h3>
|
||||
<table class="device-settings-table">
|
||||
<tr>
|
||||
<td>
|
||||
<label for="ota_only" class="device-settings-label">{{ t .Localizer "OTA Update Only" }}</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="ota_only" id="ota_only">
|
||||
<p class="field-description">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="ota_only">{{ t .Localizer "Firmware Type" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<label class="settings-inline-option">
|
||||
<input type="checkbox" name="ota_only" id="ota_only">
|
||||
<span>{{ t .Localizer "OTA Update Only" }}</span>
|
||||
</label>
|
||||
<p class="settings-help">
|
||||
{{ t .Localizer "Check this for OTA-only firmware (app only, flash at 0x10000). Leave unchecked for a full flash binary that includes the bootloader and partition table (flash at 0x0). To update the firmware on device go to the edit device page and scroll down to the firmware update section." }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="w3-button w3-blue" type="submit">
|
||||
<i class="fa-solid fa-microchip" aria-hidden="true"></i> {{ t .Localizer "Generate Firmware File" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Flashing Instructions" }}</h2>
|
||||
<p>{{ t .Localizer "After generating and downloading the firmware file, use one of these web-based flashers:" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-settings-section">
|
||||
<div class="config-management-container">
|
||||
<button class="w3-button w3-blue config-management-btn" type="submit">
|
||||
<i class="fa-solid fa-microchip" aria-hidden="true"></i> {{ t .Localizer "Generate Firmware File" }}
|
||||
</button>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://espressif.github.io/esptool-js/" target="_blank">Espressif Web Flasher</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://thelastoutpostworkshop.github.io/ESPConnect/"
|
||||
target="_blank">ESPConnect Web Flasher</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ol>
|
||||
<li>{{ t .Localizer "Connect your device to your computer with a data USB cable" }}</li>
|
||||
<li>{{ t .Localizer "Open one of the web flashers above in Chrome or Edge" }}</li>
|
||||
<li>{{ t .Localizer "Click Connect and select your device's serial port" }}</li>
|
||||
<li>{{ t .Localizer "Click Flash Tools if needed" }}</li>
|
||||
<li>
|
||||
{{ t .Localizer "Set the flash address to" }} <strong>0x0</strong>
|
||||
</li>
|
||||
<li>{{ t .Localizer "Select your downloaded firmware file and click Program/Flash" }}</li>
|
||||
<li>{{ t .Localizer "Reboot the device if it doesn't restart automatically" }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Troubleshooting" }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<p class="settings-help">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
{{ t .Localizer "If you are changing WiFi credentials or the image URL on a previously flashed device, select \"Full Erase\" or \"Erase All Flash\" before flashing. Otherwise the old settings stored on the device will be used instead of the new ones." }}
|
||||
</p>
|
||||
<p>
|
||||
<i>{{ t .Localizer "macOS and Windows users may need to install a serial driver:" }}</i>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.silabs.com/developer-tools/usb-to-uart-bridge-vcp-drivers?tab=downloads"
|
||||
target="_blank">CP210x Drivers</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/WCHSoftGroup/ch34xser_macos" target="_blank">CH34x Drivers</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
const input = document.getElementById('img_url');
|
||||
const httpRadio = document.getElementById('variant_http');
|
||||
const wsRadio = document.getElementById('variant_ws');
|
||||
const customRadio = document.getElementById('variant_custom');
|
||||
// Resolve absolute URLs
|
||||
const origin = window.location.origin;
|
||||
const httpPath = "{{ .Device.ImgURL }}";
|
||||
const wsPath = "{{ .Device.WsURL }}";
|
||||
@@ -238,7 +235,6 @@
|
||||
radio.addEventListener('change', updateUrl);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
wsRadio.checked = true;
|
||||
updateUrl();
|
||||
})();
|
||||
|
||||
@@ -3,21 +3,33 @@
|
||||
{{ end }}
|
||||
{{ define "title" }}{{ t .Localizer "Home" }}{{ end }}
|
||||
{{ define "header" }}
|
||||
<!-- Add viewport meta tag -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Include external CSS and JS -->
|
||||
<link rel="stylesheet" href="/static/css/manager.css">
|
||||
{{ end }}
|
||||
{{ define "content" }}
|
||||
{{ if .User }}
|
||||
<br>
|
||||
<a class="w3-button w3-purple w3-round w3-padding"
|
||||
href="/devices/create"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ t .Localizer "New Tronbyt" }}</a>
|
||||
<hr>
|
||||
<div class="settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<p class="settings-eyebrow">{{ t .Localizer "Home" }}</p>
|
||||
<h1>{{ t .Localizer "App Management" }}</h1>
|
||||
<p class="settings-page-copy">{{ t .Localizer "Choose a device, then manage the apps it shows." }}</p>
|
||||
</div>
|
||||
{{ if .User }}
|
||||
<div class="settings-actions">
|
||||
<a class="w3-button w3-purple" href="/devices/create">
|
||||
<i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ t .Localizer "New Tronbyt" }}
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</header>
|
||||
{{ if gt (len .Devices) 1 }}
|
||||
<nav class="settings-subnav" aria-label="{{ t .Localizer "Devices" }}">
|
||||
{{ range .Devices }}
|
||||
<a class="settings-subnav-link" href="#device-card-{{ .ID }}">{{ .Name }}</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
{{ end }}
|
||||
{{ range .Devices }}
|
||||
{{ template "device_card" (dict "Item" . "Localizer" $.Localizer "Devices" $.Devices "AllDevices" $.AllDevices) }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ range .Devices }}
|
||||
{{ template "device_card" (dict "Item" . "Localizer" $.Localizer "Devices" $.Devices "AllDevices" $.AllDevices) }}
|
||||
<hr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
h
|
||||
|
||||
+861
-1015
File diff suppressed because it is too large
Load Diff
@@ -1,132 +1,134 @@
|
||||
{{ define "app_card" }}
|
||||
<!-- App Card Component -->
|
||||
<div class="w3-card-4 w3-padding app-card"
|
||||
{{ if .App.Enabled }}
|
||||
data-status="enabled"
|
||||
{{ else }}
|
||||
data-status="disabled"
|
||||
{{ end }}
|
||||
{{ if not .App.Enabled }}
|
||||
aria-disabled="true"
|
||||
{{ end }}
|
||||
{{ if not .ReadOnly }}
|
||||
draggable="true"
|
||||
{{ end }}
|
||||
data-iname="{{ .App.Iname }}">
|
||||
<div class="app-card-content">
|
||||
<!-- Left: Preview Image -->
|
||||
<div class="app-preview">
|
||||
<div class="app-img">
|
||||
<img src="/devices/{{ .Device.ID }}/installations/{{ .App.Iname }}/preview"
|
||||
alt="{{ t .Localizer "Preview" }}"
|
||||
width="128"
|
||||
height="64">
|
||||
{{ if .App.EmptyLastRender }}
|
||||
<div class="inactive-overlay">
|
||||
<span class="inactive-badge-overlay"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> {{ t .Localizer "INACTIVE" }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Middle: App Info and Action Buttons -->
|
||||
<div class="app-info">
|
||||
<div class="app-header">
|
||||
<h3>{{ .App.Name }}-{{ .App.Iname }}</h3>
|
||||
<div class="app-status">
|
||||
{{ if isPinned .Device .App.Iname }}
|
||||
<span class="status-badge pinned"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ t .Localizer "PINNED" }}</span>
|
||||
{{ else }}
|
||||
{{ if .App.Enabled }}
|
||||
<span class="status-badge enabled"><i class="fa-solid fa-circle-check" aria-hidden="true"></i> {{ t .Localizer "ENABLED" }}</span>
|
||||
{{ else }}
|
||||
<span class="status-badge disabled"><i class="fa-solid fa-circle-xmark" aria-hidden="true"></i> {{ t .Localizer "DISABLED" }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if and .App.AutoPin (or (isPinned .Device .App.Iname) .App.Enabled) }}
|
||||
<span class="status-badge autopin"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> {{ t .Localizer "AUTOPIN" }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-details">
|
||||
<p class="app-render-info">
|
||||
{{ t .Localizer "Interval:" }} {{ .App.UInterval }} min |
|
||||
{{ if .App.EmptyLastRender }}
|
||||
{{ t .Localizer "No output for:" }} {{ timesince .Localizer (or .App.LastSuccessfulRender .App.LastRender) }}
|
||||
{{ else }}
|
||||
{{ t .Localizer "Last:" }} {{ timeago .Localizer .App.LastRender }}
|
||||
({{ t .Localizer "in" }} {{ duration .App.LastRenderDur }})
|
||||
{{ end }}
|
||||
</p>
|
||||
{{ if ne .App.DisplayTime 0 }}
|
||||
<p>{{ t .Localizer "Display Time (secs):" }} {{ .App.DisplayTime }}</p>
|
||||
{{ end }}
|
||||
{{ if .App.Notes }}
|
||||
<p>{{ t .Localizer "Notes:" }} {{ .App.Notes }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if not .ReadOnly }}
|
||||
<div class="app-actions">
|
||||
<!-- Primary Actions Row -->
|
||||
<div class="app-actions-primary">
|
||||
<a class="action w3-button w3-blue w3-round"
|
||||
href="/devices/{{ .Device.ID }}/{{ .App.Iname }}/config"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t .Localizer "Edit" }}</a>
|
||||
{{ if eq .Device.Info.ProtocolType "WS" }}
|
||||
<button class="action w3-button w3-blue w3-round"
|
||||
onclick="previewApp('{{ .Device.ID }}', '{{ .App.Iname }}', null, this)">
|
||||
<i class="fa-solid fa-play" aria-hidden="true"></i> {{ t .Localizer "Preview" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
<button class="action w3-button w3-green w3-round"
|
||||
onclick="duplicateApp('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i> {{ t .Localizer "Duplicate" }}
|
||||
</button>
|
||||
{{ if gt (len .Devices) 1 }}
|
||||
<div class="copy-to-dropdown">
|
||||
<select class="action w3-button w3-indigo w3-round custom-select"
|
||||
onchange="if(this.value) { duplicateAppToDevice('{{ .Device.ID }}', '{{ .App.Iname }}', this.value, null, false); this.selectedIndex=0; }">
|
||||
<option value="" disabled selected>{{ t .Localizer "Copy to..." }}</option>
|
||||
{{ range $target_device := .Devices }}
|
||||
{{ if ne $target_device.ID $.Device.ID }}
|
||||
<option value="{{ $target_device.ID }}">{{ $target_device.Name }}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</select>
|
||||
<i class="fa fa-caret-down custom-select-arrow"></i>
|
||||
</div>
|
||||
{{ end }}
|
||||
<button class="action w3-button w3-red w3-round"
|
||||
onclick="deleteApp('{{ .Device.ID }}', '{{ .App.Iname }}', false, {{ t .Localizer "Delete App?" | json }})">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i> {{ t .Localizer "Delete" }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Secondary Actions Row -->
|
||||
<div class="app-actions-secondary">
|
||||
<button class="action w3-button w3-blue w3-round app-btn-no-hover"
|
||||
onclick="moveApp('{{ .Device.ID }}', '{{ .App.Iname }}', 'top')">
|
||||
<i class="fa-solid fa-angles-up" aria-hidden="true"></i> {{ t .Localizer "Top" }}
|
||||
</button>
|
||||
<button class="action w3-button w3-blue w3-round app-btn-no-hover"
|
||||
onclick="moveApp('{{ .Device.ID }}', '{{ .App.Iname }}', 'bottom')">
|
||||
<i class="fa-solid fa-angles-down" aria-hidden="true"></i> {{ t .Localizer "Bottom" }}
|
||||
</button>
|
||||
{{ if isPinned .Device .App.Iname }}
|
||||
<button class="action w3-button w3-orange w3-round app-btn-no-hover"
|
||||
onclick="togglePin('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-thumbtack-slash" aria-hidden="true"></i> {{ t .Localizer "Unpin" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="action w3-button w3-green w3-round app-btn-no-hover"
|
||||
onclick="togglePin('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ t .Localizer "Pin" }}
|
||||
</button>
|
||||
{{ if .App.Enabled }}
|
||||
<button class="action w3-button w3-orange w3-round app-btn-no-hover"
|
||||
onclick="toggleEnabled('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-pause" aria-hidden="true"></i> {{ t .Localizer "Disable" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="action w3-button w3-green w3-round app-btn-no-hover"
|
||||
onclick="toggleEnabled('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-play" aria-hidden="true"></i> {{ t .Localizer "Enable" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="app-card-content">
|
||||
<!-- Left: Preview Image -->
|
||||
<div class="app-preview">
|
||||
<div class="app-img">
|
||||
<img src="/devices/{{ .Device.ID }}/installations/{{ .App.Iname }}/preview"
|
||||
alt="{{ t .Localizer "Preview" }}"
|
||||
width="128"
|
||||
height="64">
|
||||
{{ if .App.EmptyLastRender }}
|
||||
<div class="inactive-overlay">
|
||||
<span class="inactive-badge-overlay"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> {{ t .Localizer "INACTIVE" }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Middle: App Info and Action Buttons -->
|
||||
<div class="app-info">
|
||||
<div class="app-header">
|
||||
<h3>{{ .App.Name }}-{{ .App.Iname }}</h3>
|
||||
<div class="app-status">
|
||||
{{ if isPinned .Device .App.Iname }}
|
||||
<span class="status-badge pinned"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ t .Localizer "PINNED" }}</span>
|
||||
{{ end }}
|
||||
{{ if and .App.AutoPin (or (isPinned .Device .App.Iname) .App.Enabled) }}
|
||||
<span class="status-badge autopin"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> {{ t .Localizer "AUTOPIN" }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-details">
|
||||
<p class="app-render-info">
|
||||
{{ t .Localizer "Interval:" }} {{ .App.UInterval }} min |
|
||||
{{ if .App.EmptyLastRender }}
|
||||
{{ t .Localizer "No output for:" }} {{ timesince .Localizer (or .App.LastSuccessfulRender .App.LastRender) }}
|
||||
{{ else }}
|
||||
{{ t .Localizer "Last:" }} {{ timeago .Localizer .App.LastRender }}
|
||||
({{ t .Localizer "in" }} {{ duration .App.LastRenderDur }})
|
||||
{{ end }}
|
||||
</p>
|
||||
{{ if ne .App.DisplayTime 0 }}
|
||||
<p>{{ t .Localizer "Display Time (secs):" }} {{ .App.DisplayTime }}</p>
|
||||
{{ end }}
|
||||
{{ if .App.Notes }}
|
||||
<p>{{ t .Localizer "Notes:" }} {{ .App.Notes }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if not .ReadOnly }}
|
||||
<div class="app-actions">
|
||||
<!-- Primary Actions Row -->
|
||||
<div class="app-actions-primary">
|
||||
<a class="action w3-button w3-blue w3-round"
|
||||
href="/devices/{{ .Device.ID }}/{{ .App.Iname }}/config"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t .Localizer "Edit" }}</a>
|
||||
{{ if eq .Device.Info.ProtocolType "WS" }}
|
||||
<button class="action w3-button w3-blue w3-round"
|
||||
onclick="previewApp('{{ .Device.ID }}', '{{ .App.Iname }}', null, this)">
|
||||
<i class="fa-solid fa-play" aria-hidden="true"></i> {{ t .Localizer "Preview" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
<button class="action w3-button w3-green w3-round"
|
||||
onclick="duplicateApp('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i> {{ t .Localizer "Duplicate" }}
|
||||
</button>
|
||||
{{ if gt (len .Devices) 1 }}
|
||||
<div class="copy-to-dropdown">
|
||||
<select class="action w3-button w3-indigo w3-round custom-select"
|
||||
onchange="if(this.value) { duplicateAppToDevice('{{ .Device.ID }}', '{{ .App.Iname }}', this.value, null, false); this.selectedIndex=0; }">
|
||||
<option value="" disabled selected>{{ t .Localizer "Copy to..." }}</option>
|
||||
{{ range $target_device := .Devices }}
|
||||
{{ if ne $target_device.ID $.Device.ID }}
|
||||
<option value="{{ $target_device.ID }}">{{ $target_device.Name }}</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</select>
|
||||
<i class="fa fa-caret-down custom-select-arrow"></i>
|
||||
</div>
|
||||
{{ end }}
|
||||
<button class="action w3-button w3-red w3-round"
|
||||
onclick="deleteApp('{{ .Device.ID }}', '{{ .App.Iname }}', false, {{ t .Localizer "Delete App?" | json }})">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i> {{ t .Localizer "Delete" }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Secondary Actions Row -->
|
||||
<div class="app-actions-secondary">
|
||||
<button class="action w3-button w3-blue w3-round app-btn-no-hover"
|
||||
onclick="moveApp('{{ .Device.ID }}', '{{ .App.Iname }}', 'top')">
|
||||
<i class="fa-solid fa-angles-up" aria-hidden="true"></i> {{ t .Localizer "Top" }}
|
||||
</button>
|
||||
<button class="action w3-button w3-blue w3-round app-btn-no-hover"
|
||||
onclick="moveApp('{{ .Device.ID }}', '{{ .App.Iname }}', 'bottom')">
|
||||
<i class="fa-solid fa-angles-down" aria-hidden="true"></i> {{ t .Localizer "Bottom" }}
|
||||
</button>
|
||||
{{ if isPinned .Device .App.Iname }}
|
||||
<button class="action w3-button w3-orange w3-round app-btn-no-hover"
|
||||
onclick="togglePin('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-thumbtack-slash" aria-hidden="true"></i> {{ t .Localizer "Unpin" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="action w3-button w3-green w3-round app-btn-no-hover"
|
||||
onclick="togglePin('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ t .Localizer "Pin" }}
|
||||
</button>
|
||||
{{ if .App.Enabled }}
|
||||
<button class="action w3-button w3-orange w3-round app-btn-no-hover"
|
||||
onclick="toggleEnabled('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-pause" aria-hidden="true"></i> {{ t .Localizer "Disable" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="action w3-button w3-green w3-round app-btn-no-hover"
|
||||
onclick="toggleEnabled('{{ .Device.ID }}', '{{ .App.Iname }}')">
|
||||
<i class="fa-solid fa-play" aria-hidden="true"></i> {{ t .Localizer "Enable" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{{ define "app_list_grid" }}
|
||||
<div class="app-group">
|
||||
<h3 style="display: inline-block; margin-right: 10px;">{{ .Title }}</h3>
|
||||
{{ if and .ShowVersion .SystemRepoInfo .SystemRepoInfo.CommitHash }}
|
||||
<span style="font-size: 14px; color: #888;">
|
||||
(version: <a href="{{ .SystemRepoInfo.CommitURL }}"
|
||||
<div class="app-group-header">
|
||||
<h3 class="app-group-title">{{ .Title }}</h3>
|
||||
{{ if and .ShowVersion .SystemRepoInfo .SystemRepoInfo.CommitHash }}
|
||||
<span class="app-group-meta">
|
||||
(version: <a href="{{ .SystemRepoInfo.CommitURL }}"
|
||||
target="_blank"
|
||||
style="color: var(--primary-color);
|
||||
text-decoration: none"
|
||||
title="{{ .SystemRepoInfo.CommitHash }}">
|
||||
{{ if .SystemRepoInfo.CommitMessage }}{{ .SystemRepoInfo.CommitMessage }}{{ else }}{{ substr .SystemRepoInfo.CommitHash 0 8 }}{{ end }}
|
||||
</a>{{ if .SystemRepoInfo.CommitDate }} - {{ .SystemRepoInfo.CommitDate }}{{ end }})
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .SystemRepoInfo.CommitMessage }}{{ .SystemRepoInfo.CommitMessage }}{{ else }}{{ substr .SystemRepoInfo.CommitHash 0 8 }}{{ end }}
|
||||
</a>{{ if .SystemRepoInfo.CommitDate }} - {{ .SystemRepoInfo.CommitDate }}{{ end }})
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if .ShowControls }}
|
||||
<!-- Filter and Sort Controls -->
|
||||
<div class="filter-sort-controls">
|
||||
@@ -91,26 +91,17 @@
|
||||
style="opacity: 0.4"
|
||||
{{ end }}>
|
||||
</div>
|
||||
<p {{ if .App.Broken }}style="opacity: 0.5;"{{ end }}>{{ .App.Name }} - {{ .App.Summary }}
|
||||
<p class="app-item-title {{ if .App.Broken }}app-item-muted{{ end }}">
|
||||
{{ .App.Name }}{{ if .App.Summary }} - {{ .App.Summary }}{{ end }}
|
||||
</p>
|
||||
{{ if .App.Author }}
|
||||
<p {{ if .App.Broken }}style="opacity: 0.5;"{{ end }}>{{ t .Localizer "From" }} {{ .App.Author }}
|
||||
</p>
|
||||
<p class="app-item-meta {{ if .App.Broken }}app-item-muted{{ end }}">{{ t .Localizer "From" }} {{ .App.Author }}</p>
|
||||
{{ end }}
|
||||
{{ if .App.Broken }}
|
||||
<div style="background-color: #f44336;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
margin-top: 5px">
|
||||
<div class="broken-badge">
|
||||
<i class="fa-solid fa-xmark" aria-hidden="true"></i> {{ t .Localizer "BROKEN" }}
|
||||
<br>
|
||||
<span style="font-size: 10px; font-weight: normal;">{{ .App.BrokenReason }}</span>
|
||||
</div>
|
||||
<p class="app-item-meta">{{ .App.BrokenReason }}</p>
|
||||
{{ end }}
|
||||
{{ if .IsCustom }}
|
||||
<a href="/devices/{{ .DeviceID }}/uploads/{{ .App.ID }}/delete"
|
||||
@@ -122,24 +113,14 @@
|
||||
{{ if not .ConfigProduction }}
|
||||
{{ if .App.Broken }}
|
||||
<button class="unmark-broken-btn"
|
||||
onclick="unmarkAppAsBroken('{{ .App.FileName }}', '{{ .App.PackageName }}', event, {{ t .Localizer "UnmarkAppBrokenConfirm" (dict "AppName" .App.FileName) | json }});"
|
||||
style="background-color: var(--success-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
font-size: 12px">{{ t .Localizer "Unmark Broken" }}</button>
|
||||
onclick="unmarkAppAsBroken('{{ .App.FileName }}', '{{ .App.PackageName }}', event, {{ t .Localizer "UnmarkAppBrokenConfirm" (dict "AppName" .App.FileName) | json }});">
|
||||
{{ t .Localizer "Unmark Broken" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<button class="mark-broken-btn"
|
||||
onclick="markAppAsBroken('{{ .App.FileName }}', '{{ .App.PackageName }}', event, {{ t .Localizer "MarkAppBrokenConfirm" (dict "AppName" .App.FileName) | json }});"
|
||||
style="background-color: var(--danger-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
font-size: 12px">{{ t .Localizer "Mark Broken" }}</button>
|
||||
onclick="markAppAsBroken('{{ .App.FileName }}', '{{ .App.PackageName }}', event, {{ t .Localizer "MarkAppBrokenConfirm" (dict "AppName" .App.FileName) | json }});">
|
||||
{{ t .Localizer "Mark Broken" }}
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
@@ -1,196 +1,232 @@
|
||||
{{ define "device_card" }}
|
||||
<!-- Device Card Component -->
|
||||
<div id="device-card-{{ .Item.ID }}"
|
||||
class="w3-card-4 w3-padding device-container">
|
||||
<article>
|
||||
<div>
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<header>
|
||||
<h1>
|
||||
<a href="{{ .Item.ImgURL }}" target="_blank" class="device-link">{{ .Item.Name }}</a>
|
||||
{{ if and .Item.PinnedApp (ne (deref .Item.PinnedApp) "") }}
|
||||
<span class="pinned-badge">
|
||||
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ t .Localizer "Pinned:" }}
|
||||
{{ $pinned := deref .Item.PinnedApp }}
|
||||
{{ $pinnedName := "" }}
|
||||
{{ range .Item.Apps }}
|
||||
{{ if eq .Iname $pinned }}
|
||||
{{ $pinnedName = printf "%s-%s" .Name .Iname }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if ne $pinnedName "" }}
|
||||
{{ $pinnedName }}
|
||||
{{ else }}
|
||||
{{ $pinned }}
|
||||
{{ end }}
|
||||
</span>
|
||||
<article class="settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<h1>
|
||||
<a href="{{ .Item.ImgURL }}" target="_blank" class="device-link">{{ .Item.Name }}</a>
|
||||
{{ if and .Item.PinnedApp (ne (deref .Item.PinnedApp) "") }}
|
||||
<span class="pinned-badge">
|
||||
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ t .Localizer "Pinned:" }}
|
||||
{{ $pinned := deref .Item.PinnedApp }}
|
||||
{{ $pinnedName := "" }}
|
||||
{{ range .Item.Apps }}
|
||||
{{ if eq .Iname $pinned }}{{ $pinnedName = printf "%s-%s" .Name .Iname }}{{ end }}
|
||||
{{ end }}
|
||||
{{ if ne $pinnedName "" }}{{ $pinnedName }}{{ else }}{{ $pinned }}{{ end }}
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if .Item.GetNightModeIsActive }}
|
||||
<span class="night-mode-badge"
|
||||
title="{{ t .Localizer "Night Mode Active" }}">
|
||||
<i class="fa-solid fa-moon" aria-hidden="true"></i> {{ t .Localizer "Night Mode" }}
|
||||
</span>
|
||||
{{ else if .Item.GetDimModeIsActive }}
|
||||
<span class="dim-mode-badge" title="{{ t .Localizer "Dim Mode Active" }}">
|
||||
<i class="fa-solid fa-circle-half-stroke" aria-hidden="true"></i> {{ t .Localizer "Dim Mode" }}
|
||||
</span>
|
||||
{{ end }}
|
||||
</h1>
|
||||
<p class="settings-page-copy">
|
||||
{{ len .Item.Apps }} {{ if eq (len .Item.Apps) 1 }}{{ t .Localizer "app" }}{{ else }}{{ t .Localizer "apps" }}{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
{{ if not .ReadOnly }}
|
||||
<a class="w3-button w3-teal" href="/devices/{{ .Item.ID }}/addapp"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ t .Localizer "Add App" }}</a>
|
||||
<a class="w3-button w3-teal" href="/devices/{{ .Item.ID }}/update"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t .Localizer "Edit Device" }}</a>
|
||||
{{ if .Item.Type.SupportsFirmware }}
|
||||
<a class="w3-button w3-teal" href="/devices/{{ .Item.ID }}/firmware"><i class="fa-solid fa-microchip" aria-hidden="true"></i> {{ t .Localizer "Firmware" }}</a>
|
||||
{{ end }}
|
||||
<a class="w3-button w3-teal"
|
||||
href="/devices/{{ .Item.ID }}/tv"
|
||||
target="_blank"><i class="fa-solid fa-tv" aria-hidden="true"></i> {{ t .Localizer "TV Mode" }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</header>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Device Info and Controls" }}</h2>
|
||||
</div>
|
||||
<div class="settings-two-pane">
|
||||
<div class="settings-preview-block">
|
||||
<div class="app-img">
|
||||
<img id="currentWebp-{{ .Item.ID }}"
|
||||
data-src="/devices/{{ .Item.ID }}/current"
|
||||
src="/devices/{{ .Item.ID }}/current"
|
||||
alt="{{ t .Localizer "Preview" }}"
|
||||
width="128"
|
||||
height="64">
|
||||
</div>
|
||||
<p class="settings-caption">{{ t .Localizer "Currently Displaying App" }}</p>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Brightness" }}</div>
|
||||
<div class="settings-field-control">
|
||||
<span id="brightnessValue-{{ .Item.ID }}">{{ .Item.BrightnessUIScale }}</span>
|
||||
{{ if not .ReadOnly }}
|
||||
<div class="brightness-panel" id="brightness-panel-{{ .Item.ID }}">
|
||||
{{ range $i := seq 0 5 }}
|
||||
<button type="button"
|
||||
class="brightness-btn {{ if eq $.Item.BrightnessUIScale $i }}active{{ end }}"
|
||||
data-brightness="{{ $i }}"
|
||||
onclick="setBrightness('{{ $.Item.ID }}', {{ $i }})">{{ $i }}</button>
|
||||
{{ end }}
|
||||
{{ if .Item.GetNightModeIsActive }}
|
||||
<span class="night-mode-badge"
|
||||
title="{{ t .Localizer "Night Mode Active" }}">
|
||||
<i class="fa-solid fa-moon" aria-hidden="true"></i> {{ t .Localizer "Night Mode" }}
|
||||
</span>
|
||||
{{ else if .Item.GetDimModeIsActive }}
|
||||
<span class="dim-mode-badge" title="{{ t .Localizer "Dim Mode Active" }}">
|
||||
<i class="fa-solid fa-circle-half-stroke" aria-hidden="true"></i> {{ t .Localizer "Dim Mode" }}
|
||||
</span>
|
||||
{{ end }}
|
||||
</h1>
|
||||
</header>
|
||||
</td>
|
||||
<td rowspan="4" width="40%">
|
||||
<div class="app-img">
|
||||
<img id="currentWebp-{{ .Item.ID }}"
|
||||
data-src="/devices/{{ .Item.ID }}/current"
|
||||
src="/devices/{{ .Item.ID }}/current"
|
||||
alt="{{ t .Localizer "Preview" }}"
|
||||
width="128"
|
||||
height="64">
|
||||
</div>
|
||||
<div>{{ t .Localizer "Currently Displaying App" }}</div>
|
||||
<div class="device-info-box">
|
||||
<button class="device-info-toggle"
|
||||
aria-expanded="false"
|
||||
aria-controls="device-info-content-{{ .Item.ID }}">
|
||||
<i class="fas fa-chevron-down"></i> {{ t .Localizer "Device Info" }}
|
||||
</button>
|
||||
<div id="device-info-content-{{ .Item.ID }}" class="device-info-content">
|
||||
<table class="device-info-table">
|
||||
{{ if .Item.Info.FirmwareVersion }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Firmware Version:" }}</td>
|
||||
<td>{{ .Item.Info.FirmwareVersion }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.FirmwareType }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Firmware Type:" }}</td>
|
||||
<td>{{ .Item.Info.FirmwareType }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.ProtocolVersion }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Protocol Version:" }}</td>
|
||||
<td>{{ deref .Item.Info.ProtocolVersion }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.MACAddress }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "MAC Address:" }}</td>
|
||||
<td>{{ .Item.Info.MACAddress }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.Hostname }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Hostname:" }}</td>
|
||||
<td>{{ .Item.Info.Hostname }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.SNTPServer }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "SNTP Server:" }}</td>
|
||||
<td>{{ .Item.Info.SNTPServer }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.SyslogAddr }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Syslog Address:" }}</td>
|
||||
<td>{{ .Item.Info.SyslogAddr }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.SSID }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "WiFi SSID:" }}</td>
|
||||
<td>{{ .Item.Info.SSID }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Protocol Type:" }}</td>
|
||||
<td>{{ .Item.Info.ProtocolType }}</td>
|
||||
</tr>
|
||||
{{ if .Item.LastSeen }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Last Seen:" }}</td>
|
||||
<td>{{ timeago $.Localizer .Item.LastSeen }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label>{{ t .Localizer "Brightness" }}</label>
|
||||
=
|
||||
<span id="brightnessValue-{{ .Item.ID }}">{{ .Item.BrightnessUIScale }}</span>
|
||||
{{ if not .ReadOnly }}
|
||||
<div class="brightness-panel" id="brightness-panel-{{ .Item.ID }}">
|
||||
{{ range $i := seq 0 5 }}
|
||||
<button type="button"
|
||||
class="brightness-btn {{ if eq $.Item.BrightnessUIScale $i }}active{{ end }}"
|
||||
data-brightness="{{ $i }}"
|
||||
onclick="setBrightness('{{ $.Item.ID }}', {{ $i }})">{{ $i }}</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="default_interval-{{ .Item.ID }}">{{ t .Localizer "App Cycle Time (Seconds)" }}</label>
|
||||
= <span id="intervalValue-{{ .Item.ID }}">{{
|
||||
.Item.DefaultInterval }} </span> s
|
||||
<br>
|
||||
<input type="range"
|
||||
name="default_interval"
|
||||
id="default_interval-{{ .Item.ID }}"
|
||||
min="1"
|
||||
max="30"
|
||||
value="{{ .Item.DefaultInterval }}"
|
||||
{{ if .ReadOnly }}
|
||||
disabled
|
||||
{{ else }}
|
||||
oninput="updateIntervalValue('{{ .Item.ID }}', this.value)"
|
||||
onmouseup="updateInterval('{{ .Item.ID }}', this.value)"
|
||||
{{ end }}>
|
||||
</td>
|
||||
</tr>
|
||||
{{ if not .ReadOnly }}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="w3-button w3-teal w3-round"
|
||||
style="min-width: 100px"
|
||||
href="/devices/{{ .Item.ID }}/addapp"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ t .Localizer "Add App" }}</a>
|
||||
<a class="w3-button w3-teal w3-round"
|
||||
style="min-width: 100px"
|
||||
href="/devices/{{ .Item.ID }}/update"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t .Localizer "Edit Device" }}</a>
|
||||
{{ if .Item.Type.SupportsFirmware }}
|
||||
<a class="w3-button w3-teal w3-round"
|
||||
style="min-width: 100px"
|
||||
href="/devices/{{ .Item.ID }}/firmware"><i class="fa-solid fa-microchip" aria-hidden="true"></i> {{ t .Localizer "Firmware" }}</a>
|
||||
{{ end }}
|
||||
<a class="w3-button w3-teal w3-round"
|
||||
style="min-width: 100px"
|
||||
href="/devices/{{ .Item.ID }}/tv"
|
||||
target="_blank">
|
||||
<i class="fa-solid fa-tv" aria-hidden="true"></i> {{ t .Localizer "TV Mode" }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
<!-- View Toggle Row -->
|
||||
<div class="view-toggle-row"
|
||||
style="display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center">
|
||||
<div class="view-toggle-container"
|
||||
style="display: flex;
|
||||
align-items: center">
|
||||
<span style="margin-right: 10px; font-weight: bold;">{{ t .Localizer "View:" }}</span>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="default_interval-{{ .Item.ID }}">{{ t .Localizer "App Cycle Time (Seconds)" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<span id="intervalValue-{{ .Item.ID }}">{{ .Item.DefaultInterval }}</span> s
|
||||
<br>
|
||||
<input type="range"
|
||||
name="default_interval"
|
||||
id="default_interval-{{ .Item.ID }}"
|
||||
min="1"
|
||||
max="30"
|
||||
value="{{ .Item.DefaultInterval }}"
|
||||
{{ if .ReadOnly }}
|
||||
disabled
|
||||
{{ else }}
|
||||
oninput="updateIntervalValue('{{ .Item.ID }}', this.value)"
|
||||
onmouseup="updateInterval('{{ .Item.ID }}', this.value)"
|
||||
{{ end }}>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .Item.NightModeEnabled }}
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="night-mode-toggle-{{ .Item.ID }}">{{ t .Localizer "Night Mode" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<label class="settings-inline-option settings-toggle-row"
|
||||
for="night-mode-toggle-{{ .Item.ID }}">
|
||||
<input type="checkbox"
|
||||
id="night-mode-toggle-{{ .Item.ID }}"
|
||||
{{ if .Item.GetNightModeIsActive }}
|
||||
checked
|
||||
{{ end }}
|
||||
{{ if .ReadOnly }}
|
||||
disabled
|
||||
{{ else }}
|
||||
onchange="setNightModeOverride('{{ .Item.ID }}', this.checked, this)"
|
||||
{{ end }}>
|
||||
<span>{{ t .Localizer "Night mode on" }}</span>
|
||||
</label>
|
||||
<p class="settings-caption settings-toggle-hint">
|
||||
{{ t .Localizer "Persists until the next scheduled night mode change." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Item.DimModeEnabled }}
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="dim-mode-toggle-{{ .Item.ID }}">{{ t .Localizer "Dim Mode" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<label class="settings-inline-option settings-toggle-row"
|
||||
for="dim-mode-toggle-{{ .Item.ID }}">
|
||||
<input type="checkbox"
|
||||
id="dim-mode-toggle-{{ .Item.ID }}"
|
||||
{{ if .Item.GetDimModeIsActive }}
|
||||
checked
|
||||
{{ end }}
|
||||
{{ if .ReadOnly }}
|
||||
disabled
|
||||
{{ else }}
|
||||
onchange="setDimModeOverride('{{ .Item.ID }}', this.checked, this)"
|
||||
{{ end }}>
|
||||
<span>{{ t .Localizer "Dim mode on" }}</span>
|
||||
</label>
|
||||
<p class="settings-caption settings-toggle-hint">
|
||||
{{ t .Localizer "Persists until the next scheduled dim mode change." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="device-info-box">
|
||||
<button class="device-info-toggle"
|
||||
aria-expanded="false"
|
||||
aria-controls="device-info-content-{{ .Item.ID }}">
|
||||
<i class="fas fa-chevron-down"></i> {{ t .Localizer "Device Info" }}
|
||||
</button>
|
||||
<div id="device-info-content-{{ .Item.ID }}" class="device-info-content">
|
||||
<table class="device-info-table">
|
||||
{{ if .Item.Info.FirmwareVersion }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Firmware Version:" }}</td>
|
||||
<td>{{ .Item.Info.FirmwareVersion }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.FirmwareType }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Firmware Type:" }}</td>
|
||||
<td>{{ .Item.Info.FirmwareType }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.ProtocolVersion }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Protocol Version:" }}</td>
|
||||
<td>{{ deref .Item.Info.ProtocolVersion }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.MACAddress }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "MAC Address:" }}</td>
|
||||
<td>{{ .Item.Info.MACAddress }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.Hostname }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Hostname:" }}</td>
|
||||
<td>{{ .Item.Info.Hostname }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.SNTPServer }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "SNTP Server:" }}</td>
|
||||
<td>{{ .Item.Info.SNTPServer }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.SyslogAddr }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Syslog Address:" }}</td>
|
||||
<td>{{ .Item.Info.SyslogAddr }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ if .Item.Info.SSID }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "WiFi SSID:" }}</td>
|
||||
<td>{{ .Item.Info.SSID }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Protocol Type:" }}</td>
|
||||
<td>{{ .Item.Info.ProtocolType }}</td>
|
||||
</tr>
|
||||
{{ if .Item.LastSeen }}
|
||||
<tr>
|
||||
<td>{{ t $.Localizer "Last Seen:" }}</td>
|
||||
<td>{{ timeago $.Localizer .Item.LastSeen }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="view-toggle-row settings-view-row">
|
||||
<div class="view-toggle-container settings-view-controls">
|
||||
<span class="settings-view-label">{{ t .Localizer "View:" }}</span>
|
||||
<button id="listViewBtn-{{ .Item.ID }}"
|
||||
class="w3-button w3-blue w3-round view-toggle-btn active"
|
||||
onclick="switchToListView('{{ .Item.ID }}')"
|
||||
@@ -211,7 +247,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{{ if not .ReadOnly }}
|
||||
<div class="instruction-text" style="color: #666; font-style: italic;">
|
||||
<div class="instruction-text settings-caption">
|
||||
{{ t .Localizer "Drag apps to reorder or copy from/to another device" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -222,7 +258,7 @@
|
||||
<hr class="list-view-separator">
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
{{ define "settings_account" }}
|
||||
{{ template "base" . }}
|
||||
{{ end }}
|
||||
{{ define "title" }}{{ t .Localizer "System Settings" }}{{ end }}
|
||||
{{ define "content" }}
|
||||
<div class="settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<p class="settings-eyebrow">{{ t .Localizer "System Settings" }}</p>
|
||||
<h1>{{ t .Localizer "Account" }}</h1>
|
||||
<p class="settings-page-copy">{{ t .Localizer "Manage your login, security, and API access." }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Profile" }}</h2>
|
||||
</div>
|
||||
<form method="post" action="/settings/account" class="settings-form">
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="email">{{ t .Localizer "Email Address" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ derefOr .User.Email "" }}"
|
||||
class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card-subsection">
|
||||
<h3>{{ t .Localizer "Change Password" }}</h3>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="old_password">{{ t .Localizer "Old Password" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="password"
|
||||
name="old_password"
|
||||
id="old_password"
|
||||
class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="password">{{ t .Localizer "New Password" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="password" name="password" id="password" class="settings-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="w3-button w3-green">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t .Localizer "Save Changes" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Passkey" }}</h2>
|
||||
<p>{{ t .Localizer "Manage sign-in with a hardware key, browser, or device passkey." }}</p>
|
||||
<p>
|
||||
{{ t .Localizer "Passkeys only work when Tronbyt is opened on a secure connection. If passkey setup is unavailable, ask whoever manages your server whether HTTPS is enabled." }}
|
||||
</p>
|
||||
</div>
|
||||
<div id="webauthn-settings-container">
|
||||
<ul class="settings-list">
|
||||
{{ range .User.Credentials }}
|
||||
<li class="settings-list-item">
|
||||
<div class="settings-list-copy">
|
||||
{{ $iconLight := webauthn_icon .Authenticator false }}
|
||||
{{ $iconDark := webauthn_icon .Authenticator true }}
|
||||
{{ if and $iconLight $iconDark (ne $iconLight $iconDark) }}
|
||||
<span class="authenticator-icon-container">
|
||||
<img src="{{ $iconLight }}"
|
||||
alt="Authenticator Icon"
|
||||
class="auth-icon-light authenticator-icon">
|
||||
<img src="{{ $iconDark }}"
|
||||
alt="Authenticator Icon"
|
||||
class="auth-icon-dark authenticator-icon">
|
||||
</span>
|
||||
{{ else if or $iconLight $iconDark }}
|
||||
<img src="{{ or $iconLight $iconDark }}"
|
||||
alt="Authenticator Icon"
|
||||
class="authenticator-icon">
|
||||
{{ else }}
|
||||
<i class="fa-solid fa-key"></i>
|
||||
{{ end }}
|
||||
<span>{{ or .Name .Authenticator }}{{ if ne .AttestationType "none" }} ({{ .AttestationType }}){{ end }}</span>
|
||||
</div>
|
||||
<button onclick="deletePasskey('{{ .ID }}')"
|
||||
class="w3-button w3-red"
|
||||
type="button"
|
||||
title="{{ t $.Localizer "Delete Passkey" }}">
|
||||
<i class="fa-solid fa-trash"></i> {{ t $.Localizer "Delete" }}
|
||||
</button>
|
||||
</li>
|
||||
{{ else }}
|
||||
<li class="settings-list-empty">{{ t .Localizer "No passkeys registered." }}</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<div class="settings-actions">
|
||||
<button onclick="registerPasskey()"
|
||||
class="w3-button w3-blue"
|
||||
type="button"
|
||||
{{ if gt (len .User.Credentials) 0 }}
|
||||
disabled
|
||||
title="{{ t .Localizer "Only one passkey allowed" }}"
|
||||
{{ end }}>
|
||||
<i class="fa-solid fa-fingerprint"></i> {{ t .Localizer "Register New Passkey" }}
|
||||
</button>
|
||||
</div>
|
||||
{{ if gt (len .User.Credentials) 0 }}
|
||||
<p class="settings-help">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
{{ t .Localizer "Only one passkey can be registered at a time. Delete the existing one to add a new device." }}
|
||||
</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "API Access" }}</h2>
|
||||
<p>{{ t .Localizer "Use this key for user-level API calls and integrations." }}</p>
|
||||
</div>
|
||||
<form method="post" action="/auth/set_api_key" class="settings-form">
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="api_key">{{ t .Localizer "User API Key" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<div class="settings-inline-group settings-api-key-row">
|
||||
<input type="text"
|
||||
name="api_key"
|
||||
id="api_key"
|
||||
required
|
||||
value="{{ .User.APIKey }}"
|
||||
class="settings-input settings-input-xwide settings-input-code">
|
||||
<button type="button" class="w3-button w3-blue" onclick="copyAPIKey()">
|
||||
<i class="fa-solid fa-copy" aria-hidden="true"></i> {{ t .Localizer "Copy" }}
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="w3-button w3-green"
|
||||
onclick="return confirm('{{ t .Localizer "Update API key?" }}');">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t .Localizer "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="settings-actions">
|
||||
<form method="post" action="/auth/generate_api_key">
|
||||
<button type="submit"
|
||||
class="w3-button w3-blue"
|
||||
onclick="return confirm('{{ t .Localizer "Generate a new API key? This will replace your current API key." }}');">
|
||||
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> {{ t .Localizer "Generate New" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
const el = document.getElementById('webauthn-settings-container');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function bufferDecode(value) {
|
||||
return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
function bufferEncode(value) {
|
||||
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
async function deletePasskey(id) {
|
||||
if (!confirm('{{ t .Localizer "Delete this passkey?" }}')) return;
|
||||
try {
|
||||
const resp = await fetch('/auth/webauthn/delete/' + encodeURIComponent(id), {
|
||||
method: 'POST'
|
||||
});
|
||||
if (resp.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert("{{ t .Localizer "Failed to delete passkey" }}");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Error: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPasskey() {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
alert("{{ t .Localizer "WebAuthn is not supported in this browser." }}");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/auth/webauthn/register/begin');
|
||||
if (!resp.ok) throw new Error("Failed to begin registration");
|
||||
|
||||
const options = await resp.json();
|
||||
options.publicKey.challenge = bufferDecode(options.publicKey.challenge);
|
||||
options.publicKey.user.id = bufferDecode(options.publicKey.user.id);
|
||||
if (options.publicKey.excludeCredentials) {
|
||||
options.publicKey.excludeCredentials.forEach(c => {
|
||||
c.id = bufferDecode(c.id);
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: options.publicKey
|
||||
});
|
||||
|
||||
const body = {
|
||||
id: credential.id,
|
||||
rawId: bufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferEncode(credential.response.attestationObject),
|
||||
clientDataJSON: bufferEncode(credential.response.clientDataJSON),
|
||||
}
|
||||
};
|
||||
|
||||
const finishResp = await fetch('/auth/webauthn/register/finish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (finishResp.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert("Registration failed");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Error: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAPIKey() {
|
||||
const input = document.getElementById('api_key');
|
||||
if (!input) return;
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(input.value);
|
||||
} catch (e) {
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch (copyError) {
|
||||
alert("{{ t .Localizer "Failed to copy API key" }}");
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,331 @@
|
||||
{{ define "settings_admin" }}
|
||||
{{ template "base" . }}
|
||||
{{ end }}
|
||||
{{ define "title" }}{{ t .Localizer "System Settings" }}{{ end }}
|
||||
{{ define "content" }}
|
||||
<div class="settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<p class="settings-eyebrow">{{ t .Localizer "System Settings" }}</p>
|
||||
<h1>{{ t .Localizer "Admin" }}</h1>
|
||||
<p class="settings-page-copy">{{ t .Localizer "Manage backups and user accounts for this server." }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Data Management" }}</h2>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Export Configuration" }}</div>
|
||||
<div class="settings-field-control">
|
||||
<p class="settings-help">{{ t .Localizer "Download your entire user configuration as a JSON file." }}</p>
|
||||
<a href="/export_user_config" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i> {{ t .Localizer "Export" }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Import Configuration" }}</div>
|
||||
<div class="settings-field-control">
|
||||
<p class="settings-help">
|
||||
{{ t .Localizer "Import your user configuration from a JSON file. This will replace your current configuration, but preserve your username and password." }}
|
||||
</p>
|
||||
<form method="post"
|
||||
action="/import_user_config"
|
||||
enctype="multipart/form-data"
|
||||
class="settings-form">
|
||||
<div class="settings-control-stack">
|
||||
<input type="file"
|
||||
name="file"
|
||||
id="file"
|
||||
accept=".json"
|
||||
required
|
||||
class="settings-input settings-file-input">
|
||||
<button type="submit" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-upload" aria-hidden="true"></i> {{ t .Localizer "Import" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<form method="post" action="/settings/admin/oidc" class="settings-form">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "OIDC Configuration" }}</h2>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_enabled">{{ t .Localizer "OIDC Enable" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="checkbox"
|
||||
name="oidc_enabled"
|
||||
id="oidc_enabled"
|
||||
class="w3-check"
|
||||
{{ if .OIDCEnabled }}
|
||||
checked
|
||||
{{ end }}
|
||||
onchange="updateOIDCVision()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="oidc-collapsible-fields"
|
||||
{{ if not .OIDCEnabled }}
|
||||
style="display: none"
|
||||
{{ end }}>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_issuer_url">{{ t .Localizer "OIDC Issuer URL" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="url"
|
||||
name="oidc_issuer_url"
|
||||
id="oidc_issuer_url"
|
||||
class="settings-input settings-input-wide"
|
||||
value="{{ .OIDCIssuerURL }}"
|
||||
placeholder="https://auth.example.com/realms/myrealm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_client_id">{{ t .Localizer "OIDC Client ID" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="text"
|
||||
name="oidc_client_id"
|
||||
id="oidc_client_id"
|
||||
class="settings-input settings-input-wide"
|
||||
value="{{ .OIDCClientID }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_client_secret">{{ t .Localizer "OIDC Client Secret" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="password"
|
||||
name="oidc_client_secret"
|
||||
id="oidc_client_secret"
|
||||
class="settings-input settings-input-wide"
|
||||
value="{{ .OIDCClientSecret }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_allow_auto_create">{{ t .Localizer "OIDC Allow Auto Create" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="checkbox"
|
||||
name="oidc_allow_auto_create"
|
||||
id="oidc_allow_auto_create"
|
||||
class="w3-check"
|
||||
{{ if .OIDCAllowAutoCreate }}
|
||||
checked
|
||||
{{ end }}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_username_claim">{{ t .Localizer "OIDC Username Claim" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="text"
|
||||
name="oidc_username_claim"
|
||||
id="oidc_username_claim"
|
||||
class="settings-input settings-input-wide"
|
||||
value="{{ .OIDCUsernameClaim }}"
|
||||
placeholder="preferred_username">
|
||||
<p class="settings-help">{{ t .Localizer "Claim to use for username" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_admin_group_claim">{{ t .Localizer "OIDC Admin Group Claim" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="text"
|
||||
name="oidc_admin_group_claim"
|
||||
id="oidc_admin_group_claim"
|
||||
class="settings-input settings-input-wide"
|
||||
value="{{ .OIDCAdminGroupClaim }}"
|
||||
placeholder="groups">
|
||||
<p class="settings-help">{{ t .Localizer "Claim name for group/role check" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="oidc_admin_group_value">{{ t .Localizer "OIDC Admin Group Value" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="text"
|
||||
name="oidc_admin_group_value"
|
||||
id="oidc_admin_group_value"
|
||||
class="settings-input settings-input-wide"
|
||||
value="{{ .OIDCAdminGroupValue }}"
|
||||
placeholder="tronbyt-admins">
|
||||
<p class="settings-help">{{ t .Localizer "OIDC Admin group description" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t .Localizer "Save OIDC Settings" }}
|
||||
</button>
|
||||
<a href="#"
|
||||
class="settings-caption"
|
||||
id="oidc-details-toggle"
|
||||
style="text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-left: 8px">{{ t .Localizer "Expand" }}</a>
|
||||
{{ if .OIDCEnabled }}
|
||||
<a href="/auth/oidc/login" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-arrow-right" aria-hidden="true"></i> {{ t .Localizer "Test OIDC Login" }}
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "User Management" }}</h2>
|
||||
<p>{{ t .Localizer "Create, review, and remove users." }}</p>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<a class="w3-button w3-green" href="/auth/register">
|
||||
<i class="fa-solid fa-user-plus" aria-hidden="true"></i> {{ t .Localizer "Create User" }}
|
||||
</a>
|
||||
</div>
|
||||
<ul class="settings-list">
|
||||
{{ range .Users }}
|
||||
<li class="settings-list-item settings-user-row">
|
||||
<div class="settings-list-copy">
|
||||
<strong>{{ .Username }}</strong>
|
||||
{{ if .IsAdmin }}
|
||||
<span class="settings-tag">{{ t $.Localizer "Admin" }}</span>
|
||||
{{ end }}
|
||||
<span>{{ len .Devices }} {{ if eq (len .Devices) 1 }}{{ t $.Localizer "device" }}{{ else }}{{ t $.Localizer "devices" }}{{ end }}</span>
|
||||
<span class="settings-caption">{{ t $.Localizer "Email Address" }}</span>
|
||||
<span>{{ if .Email }}{{ derefOr .Email "" }}{{ else }}{{ t $.Localizer "No email address" }}{{ end }}</span>
|
||||
</div>
|
||||
<div class="settings-actions settings-user-actions">
|
||||
<button class="w3-button w3-blue"
|
||||
type="button"
|
||||
onclick="openEmailDialog('{{ .Username }}')">
|
||||
<i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ t $.Localizer "Edit Email" }}
|
||||
</button>
|
||||
{{ if ne .Username $.User.Username }}
|
||||
<button class="w3-button w3-red"
|
||||
type="button"
|
||||
onclick="deleteUser('{{ .Username }}')">
|
||||
<i class="fa-solid fa-trash"></i> {{ t $.Localizer "Delete User" }}
|
||||
</button>
|
||||
{{ else }}
|
||||
<span class="settings-help">{{ t $.Localizer "Current user" }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<dialog id="user-email-dialog-{{ .Username }}" class="settings-dialog">
|
||||
<form method="post"
|
||||
action="/settings/admin/users/{{ .Username }}/email"
|
||||
class="settings-form">
|
||||
<div class="settings-card-header">
|
||||
<h3>{{ t $.Localizer "Edit Email" }}</h3>
|
||||
<p>{{ .Username }}</p>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="user-email-{{ .Username }}">{{ t $.Localizer "Email Address" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<input type="email"
|
||||
name="email"
|
||||
id="user-email-{{ .Username }}"
|
||||
value="{{ derefOr .Email "" }}"
|
||||
placeholder="{{ t $.Localizer "No email address" }}"
|
||||
class="settings-input settings-input-wide">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-dialog-actions">
|
||||
<button class="w3-button w3-blue" type="submit">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t $.Localizer "Save Email" }}
|
||||
</button>
|
||||
<button class="w3-button"
|
||||
type="button"
|
||||
onclick="closeEmailDialog('{{ .Username }}')">{{ t $.Localizer "Cancel" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
function openEmailDialog(username) {
|
||||
const dialog = document.getElementById('user-email-dialog-' + username);
|
||||
if (dialog) dialog.showModal();
|
||||
}
|
||||
|
||||
function closeEmailDialog(username) {
|
||||
const dialog = document.getElementById('user-email-dialog-' + username);
|
||||
if (dialog) dialog.close();
|
||||
}
|
||||
|
||||
function updateOIDCVision() {
|
||||
const checkbox = document.getElementById('oidc_enabled');
|
||||
const container = document.getElementById('oidc-collapsible-fields');
|
||||
const toggleLink = document.getElementById('oidc-details-toggle');
|
||||
|
||||
if (checkbox && checkbox.checked) {
|
||||
// Always show when enabled
|
||||
container.style.display = 'block';
|
||||
if (toggleLink) toggleLink.style.display = 'none';
|
||||
} else {
|
||||
// Collapse when disabled
|
||||
container.style.display = 'none';
|
||||
if (toggleLink) {
|
||||
toggleLink.style.display = 'inline-block';
|
||||
toggleLink.textContent = '{{ t .Localizer "Expand" }}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toggleLink = document.getElementById('oidc-details-toggle');
|
||||
const container = document.getElementById('oidc-collapsible-fields');
|
||||
|
||||
if (toggleLink && container) {
|
||||
toggleLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const isHidden = container.style.display === 'none';
|
||||
container.style.display = isHidden ? 'block' : 'none';
|
||||
toggleLink.textContent = isHidden ? '{{ t .Localizer "Collapse" }}' : '{{ t .Localizer "Expand" }}';
|
||||
});
|
||||
}
|
||||
updateOIDCVision();
|
||||
});
|
||||
|
||||
function deleteUser(username) {
|
||||
if (!confirm('{{ t .Localizer "Delete User?" }}')) return;
|
||||
|
||||
fetch('/settings/admin/users/' + username, {
|
||||
method: 'DELETE'
|
||||
}).then(response => {
|
||||
if (response.ok || response.redirected) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('{{ t .Localizer "Failed to delete user" }}');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
alert('{{ t .Localizer "Error deleting user" }}');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,301 @@
|
||||
{{ define "settings_content" }}
|
||||
{{ template "base" . }}
|
||||
{{ end }}
|
||||
{{ define "title" }}{{ t .Localizer "System Settings" }}{{ end }}
|
||||
{{ define "content" }}
|
||||
<div class="settings-page">
|
||||
<header class="settings-page-header">
|
||||
<div>
|
||||
<p class="settings-eyebrow">{{ t .Localizer "System Settings" }}</p>
|
||||
<h1>{{ t .Localizer "Content and Firmware" }}</h1>
|
||||
<p class="settings-page-copy">
|
||||
{{ t .Localizer "Manage where apps and firmware come from, and refresh what is available on this server." }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
{{ if .User.IsAdmin }}
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "System Apps Repository" }}</h2>
|
||||
<p>{{ t .Localizer "Built-in app source for this server." }}</p>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Current Repository" }}</div>
|
||||
<div class="settings-field-control">
|
||||
{{ if .SystemRepoInfo }}
|
||||
<div class="settings-meta-block">
|
||||
<a href="{{ .SystemRepoInfo.URL }}"
|
||||
target="_blank"
|
||||
id="system-repo-url-link">{{ .SystemRepoInfo.URL }}</a>
|
||||
<div id="system-repo-branch">{{ t .Localizer "Branch" }}: {{ .SystemRepoInfo.Branch }}</div>
|
||||
<div>
|
||||
{{ t .Localizer "Commit" }}:
|
||||
<a href="{{ .SystemRepoInfo.CommitURL }}"
|
||||
target="_blank"
|
||||
title="{{ .SystemRepoInfo.CommitHash }}"
|
||||
id="system-repo-commit-link">
|
||||
{{ if .SystemRepoInfo.CommitMessage }}{{ .SystemRepoInfo.CommitMessage }}{{ else }}{{ substr .SystemRepoInfo.CommitHash 0 8 }}{{ end }}
|
||||
</a>
|
||||
{{ if .SystemRepoInfo.CommitDate }}
|
||||
<span id="system-repo-commit-date">{{ .SystemRepoInfo.CommitDate }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="settings-help">{{ t .Localizer "No system repository configured." }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<form method="post"
|
||||
action="/refresh_system_repo"
|
||||
onsubmit="event.preventDefault(); refreshSystemRepo(this);">
|
||||
<button type="submit" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> {{ t .Localizer "Refresh" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="settings-card-subsection">
|
||||
<h3>{{ t .Localizer "Change Repository" }}</h3>
|
||||
<form method="post" action="/set_system_repo" class="settings-form">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="app_repo_url">{{ t .Localizer "Repository URL" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<div class="settings-inline-group">
|
||||
<input name="app_repo_url"
|
||||
id="app_repo_url"
|
||||
required
|
||||
value="{{ .GlobalSystemRepoURL }}"
|
||||
class="settings-input settings-input-wide">
|
||||
<button type="submit" class="w3-button w3-green">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t .Localizer "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="settings-help">
|
||||
{{ t .Localizer "Replacing the existing system repo will delete the previous repo and break any apps using that repo." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Custom Apps Repository" }}</h2>
|
||||
<p>{{ t .Localizer "Optional personal app source." }}</p>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Current Repository" }}</div>
|
||||
<div class="settings-field-control">
|
||||
{{ if .UserRepoInfo }}
|
||||
<div class="settings-meta-block">
|
||||
<a href="{{ .UserRepoInfo.URL }}" target="_blank">{{ .UserRepoInfo.URL }}</a>
|
||||
<div>{{ t .Localizer "Branch" }}: {{ .UserRepoInfo.Branch }}</div>
|
||||
<div>
|
||||
{{ t .Localizer "Commit" }}:
|
||||
<a href="{{ .UserRepoInfo.CommitURL }}"
|
||||
target="_blank"
|
||||
title="{{ .UserRepoInfo.CommitHash }}">
|
||||
{{ if .UserRepoInfo.CommitMessage }}{{ .UserRepoInfo.CommitMessage }}{{ else }}{{ substr .UserRepoInfo.CommitHash 0 8 }}{{ end }}
|
||||
</a>
|
||||
{{ if .UserRepoInfo.CommitDate }}
|
||||
<span>{{ .UserRepoInfo.CommitDate }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<p class="settings-help">{{ t .Localizer "No custom repository configured." }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .User.AppRepoURL }}
|
||||
<div class="settings-actions">
|
||||
<form method="post"
|
||||
action="/refresh_user_repo"
|
||||
onsubmit="var btn = this.querySelector('button'); btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '<i class=\'fa-solid fa-spinner fa-spin\'></i> {{ t .Localizer "Refreshing..." }}';">
|
||||
<button type="submit" class="w3-button w3-blue">
|
||||
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> {{ t .Localizer "Refresh" }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post"
|
||||
action="/set_user_repo"
|
||||
onsubmit="return confirm('{{ t .Localizer "Remove custom repository? This will delete the repo and break any apps using it." }}');">
|
||||
<input type="hidden" name="app_repo_url" value="">
|
||||
<button type="submit" class="w3-button w3-red">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i> {{ t .Localizer "Remove" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="settings-card-subsection">
|
||||
<h3>{{ t .Localizer "Change Repository" }}</h3>
|
||||
<form method="post" action="/set_user_repo" class="settings-form">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="user_repo_url">{{ t .Localizer "Repository URL" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<div class="settings-inline-group">
|
||||
<input name="app_repo_url"
|
||||
id="user_repo_url"
|
||||
required
|
||||
value="{{ .User.AppRepoURL }}"
|
||||
class="settings-input settings-input-wide">
|
||||
<button type="submit" class="w3-button w3-green">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t .Localizer "Save" }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="settings-help">
|
||||
{{ t .Localizer "Replacing an existing custom repo will delete the previous repo and break any apps using that repo." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Content Preferences" }}</h2>
|
||||
<p>{{ t .Localizer "Choose where a new app appears when you add it to a device." }}</p>
|
||||
</div>
|
||||
<form method="post" action="/settings/account" class="settings-form">
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">
|
||||
<label for="add_apps_to_top">{{ t .Localizer "App Ordering" }}</label>
|
||||
</div>
|
||||
<div class="settings-field-control">
|
||||
<label class="settings-inline-option">
|
||||
<input type="checkbox"
|
||||
name="add_apps_to_top"
|
||||
id="add_apps_to_top"
|
||||
value="1"
|
||||
{{ if .User.AddAppsToTop }}
|
||||
checked
|
||||
{{ end }}>
|
||||
<span>{{ t .Localizer "Add new device apps to the top of the list" }}</span>
|
||||
</label>
|
||||
<p class="settings-help">
|
||||
{{ t .Localizer "When enabled, an app you add to a device will be inserted at position #1 in that device's app rotation instead of at the end." }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" class="w3-button w3-green">
|
||||
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ t .Localizer "Save Preference" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>{{ t .Localizer "Firmware" }}</h2>
|
||||
<p>{{ t .Localizer "Firmware files available for generating or flashing devices." }}</p>
|
||||
</div>
|
||||
<div class="settings-field-list">
|
||||
<div class="settings-field-row">
|
||||
<div class="settings-field-label">{{ t .Localizer "Downloaded firmware version" }}</div>
|
||||
<div class="settings-field-control">
|
||||
<div id="firmware-version">{{ .FirmwareVersion }}</div>
|
||||
<p class="settings-help">{{ t .Localizer "Firmware is automatically updated when the server starts." }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<form method="post"
|
||||
action="/update_firmware"
|
||||
onsubmit="event.preventDefault(); updateFirmware(this);">
|
||||
<button type="submit" class="w3-button w3-blue">
|
||||
<i class="fa-solid {{ if ne .FirmwareVersion "unknown" }}fa-arrows-rotate{{ else }}fa-download{{ end }}"
|
||||
aria-hidden="true"></i> {{ if ne .FirmwareVersion "unknown" }}{{ t .Localizer "Update" }}{{ else }}{{ t .Localizer "Download" }}{{ end }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="settings-help">
|
||||
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
|
||||
{{ t .Localizer "Note: This updates the firmware files available for flashing, not the firmware on existing devices. Devices must be manually reflashed to use the new firmware." }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
function refreshSystemRepo(form) {
|
||||
const btn = form.querySelector('button');
|
||||
const originalContent = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t .Localizer "Refreshing..." }}';
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.CommitHash) {
|
||||
const branchEl = document.getElementById('system-repo-branch');
|
||||
if (branchEl) branchEl.innerText = '{{ t .Localizer "Branch" }}: ' + data.Branch;
|
||||
const commitLink = document.getElementById('system-repo-commit-link');
|
||||
if (commitLink) {
|
||||
commitLink.href = data.CommitURL;
|
||||
commitLink.title = data.CommitHash;
|
||||
commitLink.innerText = data.CommitMessage || data.CommitHash.substring(0, 8);
|
||||
}
|
||||
const dateEl = document.getElementById('system-repo-commit-date');
|
||||
if (dateEl) dateEl.innerText = data.CommitDate;
|
||||
}
|
||||
btn.innerHTML = '<i class="fa-solid fa-check"></i> {{ t .Localizer "Success" }}';
|
||||
})
|
||||
.catch(() => {
|
||||
btn.innerHTML = '<i class="fa-solid fa-xmark"></i> {{ t .Localizer "Failed" }}';
|
||||
})
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalContent;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function updateFirmware(form) {
|
||||
const btn = form.querySelector('button');
|
||||
const originalContent = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> {{ t .Localizer "Updating..." }}';
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.version) {
|
||||
const el = document.getElementById('firmware-version');
|
||||
if (el) el.textContent = data.version;
|
||||
}
|
||||
btn.innerHTML = data.success
|
||||
? '<i class="fa-solid fa-check"></i> {{ t .Localizer "Success" }}'
|
||||
: '<i class="fa-solid fa-xmark"></i> {{ t .Localizer "Failed" }}';
|
||||
})
|
||||
.catch(() => {
|
||||
btn.innerHTML = '<i class="fa-solid fa-xmark"></i> {{ t .Localizer "Failed" }}';
|
||||
})
|
||||
.finally(() => {
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalContent;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user