This commit is contained in:
Joris Bertomeu
2024-11-19 12:01:15 +01:00
commit dfb0846b79
40 changed files with 16403 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminComponent } from './pages/admin/admin.component';
import { DashboardComponent } from './pages/dashboard/dashboard.component';
const routes: Routes = [
{ path: 'admin', component: AdminComponent },
{ path: 'dashboard', component: DashboardComponent },
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

10
src/app/app.component.css Normal file
View File

@@ -0,0 +1,10 @@
body, html {
margin: 0;
padding: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: black;
height: 100vh;
}

View File

@@ -0,0 +1,2 @@
<router-outlet />

15
src/app/app.component.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { environment } from '../environments/environment';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'web-screensaver';
ngOnInit() {
console.log(environment);
}
}

24
src/app/app.module.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { AdminComponent } from './pages/admin/admin.component';
import { DashboardComponent } from './pages/dashboard/dashboard.component';
@NgModule({
declarations: [
AppComponent,
AdminComponent,
DashboardComponent
],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

View File

@@ -0,0 +1,126 @@
<div class="container">
<div class="row mt-3 mb-3">
<div class="col-12">
<div class="card shadow">
<div class="card-body text-center">
<a href="/" class="float-start btn btn-primary btn-sm"><i class="fa fa-arrow-left me-2"></i>Back to Dashboard</a>
<h3 class="mb-0">Web Screensaver Admin Area</h3>
<small class="text-muted">Version 1.0.2</small>
</div>
</div>
</div>
<div class="col-12 col-md-6 mt-3">
<div class="card shadow">
<div class="card-header">
<i class="fa fa-screwdriver-wrench me-2"></i>Main Settings
</div>
<div class="card-body">
<label class="form-label">Background Refresh Delay</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa fa-stopwatch"></i></span>
<input [(ngModel)]="settings.refreshDelay" type="number" class="form-control" placeholder="ex: 60">
<span class="input-group-text"> Seconds</span>
</div>
<label class="form-label">Native resolution (Width x Height)</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa fa-display"></i></span>
<input [(ngModel)]="settings.resolution.width" type="number" class="form-control" placeholder="ex: 1920">
<span class="input-group-text">x</span>
<input [(ngModel)]="settings.resolution.height" type="number" class="form-control" placeholder="ex: 1080">
</div>
<hr>
<div class="form-check form-switch mb-3">
<input [(ngModel)]="settings.unsplash.enabled" class="form-check-input" type="checkbox" role="switch" id="useUnsplashSwitch">
<label class="form-check-label" for="useUnsplashSwitch">Use Unsplash as Picture provider</label>
</div>
<ng-container *ngIf="settings.unsplash.enabled">
<div class="row mb-3">
<div class="col-12 col-md-6">
<label class="form-label">Access Key</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-key"></i></span>
<input [(ngModel)]="settings.unsplash.accessKey" type="text" class="form-control" placeholder="ex: BRONXJ...">
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Secret Key</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-lock"></i></span>
<input [(ngModel)]="settings.unsplash.secretKey" type="text" class="form-control" placeholder="ex: eGyRBNKsX...">
</div>
</div>
</div>
<label class="form-label">Unsplash Collections ID (Comma separeted)</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa fa-layer-group"></i></span>
<input [(ngModel)]="settings.unsplash.collectionsId" type="text" class="form-control" placeholder="ex: 1806988,1427155">
</div>
</ng-container>
<button [disabled]="isSaving" (click)="saveSettings()" class="btn btn-primary float-end"><i class="fa fa-save me-2"></i>Save</button>
</div>
</div>
</div>
<div class="col-12 col-md-6 mt-3">
<div class="card shadow">
<div class="card-header">
<img src="https://upload.wikimedia.org/wikipedia/commons/6/6e/Home_Assistant_Logo.svg" class="me-2" style="width: 24px;">HASS Configuration
</div>
<div class="card-body">
<label class="form-label">Home Assistant URL</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa fa-cog"></i></span>
<input [(ngModel)]="settings.hass.endpoint" type="text" class="form-control" placeholder="ex: https://home.myhome.com">
</div>
<label class="form-label">Long-Lived Access Token</label>
<div class="input-group mb-3">
<span class="input-group-text"><i class="fa fa-lock"></i></span>
<input [(ngModel)]="settings.hass.token" type="text" class="form-control" placeholder="ex: eyJhbGciOiJIUz...">
</div>
<button [disabled]="isSaving" (click)="saveSettings()" class="btn btn-primary float-end"><i class="fa fa-save me-2"></i>Save</button>
</div>
<div class="card-header border-top">
<i class="fa fa-cubes me-2"></i>Widgets
<button (click)="addWidget()" class="btn btn-clear float-end p-0"><i class="fa fa-circle-plus text-success"></i></button>
</div>
<div class="card-body">
<div class="row">
<div class="col-12 mb-3" *ngFor="let w of widgets; let index = index">
<div class="card shadow">
<div class="card-header">
Widget #{{index+1}}
<button (click)="removeWidget(index)" class="btn btn-clear p-0 float-end">
<i class="fa fa-trash text-danger"></i>
</button>
</div>
<div class="card-body p-2">
<label class="form-label">HASS Entity</label>
<div class="input-group mb-2">
<span class="input-group-text"><i class="fa fa-cube"></i></span>
<input [(ngModel)]="w.entityId" type="text" class="form-control" placeholder="ex: sensor.temperature">
</div>
<label class="form-label">FA Icon</label>
<div class="input-group mb-2">
<span class="input-group-text"><i class="fa fa-icons"></i></span>
<input [(ngModel)]="w.icon" type="text" class="form-control" placeholder="ex: fa fa-cloud-sun text-secondary">
<span class="input-group-text"><i class="{{w.icon}}"></i></span>
</div>
<div class="row">
<div class="col-12 col-md-6">
<label class="form-label">Prefix</label>
<input [(ngModel)]="w.prefix" type="text" placeholder="ex: 🤷 IDK" class="form-control">
</div>
<div class="col-12 col-md-6">
<label class="form-label">Suffix</label>
<input [(ngModel)]="w.suffix" type="text" placeholder="ex: °C" class="form-control">
</div>
</div>
</div>
</div>
</div>
</div>
<button [disabled]="isSaving" (click)="saveWidgets()" class="btn btn-primary float-end"><i class="fa fa-save me-2"></i>Save</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,127 @@
import { Component, OnInit } from '@angular/core';
import { environment } from '../../../environments/environment';
@Component({
selector: 'app-admin',
templateUrl: './admin.component.html',
styleUrl: './admin.component.css'
})
export class AdminComponent implements OnInit {
settings = {
id: '',
refreshDelay: '',
resolution: {
width: '',
height: ''
},
unsplash: {
enabled: false,
accessKey: '',
secretKey: '',
collectionsId: ''
},
hass: {
endpoint: '',
token: ''
}
};
isSaving: boolean = false;
widgets: Array<any> = []
ngOnInit(): void {
this.loadSettings();
}
async loadSettings() {
try {
const resp = await fetch(`${environment.API.endpoint}/admin/settings`);
const respJson = await resp.json();
this.settings = {
id: respJson.id || '',
refreshDelay: respJson.refreshDelay || 60,
resolution: {
width: respJson?.resolution?.width || 1920,
height: respJson?.resolution?.height || 1080
},
unsplash: {
enabled: respJson?.unsplash?.enabled || false,
accessKey: respJson?.unsplash?.accessKey || '',
secretKey: respJson?.unsplash?.secretKey || '',
collectionsId: respJson?.unsplash?.collectionsId ? respJson?.unsplash?.collectionsId.join(',') : ''
},
hass: {
endpoint: respJson?.hass?.endpoint || '',
token: respJson?.hass?.token || ''
}
};
await this.loadWidgets();
} catch(e) {
console.log('Error while fetching settings', e);
}
}
async loadWidgets() {
try {
const resp = await fetch(`${environment.API.endpoint}/admin/widgets/${this.settings.id}`);
const respJson = await resp.json();
this.widgets = respJson;
} catch(e) {
console.log('Error while fetching widgets', e);
}
}
addWidget() {
this.widgets.push({
id: null,
icon: '',
entityId: '',
prefix: '',
suffix: '',
});
}
removeWidget(index: number) {
if (!confirm('Are you sure?'))
return;
this.widgets.splice(index, 1);
}
async saveSettings() {
this.isSaving = true;
try {
await fetch(`${environment.API.endpoint}/admin/settings/${this.settings.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(Object.assign(this.settings, {
unsplash: Object.assign(this.settings.unsplash, {
collectionsId: this.settings.unsplash.collectionsId.includes(',') ? this.settings.unsplash.collectionsId.split(',') : [this.settings.unsplash.collectionsId]
})
}))
});
} catch(e) {
console.log('Error while saving settings', e);
}
this.isSaving = false;
}
async saveWidgets() {
this.isSaving = true;
try {
await fetch(`${environment.API.endpoint}/admin/widgets/${this.settings.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.widgets)
});
} catch(e) {
console.log('Error while saving widgets', e);
}
this.isSaving = false;
}
}

View File

@@ -0,0 +1,43 @@
.background-img {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 1s ease;
}
.widget {
position: sticky;
z-index: 99;
text-align: center;
color: white;
font-family: Arial, sans-serif;
background-color: rgba(0, 0, 0, 0.5);
padding: 15px;
}
.widget-clock {
font-size: 100px;
}
.widget-bottom {
font-size: 52px;
}
.footer {
font-size: 48px!important;
z-index: 99;
position: absolute;
bottom: 0;
width: 100%;
}
.loader {
position: absolute;
z-index: 99;
right: 20px;
top: 20px;
}

View File

@@ -0,0 +1,27 @@
<img #bg1 class="background-img bg1"/>
<img #bg2 class="background-img bg2" style="opacity: 0;"/>
<div class="loader" *ngIf="isLoading">
<i class="fa fa-spinner fa-spin fa-4x text-primary"></i>
</div>
<div class="container-fluid">
<div class="row mt-4">
<div class="col-4 offset-4">
<div class="widget widget-clock rounded-5 shadow">
<div>{{currentClock.time}}</div>
<div style="font-size:32px;margin-top:-25px">{{currentClock.date}}</div>
</div>
</div>
</div>
</div>
<footer class="footer px-3" *ngIf="bottomWidgets.length > 0">
<div class="row mb-4">
<div class="col" *ngFor="let w of bottomWidgets; let index = index">
<div class="widget widget-bottom rounded-3 shadow">
<i class="{{w.icon}} me-3 fa-fw"></i>{{w.value}}
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,142 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { environment } from '../../../environments/environment';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.css'
})
export class DashboardComponent implements OnInit {
@ViewChild('bg1', { static: true }) bg1!: ElementRef;
@ViewChild('bg2', { static: true }) bg2!: ElementRef;
currentBackground: ElementRef | null = null;
currentClock: any = {
time: '',
date: ''
};
isLoading: boolean = true;
bottomWidgets: any[] = [];
settings = {
id: '',
refreshDelay: 60,
resolution: {
width: '2880',
height: '1800'
},
unsplash: {
enabled: false,
collectionsId: ''
},
hass: {
endpoint: '',
token: ''
}
};
async ngOnInit() {
await this.loadSettings();
this.isLoading = false;
this.updateClock();
this.currentBackground = this.bg1;
this.updateBackground();
this.updateHAWidgets();
setInterval(() => this.updateClock(), 750);
setInterval(() => this.updateBackground(), this.settings.refreshDelay * 1000);
setInterval(() => this.updateHAWidgets(), 60000);
}
async loadWidgets() {
try {
const resp = await fetch(`${environment.API.endpoint}/admin/widgets/${this.settings.id}`);
const respJson = await resp.json();
this.bottomWidgets = respJson;
} catch(e) {
console.log('Error while fetching widgets', e);
}
}
async loadSettings() {
try {
const resp = await fetch(`${environment.API.endpoint}/admin/settings`);
const respJson = await resp.json();
this.settings = {
id: respJson.id || '',
refreshDelay: respJson.refreshDelay || 60,
resolution: {
width: respJson?.resolution?.width || 1920,
height: respJson?.resolution?.height || 1080
},
unsplash: {
enabled: respJson?.unsplash?.enabled || false,
collectionsId: respJson?.unsplash?.collectionsId || ''
},
hass: {
endpoint: respJson?.hass?.endpoint || '',
token: respJson?.hass?.token || ''
}
};
await this.loadWidgets();
} catch(e) {
console.log('Error while fetching settings', e);
}
}
updateClock() {
const now = new Date();
const options: any = { weekday: 'long', month: 'long', day: 'numeric' };
let dateStr = now.toLocaleDateString('fr-FR', options);
dateStr = dateStr.replace(/^\w| (\w)/g, match => match.toUpperCase());
const timeStr = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
this.currentClock = { time: timeStr, date: dateStr };
}
updateHAWidgets() {
this.bottomWidgets.forEach(async (widget) => {
try {
const resp = await fetch(`${this.settings.hass.endpoint}/api/states/${widget.entityId}`, {
headers: {
Authorization: `Bearer ${this.settings.hass.token}`
}
});
const data = await resp?.json();
widget.value = `${widget.prefix || ''}${data.state}${widget.suffix || ''}`;
} catch(e: any) {
widget.value = `Error: ${e.message}`;
}
});
}
async updateBackground() {
this.isLoading = true;
const nextBackground = this.currentBackground === this.bg1 ? this.bg2 : this.bg1;
let pictureResp = null;
try {
if (!this.settings.unsplash.enabled)
throw new Error('Unsplash is disabled');
pictureResp = await fetch(`${environment.API.endpoint}/photos/random/${this.settings.id}`);
const pictureData = await pictureResp.json();
nextBackground.nativeElement.src = `${pictureData.urls.raw}&w=${this.settings.resolution.width}&h=${this.settings.resolution.height}`;
} catch(e) {
console.log('Cannot request Unsplash GW API');
nextBackground.nativeElement.src = `https://picsum.photos/${this.settings.resolution.width}/${this.settings.resolution.height}?random=${Date.now()}`;
}
nextBackground.nativeElement.onload = () => {
if (this.currentBackground === this.bg1) {
this.bg1.nativeElement.style.opacity = "0";
this.bg2.nativeElement.style.opacity = "1";
this.currentBackground = this.bg2;
} else {
this.bg2.nativeElement.style.opacity = "0";
this.bg1.nativeElement.style.opacity = "1";
this.currentBackground = this.bg1;
}
this.isLoading = false;
};
}
}

View File

@@ -0,0 +1,5 @@
export const environment = {
API: {
endpoint: 'http://localhost:3000'
}
};

View File

@@ -0,0 +1,5 @@
export const environment = {
API: {
endpoint: '/api'
}
};

14
src/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Web Screensaver</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<app-root></app-root>
</body>
</html>

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule, {
ngZoneEventCoalescing: true
})
.catch(err => console.error(err));

1
src/styles.css Normal file
View File

@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */