├── public
├── favicon.png
├── js
│ ├── theme-loader.js
│ ├── settings-manager.js
│ ├── page-loader.js
│ ├── playlist-manager.js
│ ├── scheduler-manager.js
│ ├── screens-manager.js
│ ├── landing-auth.js
│ ├── media-manager.js
│ ├── navigation.js
│ ├── ui-scheduler.js
│ ├── main.js
│ └── view.js
├── firebase-config.js
├── _header.html
├── css
│ ├── components
│ │ ├── _loaders.css
│ │ ├── _toast.css
│ │ ├── _buttons.css
│ │ ├── _dashboard.css
│ │ ├── _tables.css
│ │ ├── _scheduler.css
│ │ ├── _modals.css
│ │ └── _media-library.css
│ ├── view.css
│ ├── guides.css
│ ├── style.css
│ └── features.css
├── sitemap.xml
├── _footer.html
├── view.html
├── 404.html
├── guides
│ ├── creating-playlists.html
│ └── getting-started.html
├── terms.html
├── privacy.html
├── features.html
└── app.html
├── firebase.json
├── firestore.rules
├── functions
└── index.js
└── README.md
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yf19770/sign-net/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/js/theme-loader.js:
--------------------------------------------------------------------------------
1 | // js/theme-loader.js
2 | (function() {
3 | const savedTheme = localStorage.getItem('theme');
4 | if (savedTheme === 'light') {
5 | document.body.classList.add('light-mode');
6 | } else if (savedTheme === 'dark') {
7 | document.body.classList.remove('light-mode');
8 | } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
9 | document.body.classList.add('light-mode');
10 | }
11 | })();
--------------------------------------------------------------------------------
/public/firebase-config.js:
--------------------------------------------------------------------------------
1 | // firebase-config.js
2 |
3 | // PASTE YOUR FIREBASE CONFIGURATION OBJECT HERE
4 | // This object is provided by Firebase when you create a new web app.
5 |
6 | const firebaseConfig = {
7 | apiKey: "YOUR_API_KEY",
8 | authDomain: "YOUR_AUTH_DOMAIN",
9 | projectId: "YOUR_PROJECT_ID",
10 | storageBucket: "YOUR_STORAGE_BUCKET",
11 | messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
12 | appId: "YOUR_APP_ID"
13 | };
14 |
15 |
16 | // NOTE: Make sure you've enabled Firestore and Firebase Storage in your Firebase project console!
--------------------------------------------------------------------------------
/public/_header.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Signet
6 |
7 |
13 |
14 | Login
15 | Sign Up Free
16 |
17 |
20 |
21 |
--------------------------------------------------------------------------------
/public/css/components/_loaders.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_loaders.css */
2 |
3 | @keyframes shimmer {
4 | 0% {
5 | background-position: -400px 0;
6 | }
7 | 100% {
8 | background-position: 400px 0;
9 | }
10 | }
11 |
12 | .skeleton-container {
13 | background-color: var(--bg-lighter);
14 | background-image: linear-gradient(to right, var(--bg-lighter) 0%, var(--bg-light) 20%, var(--bg-lighter) 40%, var(--bg-lighter) 100%);
15 | background-repeat: no-repeat;
16 | background-size: 800px 100%;
17 | animation: shimmer 1.5s linear infinite;
18 | overflow: hidden;
19 | position: relative; /* Needed for the image to overlay correctly */
20 | }
21 |
22 | /* The image starts invisible and centered */
23 | .skeleton-container .lazy-image {
24 | opacity: 0;
25 | transition: opacity 0.4s ease-in-out;
26 | position: absolute;
27 | top: 0;
28 | left: 0;
29 | width: 100%;
30 | height: 100%;
31 | object-fit: cover;
32 | }
33 |
34 | /* When loaded, the image becomes visible */
35 | .skeleton-container .lazy-image.is-loaded {
36 | opacity: 1;
37 | }
--------------------------------------------------------------------------------
/public/css/components/_toast.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_toast.css */
2 |
3 | #toast-container {
4 | position: fixed;
5 | bottom: 20px;
6 | right: 20px;
7 | z-index: 2000;
8 | display: flex;
9 | flex-direction: column;
10 | gap: 10px;
11 | }
12 |
13 | .toast {
14 | background-color: var(--bg-light);
15 | color: var(--text-main);
16 | padding: 16px 24px;
17 | border-radius: 8px;
18 | border-left: 4px solid var(--primary-color);
19 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
20 | display: flex;
21 | align-items: center;
22 | gap: 12px;
23 | transform: translateX(120%);
24 | animation: slideIn 0.3s forwards, slideOut 0.3s 3.5s forwards;
25 | pointer-events: auto;
26 | }
27 |
28 | .toast.success {
29 | border-color: var(--green-status);
30 | }
31 |
32 | .toast.error {
33 | border-color: var(--danger-color);
34 | }
35 |
36 | @keyframes slideIn {
37 | to {
38 | transform: translateX(0);
39 | }
40 | }
41 |
42 | @keyframes slideOut {
43 | from {
44 | transform: translateX(0);
45 | }
46 |
47 | to {
48 | transform: translateX(120%);
49 | }
50 | }
--------------------------------------------------------------------------------
/public/css/components/_buttons.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_buttons.css */
2 |
3 | .btn {
4 | padding: 10px 20px;
5 | border: none;
6 | border-radius: 8px;
7 | font-weight: 600;
8 | cursor: pointer;
9 | transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
10 | font-size: 14px;
11 | display: inline-flex;
12 | align-items: center;
13 | gap: 8px;
14 | white-space: nowrap;
15 | /* Prevent buttons from wrapping text */
16 | }
17 |
18 | .btn-primary {
19 | background-color: var(--primary-color);
20 | color: #FFF;
21 | }
22 |
23 | .btn-primary:hover {
24 | background-color: var(--primary-hover);
25 | }
26 |
27 | .btn-danger {
28 | background-color: var(--danger-color);
29 | color: #FFF;
30 | }
31 |
32 | .btn-danger:hover {
33 | background-color: var(--danger-hover);
34 | }
35 |
36 | .btn-secondary {
37 | background-color: var(--bg-light);
38 | color: var(--text-main);
39 | border: 1px solid var(--border-color);
40 | }
41 |
42 | .btn-secondary:hover {
43 | background-color: var(--bg-lighter);
44 | }
45 |
46 | body.light-mode .btn-secondary:hover {
47 | background-color: #e5e7eb;
48 | }
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "firestore": {
3 | "rules": "firestore.rules"
4 | },
5 | "hosting": {
6 | "public": "public",
7 | "ignore": [
8 | "firebase.json",
9 | "**/.*",
10 | "**/node_modules/**"
11 | ],
12 | "rewrites": [
13 | {
14 | "source": "/dashboard",
15 | "destination": "/app.html"
16 | },
17 | {
18 | "source": "/privacy",
19 | "destination": "/privacy.html"
20 | },
21 | {
22 | "source": "/t&c",
23 | "destination": "/terms.html"
24 | }
25 | ],
26 | "cleanUrls": true
27 | },
28 | "functions": [
29 | {
30 | "source": "functions",
31 | "codebase": "default",
32 | "ignore": [
33 | "node_modules",
34 | ".git",
35 | "firebase-debug.log",
36 | "firebase-debug.*.log",
37 | "*.local"
38 | ],
39 | "runtime": "nodejs20"
40 | }
41 | ],
42 | "emulators": {
43 | "functions": {
44 | "port": 5001
45 | },
46 | "hosting": {
47 | "port": 5000
48 | },
49 | "ui": {
50 | "enabled": true
51 | },
52 | "singleProjectMode": true,
53 | "auth": {
54 | "port": 9099
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/public/js/settings-manager.js:
--------------------------------------------------------------------------------
1 | // js/settings-manager.js
2 |
3 | import { doc, onSnapshot, setDoc } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
4 | import { showToast } from './ui.js';
5 |
6 | function getSettingsDoc(db, userId) {
7 | return doc(db, 'users', userId, 'settings', 'main');
8 | }
9 |
10 | export function listenForSettingsChanges(userId, db, callback) {
11 | onSnapshot(getSettingsDoc(db, userId), doc => {
12 | if (doc.exists()) {
13 | callback(doc.data());
14 | } else {
15 | callback({});
16 | }
17 | }, error => {
18 | console.error("Error fetching settings: ", error);
19 | showToast("Could not load user settings.", "error");
20 | });
21 | }
22 |
23 | export async function setGlobalDefaultContent(userId, db, content) {
24 | try {
25 | await setDoc(getSettingsDoc(db, userId), {
26 | globalDefaultContent: content || null
27 | }, { merge: true });
28 |
29 | if (content) {
30 | showToast(`'${content.data.name}' is now the global fallback.`);
31 | } else {
32 | showToast('Global fallback content cleared.');
33 | }
34 | return true;
35 | } catch (error) {
36 | console.error("Error setting global fallback: ", error);
37 | showToast("Failed to update global fallback.", "error");
38 | return false;
39 | }
40 | }
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | https://sign-net.app/
7 | 2025-07-28
8 | weekly
9 | 1.0
10 |
11 |
12 |
13 |
14 | https://sign-net.app/features.html
15 | 2025-07-30
16 | monthly
17 | 0.9
18 |
19 |
20 |
21 |
22 | https://sign-net.app/guides/getting-started.html
23 | 2025-07-30
24 | monthly
25 | 0.9
26 |
27 |
28 |
29 | https://sign-net.app/guides/creating-playlists.html
30 | 2025-07-30
31 | monthly
32 | 0.9
33 |
34 |
35 |
36 |
37 | https://sign-net.app/privacy
38 | 2024-05-21
39 | yearly
40 | 0.8
41 |
42 |
43 |
44 |
45 | https://sign-net.app/terms
46 | 2024-05-21
47 | yearly
48 | 0.8
49 |
50 |
51 |
--------------------------------------------------------------------------------
/public/_footer.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/js/page-loader.js:
--------------------------------------------------------------------------------
1 | // js/page-loader.js
2 | document.addEventListener('DOMContentLoaded', () => {
3 | const loadComponent = (selector, url) => {
4 | const element = document.querySelector(selector);
5 | if (element) {
6 | fetch(url)
7 | .then(response => response.ok ? response.text() : Promise.reject('Component not found'))
8 | .then(data => {
9 | element.innerHTML = data;
10 | // Re-attach event listeners for dynamically loaded content
11 | if (selector === '#header-placeholder') attachHeaderListeners();
12 | if (selector === '#footer-placeholder') attachFooterListeners();
13 | })
14 | .catch(error => console.error(`Error loading ${url}:`, error));
15 | }
16 | };
17 |
18 | const attachHeaderListeners = () => {
19 | // Your mobile menu toggle, login buttons, etc. from index.html's script tag
20 | const menuToggleBtn = document.getElementById('menu-toggle-btn');
21 | const navLinks = document.getElementById('nav-links');
22 | if (menuToggleBtn && navLinks) {
23 | menuToggleBtn.addEventListener('click', () => navLinks.classList.toggle('active'));
24 | }
25 | // Add login/signup handlers if they exist on the page
26 | document.getElementById('login-btn')?.addEventListener('click', handleLogin);
27 | document.getElementById('signup-btn')?.addEventListener('click', handleLogin);
28 | };
29 |
30 | const attachFooterListeners = () => {
31 | // Attach handlers for footer login/signup buttons
32 | document.getElementById('footer-login-btn')?.addEventListener('click', handleLogin);
33 | document.getElementById('footer-signup-btn')?.addEventListener('click', handleLogin);
34 | };
35 |
36 | loadComponent('#header-placeholder', '/_header.html');
37 | loadComponent('#footer-placeholder', '/_footer.html');
38 | });
--------------------------------------------------------------------------------
/public/js/playlist-manager.js:
--------------------------------------------------------------------------------
1 | // public/js/playlist-manager.js
2 |
3 | import { collection, query, where, orderBy, onSnapshot, doc, addDoc, updateDoc, deleteDoc, serverTimestamp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
4 | import { showToast } from './ui.js';
5 |
6 | // DO NOT define the collection here. It must be defined inside the functions.
7 |
8 | export function listenForPlaylistsChanges(userId, db, callback) {
9 | const playlistsCollection = collection(db, 'playlists'); // Define it here
10 | const q = query(playlistsCollection, where("adminUid", "==", userId), orderBy("createdAt", "desc"));
11 | onSnapshot(q, callback, error => {
12 | console.error("Error fetching playlists: ", error);
13 | showToast("Error connecting to playlists.", "error");
14 | });
15 | }
16 |
17 | export async function addPlaylist(db, userId, playlistData) {
18 | try {
19 | await addDoc(collection(db, 'playlists'), { // Call it directly here
20 | ...playlistData,
21 | adminUid: userId,
22 | createdAt: serverTimestamp()
23 | });
24 | showToast('Playlist created successfully!');
25 | return true;
26 | } catch (error) {
27 | console.error("Error creating playlist: ", error);
28 | showToast('Failed to create playlist.', 'error');
29 | return false;
30 | }
31 | }
32 |
33 | export async function updatePlaylist(db, playlistId, playlistData) {
34 | try {
35 | const playlistRef = doc(db, 'playlists', playlistId);
36 | await updateDoc(playlistRef, playlistData);
37 | showToast('Playlist updated successfully!');
38 | return true;
39 | } catch (error) {
40 | console.error("Error updating playlist: ", error);
41 | showToast('Failed to update playlist.', 'error');
42 | return false;
43 | }
44 | }
45 |
46 | export async function deletePlaylist(db, playlistId) {
47 | try {
48 | const playlistRef = doc(db, 'playlists', playlistId);
49 | await deleteDoc(playlistRef);
50 | showToast('Playlist deleted successfully.');
51 | return true;
52 | } catch (error) {
53 | console.error("Error deleting playlist: ", error);
54 | showToast('Failed to delete playlist.', 'error');
55 | return false;
56 | }
57 | }
--------------------------------------------------------------------------------
/public/js/scheduler-manager.js:
--------------------------------------------------------------------------------
1 | // public/js/scheduler-manager.js
2 |
3 | import { collection, query, where, orderBy, onSnapshot, addDoc, updateDoc, doc, deleteDoc, serverTimestamp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
4 | import { showToast } from './ui.js';
5 |
6 | export function listenForScheduleChanges(userId, db, callback) {
7 | const scheduleCollection = collection(db, 'schedules'); // Define it here
8 | const q = query(scheduleCollection, where("adminUid", "==", userId), orderBy("startTime"));
9 | onSnapshot(q, snapshot => {
10 | const scheduleItems = snapshot.docs.map(doc => ({
11 | id: doc.id,
12 | ...doc.data()
13 | }));
14 | callback(scheduleItems);
15 | }, error => {
16 | console.error("Error fetching schedule: ", error);
17 | showToast("Error connecting to schedule.", "error");
18 | });
19 | }
20 |
21 | export async function addScheduleItem(db, userId, itemData) {
22 | const scheduleCollection = collection(db, 'schedules'); // Define it here
23 | try {
24 | await addDoc(scheduleCollection, {
25 | ...itemData,
26 | createdAt: serverTimestamp(),
27 | adminUid: userId
28 | });
29 | showToast('Schedule item created successfully!');
30 | return true;
31 | } catch (error) {
32 | console.error("Error adding schedule item: ", error);
33 | showToast("Failed to create schedule item.", "error");
34 | return false;
35 | }
36 | }
37 |
38 | export async function updateScheduleItem(db, id, itemData) {
39 | try {
40 | const scheduleRef = doc(db, 'schedules', id);
41 | await updateDoc(scheduleRef, itemData);
42 | showToast('Schedule item updated successfully!');
43 | return true;
44 | } catch (error) {
45 | console.error("Error updating schedule item: ", error);
46 | showToast("Failed to update schedule item.", "error");
47 | return false;
48 | }
49 | }
50 |
51 | export async function deleteScheduleItem(db, id) {
52 | try {
53 | const scheduleRef = doc(db, 'schedules', id);
54 | await deleteDoc(scheduleRef);
55 | showToast('Schedule item deleted.');
56 | return true;
57 | } catch (error) {
58 | console.error("Error deleting schedule item: ", error);
59 | showToast("Failed to delete schedule item.", "error");
60 | return false;
61 | }
62 | }
--------------------------------------------------------------------------------
/public/js/screens-manager.js:
--------------------------------------------------------------------------------
1 | // js/screens-manager.js
2 |
3 | import { collection, query, where, orderBy, onSnapshot, doc, addDoc, updateDoc, deleteDoc } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
4 | import { showToast } from './ui.js';
5 |
6 | export function listenForScreenChanges(userId, db, callback) {
7 | const screensCollection = collection(db, 'screens');
8 | const q = query(screensCollection, where("adminUid", "==", userId), orderBy("name"));
9 | onSnapshot(q, snapshot => {
10 | const screens = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
11 | callback(screens);
12 | }, error => {
13 | console.error("Error fetching screens: ", error);
14 | showToast("Error connecting to database.", "error");
15 | });
16 | }
17 |
18 | export async function addScreen(db, userId, name, defaultContent = null) {
19 | const screensCollection = collection(db, 'screens');
20 | try {
21 | await addDoc(screensCollection, {
22 | name: name,
23 | defaultContent: defaultContent,
24 | adminUid: userId
25 | });
26 | showToast('Screen created successfully!');
27 | return true;
28 | } catch (error) {
29 | console.error("Error creating screen: ", error);
30 | showToast('Error creating screen.', 'error');
31 | return false;
32 | }
33 | }
34 |
35 | export async function updateScreen(db, id, newName, defaultContent = null) {
36 | try {
37 | const screenRef = doc(db, 'screens', id);
38 | // This will overwrite the document with the new structure,
39 | // effectively removing the old `defaultImage` field if it exists.
40 | await updateDoc(screenRef, {
41 | name: newName,
42 | defaultContent: defaultContent
43 | });
44 | showToast('Screen updated successfully!');
45 | return true;
46 | } catch (error) {
47 | console.error("Error updating screen: ", error);
48 | showToast('Error updating screen.', 'error');
49 | return false;
50 | }
51 | }
52 |
53 | export async function deleteScreen(db, id) {
54 | try {
55 | const screenRef = doc(db, 'screens', id);
56 | await deleteDoc(screenRef);
57 | showToast('Screen deleted successfully.');
58 | return true;
59 | } catch (error) {
60 | console.error("Error deleting screen: ", error);
61 | showToast('Error deleting screen.', 'error');
62 | return false;
63 | }
64 | }
--------------------------------------------------------------------------------
/public/js/landing-auth.js:
--------------------------------------------------------------------------------
1 | // js/landing-auth.js
2 |
3 | import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
4 | import { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithPopup } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-auth.js";
5 |
6 | if (typeof firebaseConfig === 'undefined') {
7 | throw new Error("firebaseConfig is not loaded. Check script tags.");
8 | }
9 |
10 | const adminApp = initializeApp(firebaseConfig, "ADMIN");
11 | const auth = getAuth(adminApp);
12 | const provider = new GoogleAuthProvider();
13 |
14 | const loginError = document.getElementById('login-error');
15 |
16 | onAuthStateChanged(auth, user => {
17 | if (user) {
18 | // If the user is logged in and they are NOT on a page that is part of the app...
19 | // This prevents redirect loops if they are already on the dashboard.
20 | if (!window.location.pathname.includes('/dashboard')) {
21 | // ...redirect them to the dashboard.
22 | window.location.href = '/dashboard';
23 | }
24 | }
25 | });
26 |
27 |
28 | // --- Function to handle the Google Login process ---
29 | const handleLogin = () => {
30 | if (auth.currentUser) {
31 | window.location.href = '/dashboard';
32 | return;
33 | }
34 |
35 | if(loginError) loginError.textContent = '';
36 |
37 | signInWithPopup(auth, provider)
38 | .then((result) => {
39 | // The onAuthStateChanged listener above will now handle the redirect globally.
40 | }).catch((error) => {
41 | console.error("Google Sign-In Error:", error.code, error.message);
42 | if(loginError) loginError.textContent = "Could not sign in. Please try again.";
43 | });
44 | };
45 |
46 | // Expose handleLogin to the Global Scope for page-loader.js
47 | window.handleLogin = handleLogin;
48 |
49 | // Attach login handlers to buttons that are statically part of index.html
50 | // This is for the homepage specifically. page-loader.js will handle other pages.
51 | document.getElementById('login-btn')?.addEventListener('click', handleLogin);
52 | document.getElementById('signup-btn')?.addEventListener('click', handleLogin);
53 | document.getElementById('get-started-btn')?.addEventListener('click', handleLogin);
54 | document.getElementById('final-cta-btn')?.addEventListener('click', handleLogin);
55 | document.getElementById('footer-login-btn')?.addEventListener('click', handleLogin);
56 | document.getElementById('footer-signup-btn')?.addEventListener('click', handleLogin);
--------------------------------------------------------------------------------
/firestore.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service cloud.firestore {
3 | match /databases/{database}/documents {
4 |
5 | // Helper function to check if the requester is the owner of a document.
6 | function isOwner(resource) {
7 | return request.auth.uid == resource.data.adminUid;
8 | }
9 |
10 | // --- User Data ---
11 | match /users/{userId}/{document=**} {
12 | allow read, write: if request.auth.uid == userId;
13 | }
14 |
15 | // --- Screens Collection ---
16 | match /screens/{screenId} {
17 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
18 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/screens/$(screenId)).data.adminUid;
19 | allow list: if request.auth.uid == resource.data.adminUid;
20 | allow get: if (request.auth.uid == resource.data.adminUid) || (request.auth.uid == screenId);
21 | }
22 |
23 | // --- Media Collection ---
24 | match /media/{mediaId} {
25 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
26 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/media/$(mediaId)).data.adminUid;
27 | allow read: if request.auth.uid == resource.data.adminUid;
28 | }
29 |
30 | // --- Playlists Collection (THIS IS THE FIX) ---
31 | match /playlists/{playlistId} {
32 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
33 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/playlists/$(playlistId)).data.adminUid;
34 |
35 | // An Admin can read their own playlists.
36 | // A Screen can read a playlist IF that playlist belongs to the screen's owner.
37 | allow read: if request.auth.uid == resource.data.adminUid ||
38 | (exists(/databases/$(database)/documents/screens/$(request.auth.uid)) &&
39 | get(/databases/$(database)/documents/screens/$(request.auth.uid)).data.adminUid == resource.data.adminUid);
40 | }
41 |
42 | // --- Schedules Collection ---
43 | match /schedules/{scheduleId} {
44 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
45 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/schedules/$(scheduleId)).data.adminUid;
46 | allow read: if (request.auth.uid == resource.data.adminUid) || (request.auth.uid in resource.data.screenIds);
47 | }
48 |
49 | // --- Special Rule for a Screen to read its owner's settings ---
50 | match /users/{userId}/settings/main {
51 | allow get: if exists(/databases/$(database)/documents/screens/$(request.auth.uid)) &&
52 | get(/databases/$(database)/documents/screens/$(request.auth.uid)).data.adminUid == userId;
53 | }
54 |
55 | // --- Pairing Requests ---
56 | match /pairingRequests/{pairingId} {
57 | allow read, create: if true;
58 | allow update: if request.auth.token.firebase.sign_in_provider != 'custom';
59 | }
60 | }
61 | }
--------------------------------------------------------------------------------
/functions/index.js:
--------------------------------------------------------------------------------
1 | // functions/index.js
2 |
3 | const { onCall } = require("firebase-functions/v2/https");
4 | const { setGlobalOptions } = require("firebase-functions/v2");
5 | const admin = require("firebase-admin");
6 | const logger = require("firebase-functions/logger");
7 |
8 | // Set runtime options for all functions in this file
9 | setGlobalOptions({ runtime: 'nodejs20' });
10 |
11 | admin.initializeApp();
12 | const db = admin.firestore();
13 |
14 |
15 | // --- FUNCTION 1: Publicly Callable to Generate a PIN ---
16 | exports.generatePairingCode = onCall(async (request) => {
17 | logger.info("generatePairingCode triggered.");
18 |
19 | const pin = Math.floor(100000 + Math.random() * 900000).toString();
20 | const pairingSessionId = db.collection("temp").doc().id;
21 | const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
22 |
23 | const pairingRequestRef = db.collection("pairingRequests").doc(pairingSessionId);
24 | await pairingRequestRef.set({
25 | pin: pin,
26 | status: "pending",
27 | createdAt: admin.firestore.FieldValue.serverTimestamp(),
28 | expiresAt: expiresAt,
29 | });
30 |
31 | logger.info(`Successfully created pairing request ${pairingSessionId}.`);
32 | return { pin, pairingSessionId };
33 | });
34 |
35 |
36 | // --- FUNCTION 2: Completes Pairing for an Existing Screen ID ---
37 | // This function requires an authenticated admin user.
38 | exports.completePairing = onCall({ enforceAppCheck: true }, async (request) => {
39 | logger.info("completePairing triggered.");
40 |
41 | if (!request.auth) {
42 | throw new onCall.HttpsError("unauthenticated", "Authentication is required.");
43 | }
44 |
45 | const { pin, screenId } = request.data;
46 | const adminUid = request.auth.uid;
47 |
48 | if (!pin || !screenId) {
49 | throw new onCall.HttpsError("invalid-argument", "A PIN and screenId must be provided.");
50 | }
51 |
52 | const pairingRequestsRef = db.collection("pairingRequests");
53 | const q = pairingRequestsRef.where("pin", "==", pin).where("status", "==", "pending");
54 | const querySnapshot = await q.get();
55 |
56 | if (querySnapshot.empty) {
57 | logger.warn(`Invalid or expired PIN received: ${pin}`);
58 | throw new onCall.HttpsError("not-found", "Invalid or expired PIN. Please try again.");
59 | }
60 |
61 | const pairingDoc = querySnapshot.docs[0];
62 |
63 | // Create a custom authentication token for the screen's ID.
64 | // This allows the screen client to log in as itself.
65 | const customToken = await admin.auth().createCustomToken(screenId);
66 | logger.info(`Created custom token for existing screenId: ${screenId}`);
67 |
68 | // Update the original pairing request to complete it and pass the token.
69 | await pairingDoc.ref.update({
70 | status: "completed",
71 | customToken: customToken,
72 | pairedByUid: adminUid,
73 | screenId: screenId,
74 | });
75 |
76 | logger.info(`Successfully paired screen ${screenId} for admin ${adminUid}.`);
77 | return { success: true, message: "Screen paired successfully!" };
78 | });
--------------------------------------------------------------------------------
/public/view.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Signet Screen
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Signet
24 |
25 |
Pair This Screen
26 |
27 |
28 |
In your Signet dashboard, go to the 'Screens' page, find the correct screen, click the link icon, and enter this code:
29 |
--- ---
30 |
31 |
Requesting pairing code...
32 |
33 |
34 |
35 |
For security, your pairing code has expired. Click the button below to generate a new one.
36 |
Generate New Code
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Screen Not Found
44 |
Please check the URL and ensure the screen is configured correctly in the dashboard.
45 |
46 |
47 |
48 |
49 |
50 |
Reset Screen?
51 |
This will unpair the screen and return it to the pairing code screen. Are you sure you want to continue?
52 |
53 | Cancel
54 | Confirm & Reset
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/public/css/components/_dashboard.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_dashboard.css */
2 |
3 | /* --- DASHBOARD WIDGETS --- */
4 | .widget-grid {
5 | display: grid;
6 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
7 | gap: 24px;
8 | margin-top: 10px;
9 | margin-bottom: 32px;
10 | }
11 |
12 | .widget {
13 | background-color: var(--bg-light);
14 | padding: 24px;
15 | border-radius: 12px;
16 | border: 1px solid var(--border-color);
17 | cursor: pointer;
18 | transition: all 0.2s ease;
19 | }
20 |
21 | .widget:hover {
22 | transform: translateY(-4px);
23 | box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
24 | border-color: var(--primary-color);
25 | }
26 |
27 | .widget-header {
28 | display: flex;
29 | align-items: center;
30 | justify-content: space-between;
31 | color: var(--text-muted);
32 | margin-bottom: 12px;
33 | font-weight: 500;
34 | }
35 |
36 | .widget-value {
37 | font-size: 32px;
38 | font-weight: 700;
39 | }
40 |
41 | .widget-value .online {
42 | color: var(--green-status);
43 | }
44 |
45 | .widget-value .total {
46 | color: var(--text-muted);
47 | font-size: 28px;
48 | }
49 |
50 | /* --- LIVE VIEW & UPCOMING SCHEDULE --- */
51 | .dashboard-grid {
52 | display: grid;
53 | grid-template-columns: 2fr 0fr;
54 | }
55 |
56 | .live-view-container h3,
57 | .upcoming-schedule-container h3 {
58 | font-size: 20px;
59 | font-weight: 600;
60 | margin-bottom: 20px;
61 | padding-bottom: 10px;
62 | border-bottom: 1px solid var(--border-color);
63 | }
64 |
65 | .live-view-grid {
66 | display: grid;
67 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
68 | gap: 20px;
69 | }
70 |
71 | .screen-preview {
72 | border-radius: 12px;
73 | overflow: hidden;
74 | border: 1px solid var(--border-color);
75 | transition: all 0.2s ease;
76 | /* It will now get its shimmer from the .skeleton-container class via JS */
77 | }
78 |
79 | .screen-preview:hover {
80 | transform: scale(1.03);
81 | }
82 |
83 | .screen-preview .image-container {
84 | width: 100%;
85 | height: 160px;
86 | display: block;
87 | background-color: var(--bg-dark);
88 | position: relative; /* Add this for skeleton positioning */
89 | }
90 |
91 | .screen-preview img {
92 | width: 100%;
93 | height: 160px;
94 | object-fit: cover;
95 | display: block;
96 | background-color: var(--bg-dark);
97 | }
98 |
99 | .screen-info {
100 | padding: 16px;
101 | display: flex;
102 | justify-content: space-between;
103 | align-items: center;
104 | }
105 |
106 | .screen-info .name {
107 | font-weight: 600;
108 | }
109 |
110 | .upcoming-schedule-list {
111 | display: flex;
112 | flex-direction: column;
113 | gap: 16px;
114 | }
115 |
116 | .upcoming-item {
117 | background-color: var(--bg-light);
118 | padding: 16px;
119 | border-radius: 8px;
120 | border-left: 4px solid var(--primary-color);
121 | }
122 |
123 | .upcoming-item .time {
124 | font-weight: 600;
125 | font-size: 16px;
126 | margin-bottom: 8px;
127 | }
128 |
129 | .upcoming-item .details {
130 | font-size: 14px;
131 | color: var(--text-muted);
132 | }
133 |
134 | .upcoming-item .details span {
135 | font-weight: 500;
136 | color: var(--text-main);
137 | }
138 |
139 | .dashboard-section-margin-top {
140 | margin-top: 32px;
141 | }
--------------------------------------------------------------------------------
/public/css/components/_tables.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_tables.css */
2 |
3 | .data-table-container {
4 | background-color: var(--bg-light);
5 | border-radius: 12px;
6 | border: 1px solid var(--border-color);
7 | overflow-x: auto;
8 | /* Enable horizontal scroll on mobile */
9 | }
10 |
11 | .data-table {
12 | width: 100%;
13 | border-collapse: collapse;
14 | min-width: 600px;
15 | /* Force scroll on small screens */
16 | }
17 |
18 | .data-table th,
19 | .data-table td {
20 | padding: 16px 24px;
21 | text-align: left;
22 | border-bottom: 1px solid var(--border-color);
23 | }
24 |
25 | .data-table th {
26 | color: var(--text-muted);
27 | font-weight: 600;
28 | text-transform: uppercase;
29 | font-size: 12px;
30 | }
31 |
32 | .data-table tr:last-child td {
33 | border-bottom: none;
34 | }
35 |
36 | .data-table .action-buttons i {
37 | cursor: pointer;
38 | margin-right: 16px;
39 | color: var(--text-muted);
40 | transition: color 0.2s;
41 | font-size: 16px;
42 | }
43 |
44 | .data-table .action-buttons i:hover {
45 | color: var(--primary-color);
46 | }
47 |
48 | .data-table .screen-name {
49 | font-weight: 600;
50 | }
51 |
52 | .data-table .screen-id {
53 | font-family: monospace;
54 | color: var(--text-muted);
55 | }
56 |
57 | .status-dot {
58 | display: flex;
59 | align-items: center;
60 | gap: 8px;
61 | font-size: 14px;
62 | text-transform: capitalize;
63 | }
64 |
65 | .status-dot::before {
66 | content: '';
67 | width: 10px;
68 | height: 10px;
69 | border-radius: 50%;
70 | background-color: var(--green-status);
71 | }
72 |
73 | .status-dot.online::before {
74 | background-color: var(--green-status);
75 | }
76 |
77 | /* Explicitly set online color */
78 | .status-dot.offline::before {
79 | background-color: var(--danger-color);
80 | }
81 |
82 | /* =================================== */
83 | /* --- Responsive Table/Card Layout -- */
84 | /* =================================== */
85 |
86 | /* This rule applies ONLY to screens 768px or narrower */
87 | @media (max-width: 768px) {
88 | .data-table-container {
89 | overflow-x: hidden; /* Disable horizontal scroll for card view */
90 | border: none;
91 | background-color: transparent;
92 | }
93 |
94 | .data-table .screen-id {
95 | display: none;
96 | }
97 |
98 | .data-table .screen-name {
99 | font-weight: 400; /* Change from the default bold to normal weight on mobile */
100 | }
101 |
102 | .data-table {
103 | min-width: 0;
104 | border: none;
105 | box-shadow: none;
106 | }
107 |
108 | .data-table thead {
109 | /* Hides the original table header on mobile */
110 | border: none;
111 | clip: rect(0 0 0 0);
112 | height: 1px;
113 | margin: -1px;
114 | overflow: hidden;
115 | padding: 0;
116 | position: absolute;
117 | width: 1px;
118 | }
119 |
120 | .data-table tr {
121 | display: block;
122 | background-color: var(--bg-light);
123 | border: 1px solid var(--border-color);
124 | border-radius: 12px;
125 | margin-bottom: 16px;
126 | padding: 16px;
127 | }
128 |
129 | .data-table td {
130 | display: flex;
131 | justify-content: space-between;
132 | align-items: center;
133 | padding: 12px 0;
134 | border-bottom: 1px solid var(--border-color);
135 | }
136 |
137 | .data-table td:last-child {
138 | border-bottom: none;
139 | padding-bottom: 0;
140 | justify-content: space-between;
141 | }
142 |
143 | .data-table td:first-child {
144 | padding-top: 0;
145 | }
146 |
147 | .data-table td::before {
148 | content: attr(data-label); /* Use data-label for the title */
149 | font-weight: 600;
150 | color: var(--text-main);
151 | text-align: left;
152 | margin-right: 16px;
153 | }
154 |
155 | /* Adjust specific cells for card view */
156 | .data-table .action-buttons {
157 | justify-content: flex-end; /* Align icons to the end */
158 | }
159 | .data-table .action-buttons i:last-child {
160 | margin-right: 0;
161 | }
162 | }
--------------------------------------------------------------------------------
/public/css/components/_scheduler.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_scheduler.css */
2 |
3 | .scheduler-controls {
4 | display: flex;
5 | justify-content: space-between;
6 | align-items: center;
7 | margin-bottom: 24px;
8 | gap: 20px;
9 | flex-wrap: wrap;
10 | }
11 |
12 | .scheduler-nav {
13 | display: flex;
14 | align-items: center;
15 | gap: 12px;
16 | }
17 |
18 | .scheduler-nav h3 {
19 | font-size: 20px;
20 | font-weight: 600;
21 | text-align: center;
22 | color: var(--text-main);
23 | }
24 |
25 | .scheduler-view-options {
26 | display: flex;
27 | align-items: center;
28 | gap: 16px;
29 | margin-left: auto;
30 | }
31 |
32 | .hidden {
33 | display: none !important;
34 | }
35 |
36 | /* --- TABS UI for View Switcher --- */
37 | .view-tabs {
38 | display: flex;
39 | background-color: var(--bg-lighter);
40 | padding: 4px;
41 | border-radius: 8px;
42 | }
43 |
44 | .view-tabs .btn {
45 | background-color: transparent;
46 | border: none;
47 | font-weight: 500;
48 | color: var(--text-muted);
49 | }
50 |
51 | .view-tabs .btn.active {
52 | background-color: var(--bg-dark);
53 | color: var(--text-main);
54 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
55 | }
56 |
57 | body.light-mode .view-tabs .btn.active {
58 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
59 | }
60 |
61 | /* --- SCHEDULER GRID --- */
62 | .scheduler-viewport {
63 | height: 75vh;
64 | overflow: auto;
65 | position: relative;
66 | border: 1px solid var(--border-color);
67 | border-radius: 12px;
68 | }
69 |
70 | .scheduler-container {
71 | display: grid;
72 | position: relative;
73 | grid-template-rows: auto 1fr;
74 | /* This container will now naturally be 100% width of its parent */
75 | /* and will expand if its content (the columns) becomes wider. */
76 | }
77 |
78 | .scheduler-container .scheduler-header {
79 | grid-row: 1 / 2;
80 | grid-column: 1 / -1;
81 | position: sticky;
82 | top: 0;
83 | background-color: var(--bg-light);
84 | z-index: 10;
85 | display: grid;
86 | border-bottom: 1px solid var(--border-color);
87 | }
88 |
89 | .scheduler-container .scheduler-header>div {
90 | border-left: 1px solid var(--border-color);
91 | padding: 16px 8px;
92 | font-weight: 600;
93 | text-align: center;
94 | font-size: 14px;
95 | }
96 |
97 | .scheduler-container .scheduler-header>div:first-child {
98 | border-left: none;
99 | }
100 |
101 | .timeline {
102 | grid-row: 2 / -1;
103 | grid-column: 1 / 2;
104 | position: sticky;
105 | left: 0;
106 | z-index: 11;
107 | background-color: var(--bg-light);
108 | }
109 |
110 | .timeline .time-slot {
111 | height: 60px;
112 | display: flex;
113 | align-items: center;
114 | justify-content: center;
115 | font-size: 12px;
116 | color: var(--text-muted);
117 | border-right: 1px solid var(--border-color);
118 | border-top: 1px solid var(--border-color);
119 | }
120 |
121 | .day-column,
122 | .screen-column {
123 | grid-row: 2 / -1;
124 | position: relative;
125 | border-left: 1px solid var(--border-color);
126 | /* min-width is now handled by minmax in the grid template */
127 | overflow: hidden;
128 | }
129 |
130 | .scheduled-item {
131 | position: absolute;
132 | left: 8px;
133 | right: 8px;
134 | background-color: var(--primary-color);
135 | border-radius: 6px;
136 | padding: 8px;
137 | overflow: hidden;
138 | cursor: pointer;
139 | border: 1px solid var(--primary-hover);
140 | transition: all 0.2s ease;
141 | z-index: 2;
142 | display: flex;
143 | flex-direction: column;
144 | justify-content: center;
145 | }
146 |
147 | .scheduled-item:hover {
148 | transform: scale(1.02);
149 | z-index: 5;
150 | background-color: var(--primary-hover);
151 | }
152 |
153 | .scheduled-item .item-media-name {
154 | font-size: 13px;
155 | font-weight: 600;
156 | color: #fff;
157 | white-space: nowrap;
158 | overflow: hidden;
159 | text-overflow: ellipsis;
160 | }
161 |
162 | /* --- CURRENT HOUR HIGHLIGHT --- */
163 | .timeline .time-slot.current-hour {
164 | font-weight: 700;
165 | color: var(--primary-color);
166 | }
167 |
168 | .current-hour-highlight {
169 | position: absolute;
170 | left: 0;
171 | width: 100%;
172 | height: 60px;
173 | background-color: rgba(59, 130, 246, 0.1);
174 | z-index: 1;
175 | pointer-events: none;
176 | }
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Signet - Page Not Found
7 |
8 |
9 |
10 |
11 |
12 |
13 |
141 |
142 |
143 |
144 |
161 |
162 |
163 |
--------------------------------------------------------------------------------
/public/css/view.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | background-color: #000;
5 | color: #eee;
6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
7 | overflow: hidden;
8 | }
9 |
10 | /* --- START OF FIX: New styles for double-buffer system --- */
11 | #image-container {
12 | width: 100vw;
13 | height: 100vh;
14 | position: fixed;
15 | top: 0;
16 | left: 0;
17 | z-index: 10;
18 | background-color: #000;
19 | }
20 |
21 | .image-slot {
22 | position: absolute;
23 | top: 0;
24 | left: 0;
25 | width: 100%;
26 | height: 100%;
27 | object-fit: contain;
28 | /* The cross-fade transition */
29 | opacity: 0;
30 | transition: opacity 0.8s ease-in-out;
31 | }
32 |
33 | .image-slot.visible {
34 | opacity: 1;
35 | }
36 | /* --- END OF FIX --- */
37 |
38 |
39 | /* --- PAIRING SCREEN --- */
40 | #pairing-container {
41 | display: flex;
42 | justify-content: center;
43 | align-items: center;
44 | width: 100vw;
45 | height: 100vh;
46 | background-color: #111827;
47 | position: fixed;
48 | top: 0;
49 | left: 0;
50 | z-index: 100;
51 | opacity: 1;
52 | transition: opacity 0.5s ease;
53 | }
54 |
55 | #pairing-container.hidden {
56 | opacity: 0;
57 | pointer-events: none;
58 | }
59 |
60 | .pairing-box {
61 | background-color: #1F2937;
62 | padding: 40px 60px;
63 | border-radius: 16px;
64 | text-align: center;
65 | box-shadow: 0 10px 40px rgba(0,0,0,0.5);
66 | max-width: 550px;
67 | border: 1px solid #333;
68 | }
69 |
70 | .pairing-logo {
71 | display: flex;
72 | align-items: center;
73 | justify-content: center;
74 | gap: 15px;
75 | margin-bottom: 30px;
76 | color: #F9FAFB;
77 | }
78 |
79 | .pairing-logo i {
80 | font-size: 2.5rem;
81 | color: #3B82F6;
82 | }
83 |
84 | .pairing-logo span {
85 | font-size: 2.5rem;
86 | font-weight: 700;
87 | }
88 |
89 | .pairing-box h1 {
90 | font-size: 2.2rem;
91 | margin-bottom: 1rem;
92 | color: #fff;
93 | }
94 |
95 | .pairing-box p {
96 | font-size: 1.1rem;
97 | color: #9CA3AF;
98 | margin-bottom: 2rem;
99 | line-height: 1.6;
100 | }
101 |
102 | #pairing-pin-display {
103 | font-size: 4rem;
104 | font-weight: bold;
105 | letter-spacing: 15px;
106 | color: #fff;
107 | background-color: #111827;
108 | padding: 20px 30px;
109 | border-radius: 8px;
110 | margin-bottom: 2rem;
111 | font-family: "Courier New", Courier, monospace;
112 | border: 1px solid #333;
113 | }
114 |
115 | .pairing-box .status-text {
116 | color: #888;
117 | font-size: 1rem;
118 | margin-top: 20px;
119 | margin-bottom: 0;
120 | height: 20px;
121 | transition: color 0.3s ease;
122 | }
123 |
124 | .spinner {
125 | width: 40px;
126 | height: 40px;
127 | border: 4px solid #444;
128 | border-top-color: #fff;
129 | border-radius: 50%;
130 | animation: spin 1s linear infinite;
131 | margin: 20px auto 0;
132 | }
133 |
134 | @keyframes spin {
135 | to { transform: rotate(360deg); }
136 | }
137 |
138 | .pairing-stale-content {
139 | display: none;
140 | }
141 |
142 | .pairing-box.stale-session-active .pairing-active-content {
143 | display: none;
144 | }
145 |
146 | .pairing-box.stale-session-active .pairing-stale-content {
147 | display: block;
148 | }
149 |
150 | .pairing-stale-content .btn-primary {
151 | background-color: #3B82F6;
152 | color: #fff;
153 | padding: 12px 28px;
154 | border-radius: 8px;
155 | font-weight: 600;
156 | font-size: 1.1rem;
157 | border: none;
158 | cursor: pointer;
159 | transition: background-color 0.2s ease;
160 | }
161 |
162 | .pairing-stale-content .btn-primary:hover {
163 | background-color: #2563EB;
164 | }
165 |
166 | /* --- ERROR & LOGOUT MODAL STYLES --- */
167 | .overlay-container {
168 | display: flex;
169 | justify-content: center;
170 | align-items: center;
171 | width: 100vw;
172 | height: 100vh;
173 | background-color: rgba(0, 0, 0, 0.7);
174 | backdrop-filter: blur(5px);
175 | position: fixed;
176 | top: 0;
177 | left: 0;
178 | z-index: 200;
179 | opacity: 0;
180 | pointer-events: none;
181 | transition: opacity 0.3s ease;
182 | }
183 |
184 | .overlay-container.visible {
185 | opacity: 1;
186 | pointer-events: auto;
187 | }
188 |
189 | .overlay-box {
190 | background-color: #1F2937;
191 | padding: 40px;
192 | border-radius: 12px;
193 | text-align: center;
194 | max-width: 450px;
195 | color: #9CA3AF;
196 | }
197 |
198 | .overlay-box h1 {
199 | color: #fff;
200 | margin-top: 0;
201 | font-size: 1.8rem;
202 | }
203 |
204 | .modal-actions {
205 | margin-top: 30px;
206 | display: flex;
207 | gap: 15px;
208 | justify-content: center;
209 | }
210 |
211 | .modal-actions .btn {
212 | padding: 10px 25px;
213 | border: none;
214 | border-radius: 8px;
215 | font-size: 1rem;
216 | font-weight: 600;
217 | cursor: pointer;
218 | transition: background-color 0.2s ease;
219 | }
220 |
221 | .modal-actions .btn-secondary {
222 | background-color: #374151;
223 | color: #fff;
224 | }
225 | .modal-actions .btn-secondary:hover {
226 | background-color: #4b5563;
227 | }
228 |
229 | .modal-actions .btn-danger {
230 | background-color: #EF4444;
231 | color: #fff;
232 | }
233 | .modal-actions .btn-danger:hover {
234 | background-color: #f87171;
235 | }
--------------------------------------------------------------------------------
/public/js/media-manager.js:
--------------------------------------------------------------------------------
1 | // js/media-manager.js
2 |
3 | import { collection, query, where, orderBy, onSnapshot, addDoc, updateDoc, doc, deleteDoc, serverTimestamp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
4 | import { ref, uploadBytesResumable, getDownloadURL, deleteObject } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-storage.js";
5 | import { showToast } from './ui.js';
6 |
7 | export function listenForMediaChanges(userId, db, callback) {
8 | const mediaCollection = collection(db, 'media');
9 | const q = query(mediaCollection, where("adminUid", "==", userId), orderBy("uploadedAt", "desc"));
10 | onSnapshot(q, callback, error => {
11 | console.error("Error fetching media: ", error);
12 | showToast("Error connecting to media library.", "error");
13 | });
14 | }
15 |
16 |
17 | // A simple queue to manage uploads one by one
18 | const uploadQueue = [];
19 | let isCurrentlyUploading = false;
20 | let totalFilesInBatch = 0; // To track total progress
21 |
22 | async function processQueue(storage, db, userId) {
23 | if (isCurrentlyUploading || uploadQueue.length === 0) {
24 | return;
25 | }
26 | isCurrentlyUploading = true;
27 |
28 | const file = uploadQueue.shift(); // Get the next file from the queue
29 | const dropArea = document.querySelector('.media-upload-area');
30 | const uploadText = dropArea?.querySelector('h3');
31 | const progressBar = dropArea?.querySelector('.progress-bar');
32 |
33 | // Calculate how many files have been completed so far
34 | const filesCompleted = totalFilesInBatch - uploadQueue.length - 1;
35 |
36 | if (uploadText) {
37 | uploadText.textContent = `Uploading ${filesCompleted + 1} of ${totalFilesInBatch}...`;
38 | }
39 |
40 | try {
41 | await uploadSingleFile(storage, db, userId, file);
42 |
43 | const overallProgress = ((filesCompleted + 1) / totalFilesInBatch) * 100;
44 | if (progressBar) progressBar.style.width = overallProgress + '%';
45 |
46 | } catch (error) {
47 | console.error("Upload failed for file:", file.name, error);
48 | showToast(`Upload failed for ${file.name}.`, 'error');
49 | // In case of error, we still count it as "processed" for progress
50 | const overallProgress = ((filesCompleted + 1) / totalFilesInBatch) * 100;
51 | if (progressBar) progressBar.style.width = overallProgress + '%';
52 | }
53 |
54 | isCurrentlyUploading = false;
55 |
56 | if (uploadQueue.length > 0) {
57 | processQueue(storage, db, userId); // Process next file
58 | } else {
59 | // All uploads finished
60 | setTimeout(() => {
61 | if (dropArea) {
62 | dropArea.classList.remove('is-uploading');
63 | if (uploadText) uploadText.textContent = 'Drag & Drop or Click to Upload';
64 | if (progressBar) progressBar.style.width = '0%';
65 | }
66 | showToast('All files uploaded successfully!');
67 | }, 500); // Small delay to show 100% completion
68 | }
69 | }
70 |
71 | // The onProgress callback is no longer needed for the overall progress bar
72 | function uploadSingleFile(storage, db, userId, file) {
73 | return new Promise((resolve, reject) => {
74 | const mediaCollection = collection(db, 'media');
75 | const storagePath = `media/${userId}/${Date.now()}-${file.name}`;
76 | const storageRef = ref(storage, storagePath);
77 | const uploadTask = uploadBytesResumable(storageRef, file);
78 |
79 | uploadTask.on('state_changed',
80 | null, // We don't need the progress snapshot anymore
81 | (error) => {
82 | reject(error);
83 | },
84 | async () => {
85 | try {
86 | const downloadURL = await getDownloadURL(uploadTask.snapshot.ref);
87 | await addDoc(mediaCollection, {
88 | name: file.name,
89 | url: downloadURL,
90 | storagePath: storagePath,
91 | uploadedAt: serverTimestamp(),
92 | adminUid: userId
93 | });
94 | resolve();
95 | } catch (error) {
96 | reject(error);
97 | }
98 | }
99 | );
100 | });
101 | }
102 |
103 | export function handleFileUpload(storage, db, userId, files) {
104 | const dropArea = document.querySelector('.media-upload-area');
105 | if (!dropArea || dropArea.classList.contains('is-uploading')) {
106 | showToast('Please wait for the current uploads to finish.', 'error');
107 | return;
108 | }
109 |
110 | const validFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
111 | if (validFiles.length === 0) {
112 | showToast('No valid image files selected.', 'error');
113 | return;
114 | }
115 |
116 | // Reset and populate the queue
117 | uploadQueue.length = 0;
118 | uploadQueue.push(...validFiles);
119 | totalFilesInBatch = validFiles.length;
120 |
121 | dropArea.classList.add('is-uploading');
122 |
123 | // Start processing the queue
124 | if (!isCurrentlyUploading) {
125 | processQueue(storage, db, userId);
126 | }
127 | }
128 |
129 | export async function deleteMediaItem(storage, db, id, storagePath) {
130 | if (!id || !storagePath) { showToast("Could not delete item.", "error"); return; }
131 | try {
132 | await deleteObject(ref(storage, storagePath));
133 | await deleteDoc(doc(db, 'media', id));
134 | showToast("Media item deleted successfully.");
135 | } catch (error) {
136 | console.error("Error deleting media item: ", error);
137 | showToast("Failed to delete media item.", "error");
138 | }
139 | }
140 |
141 | export async function updateMediaName(db, mediaId, newName) {
142 | if (!mediaId || !newName) {
143 | showToast("Invalid data for update.", "error");
144 | return false;
145 | }
146 | try {
147 | await updateDoc(doc(db, 'media', mediaId), { name: newName });
148 | showToast("Filename updated!");
149 | return true;
150 | } catch (error) {
151 | console.error("Error updating media name: ", error);
152 | showToast("Failed to update name.", "error");
153 | return false;
154 | }
155 | }
--------------------------------------------------------------------------------
/public/css/components/_modals.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_modals.css */
2 |
3 | .modal-overlay {
4 | position: fixed;
5 | top: 0;
6 | left: 0;
7 | width: 100%;
8 | height: 100%;
9 | background: rgba(0, 0, 0, 0.7);
10 | backdrop-filter: blur(5px);
11 | z-index: 1001;
12 | display: none;
13 | align-items: center;
14 | justify-content: center;
15 | animation: fadeIn 0.3s ease;
16 | overflow-y: auto;
17 | padding: 20px 0;
18 | }
19 |
20 | .modal-overlay.active {
21 | display: flex;
22 | }
23 |
24 | .modal-content {
25 | background-color: var(--bg-light);
26 | padding: 32px;
27 | border-radius: 16px;
28 | width: 90%;
29 | max-width: 500px;
30 | border: 1px solid var(--border-color);
31 | animation: modal-pop 0.3s ease;
32 | margin: auto;
33 | }
34 |
35 | @keyframes modal-pop {
36 | from {
37 | transform: scale(0.9);
38 | opacity: 0;
39 | }
40 |
41 | to {
42 | transform: scale(1);
43 | opacity: 1;
44 | }
45 | }
46 |
47 | .modal-header {
48 | display: flex;
49 | justify-content: space-between;
50 | align-items: center;
51 | margin-bottom: 24px;
52 | }
53 |
54 | .modal-header h3 {
55 | font-size: 20px;
56 | }
57 |
58 | .modal-header .close-modal {
59 | font-size: 24px;
60 | cursor: pointer;
61 | color: var(--text-muted);
62 | }
63 |
64 | .modal-body {
65 | padding-top: 10px;
66 | }
67 |
68 | .form-group {
69 | margin-bottom: 20px;
70 | }
71 |
72 | .form-group label {
73 | display: block;
74 | font-weight: 500;
75 | margin-bottom: 8px;
76 | font-size: 14px;
77 | }
78 |
79 | .form-group input,
80 | .form-group select,
81 | .form-control {
82 | width: 100%;
83 | background-color: var(--bg-dark);
84 | border: 1px solid var(--border-color);
85 | color: var(--text-main);
86 | padding: 10px 12px;
87 | border-radius: 8px;
88 | font-size: 14px;
89 | transition: border-color 0.2s ease, background-color 0.2s ease;
90 | }
91 |
92 | .form-group input:focus,
93 | .form-group select:focus,
94 | .form-control:focus {
95 | outline: none;
96 | border-color: var(--primary-color);
97 | }
98 |
99 | .form-group input:read-only {
100 | background-color: var(--bg-lighter);
101 | cursor: not-allowed;
102 | }
103 |
104 | .modal-footer {
105 | margin-top: 32px;
106 | display: flex;
107 | justify-content: flex-end;
108 | gap: 12px;
109 | }
110 |
111 | .form-hint {
112 | font-size: 13px;
113 | color: var(--text-muted);
114 | margin-top: -4px;
115 | margin-bottom: 8px;
116 | }
117 |
118 | .form-group-row {
119 | display: flex;
120 | gap: 20px;
121 | }
122 |
123 | .form-group-row .form-group {
124 | flex: 1;
125 | }
126 |
127 | .multi-select-container {
128 | display: flex;
129 | flex-wrap: wrap;
130 | gap: 8px;
131 | }
132 |
133 | .multi-select-item {
134 | background-color: var(--bg-dark);
135 | padding: 6px 12px;
136 | border-radius: 20px;
137 | font-size: 13px;
138 | cursor: pointer;
139 | border: 1px solid var(--border-color);
140 | transition: all 0.2s ease;
141 | user-select: none;
142 | }
143 |
144 | .multi-select-item.selected {
145 | background-color: var(--primary-color);
146 | color: #fff;
147 | border-color: var(--primary-hover);
148 | }
149 |
150 | .modal-tabs {
151 | display: flex;
152 | gap: 5px;
153 | border-bottom: 1px solid var(--border-color);
154 | margin-bottom: 20px;
155 | }
156 |
157 | .modal-tab {
158 | padding: 10px 20px;
159 | background: none;
160 | border: none;
161 | color: var(--text-muted);
162 | font-size: 1rem;
163 | font-weight: 500;
164 | cursor: pointer;
165 | border-bottom: 3px solid transparent;
166 | }
167 |
168 | .modal-tab.active {
169 | color: var(--primary-color);
170 | border-bottom-color: var(--primary-color);
171 | }
172 |
173 |
174 | /* --- START OF CORRECTED TAB LOGIC --- */
175 |
176 | /* Hide all tab content by default */
177 | .image-picker-grid {
178 | display: grid;
179 | grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
180 | gap: 10px;
181 | max-height: 250px;
182 | overflow-y: auto;
183 | padding: 10px;
184 | background: var(--bg-dark);
185 | border-radius: 8px;
186 | border: 1px solid var(--border-color);
187 | }
188 |
189 | /* 2. Define the list layout for the playlist picker. */
190 | .playlist-picker-list {
191 | display: flex; /* This is a key part of the fix */
192 | flex-direction: column;
193 | gap: 8px;
194 | max-height: 250px;
195 | overflow-y: auto;
196 | padding: 10px;
197 | background: var(--bg-dark);
198 | border-radius: 8px;
199 | border: 1px solid var(--border-color);
200 | }
201 |
202 | /* 3. Handle the show/hide logic for tabs. */
203 | /* This fixes the scheduler modal's picker. */
204 | .tab-content {
205 | display: none;
206 | }
207 | .tab-content.active {
208 | display: grid; /* Default to grid for the image picker */
209 | }
210 | .tab-content.playlist-picker-list.active {
211 | display: flex; /* Override to flex for the playlist list */
212 | }
213 |
214 | /* --- END OF CORRECTED TAB LOGIC --- */
215 |
216 |
217 | .image-picker-item {
218 | position: relative;
219 | aspect-ratio: 1/1;
220 | border-radius: 6px;
221 | cursor: pointer;
222 | border: 3px solid transparent;
223 | overflow: hidden;
224 | transition: border-color 0.2s ease;
225 | }
226 |
227 | .image-picker-item img {
228 | width: 100%;
229 | height: 100%;
230 | object-fit: cover;
231 | }
232 |
233 | .image-picker-item.selected {
234 | border-color: var(--primary-color);
235 | }
236 |
237 | .image-picker-item .selection-indicator {
238 | position: absolute;
239 | top: 0;
240 | left: 0;
241 | width: 100%;
242 | height: 100%;
243 | background: rgba(59, 130, 246, 0.5);
244 | color: white;
245 | display: flex;
246 | align-items: center;
247 | justify-content: center;
248 | font-size: 2rem;
249 | opacity: 0;
250 | transition: opacity 0.2s ease;
251 | }
252 |
253 | .image-picker-item.selected .selection-indicator {
254 | opacity: 1;
255 | }
256 |
257 | .playlist-picker-item {
258 | display: flex;
259 | align-items: center;
260 | gap: 12px;
261 | padding: 12px;
262 | border-radius: 6px;
263 | cursor: pointer;
264 | border: 2px solid transparent;
265 | transition: background-color 0.2s, border-color 0.2s;
266 | }
267 |
268 | .playlist-picker-item:hover {
269 | background-color: var(--bg-lighter);
270 | }
271 |
272 | .playlist-picker-item.selected {
273 | border-color: var(--primary-color);
274 | background-color: var(--bg-lighter);
275 | }
276 |
277 | .playlist-picker-item i {
278 | font-size: 1.5rem;
279 | color: var(--primary-color);
280 | }
281 |
282 | .playlist-picker-item span {
283 | font-weight: 500;
284 | }
285 |
286 | .playlist-picker-item .count {
287 | margin-left: auto;
288 | color: var(--text-muted);
289 | font-size: 0.9rem;
290 | }
--------------------------------------------------------------------------------
/public/guides/creating-playlists.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | How to Create a Playlist | Signet Guide
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
Guide: Creating a Dynamic Playlist
30 |
Playlists are the best way to display a rotating series of images. Follow this guide to build and schedule your first one.
31 |
32 |
33 |
34 |
35 |
36 |
37 |
01
38 |
39 |
Create a New Playlist
40 |
From the "Media Library" page, click the "Create Playlist" button. This will open the playlist editor. Give your playlist a clear name that describes its purpose, like "Lobby Welcome Loop" or "Weekly Promotions".
41 |
42 |
43 |
44 |
45 |
Playlist Name
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
02
56 |
57 |
Add and Manage Slides
58 |
Click "Add Slide" to select images from your media library. You can then reorder them by dragging and dropping. Click on the duration (e.g., "10s") on any slide to set how long it should appear on screen.
59 |
60 |
61 |
62 |
15s
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
03
74 |
75 |
Schedule Your Playlist
76 |
Once saved, your playlist acts like any other piece of content. Go to the "Scheduler," create a new event, and this time, select the "Playlists" tab to find and select your newly created playlist.
77 |
89 |
90 |
91 |
92 |
93 |
94 |
Playlist Pro!
95 |
You've now learned the core workflow of Signet. You can create as many playlists as you need for different purposes and screens.
96 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/public/terms.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Terms and Conditions - Signet
7 |
8 |
9 |
10 |
11 |
22 |
23 |
24 |
25 |
Terms and Conditions
26 |
Last updated: 7/10/2025
27 |
Please read these terms and conditions carefully before using Our Service.
28 |
29 |
1. Acknowledgment
30 |
These are the Terms and Conditions governing the use of the Signet service ("Service") and the agreement that operates between You and Us. These Terms and Conditions set out the rights and obligations of all users regarding the use of the Service. Your access to and use of the Service is conditioned on Your acceptance of and compliance with these Terms and Conditions. By accessing or using the Service You agree to be bound by these Terms and Conditions. If You disagree with any part of these Terms and Conditions then You may not access the Service.
31 |
You represent that you are over the age of 18. We do not permit those under 18 to use the Service.
32 |
33 |
2. User Accounts
34 |
When You create an account with Us, You must provide Us information that is accurate, complete, and current at all times. Failure to do so constitutes a breach of the Terms, which may result in immediate termination of Your account on Our Service.
35 |
You are responsible for safeguarding the account that You use to access the Service and for any activities or actions under Your account. You agree not to disclose your login credentials to any third party. You must notify Us immediately upon becoming aware of any breach of security or unauthorized use of Your account.
36 |
37 |
3. Content
38 |
Your Right to Post Content
39 |
Our Service allows You to post, upload, and manage content ("Content"). You are responsible for the Content that You post to the Service, including its legality, reliability, and appropriateness.
40 |
By posting Content to the Service, You grant Us the right and license to use, host, store, display, and distribute such Content on and through the Service, solely for the purpose of operating, providing, and improving the Service. You retain any and all of Your rights to any Content You submit, and You are responsible for protecting those rights.
41 |
Content Restrictions
42 |
You represent and warrant that: (i) the Content is Yours (You own it) or You have the right to use it and grant Us the rights and license as provided in these Terms, and (ii) the posting of Your Content on or through the Service does not violate the privacy rights, publicity rights, copyrights, contract rights or any other rights of any person.
43 |
We reserve the right to remove any Content that is deemed to be in violation of these terms, illegal, or otherwise objectionable, at our sole discretion.
44 |
45 |
4. Intellectual Property
46 |
The Service and its original content (excluding Content provided by You or other users), features and functionality are and will remain the exclusive property of the Signet Project Contributors and its licensors. The Service is protected by copyright and other laws. The underlying source code for the Service is available under the MIT License.
47 |
48 |
5. Termination
49 |
We may terminate or suspend Your Account immediately, without prior notice or liability, for any reason whatsoever, including without limitation if You breach these Terms and Conditions.
50 |
Upon termination, Your right to use the Service will cease immediately. If You wish to terminate Your Account, You may do so by contacting us to request account deletion.
51 |
52 |
6. Limitation of Liability
53 |
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE SIGNET PROJECT CONTRIBUTORS OR ITS SUPPLIERS BE LIABLE FOR ANY SPECIAL, INCIDENTAL, INDIRECT, OR CONSEQUENTIAL DAMAGES WHATSOEVER (INCLUDING, BUT NOT LIMITED TO, DAMAGES FOR LOSS OF PROFITS, LOSS OF DATA OR OTHER INFORMATION, FOR BUSINESS INTERRUPTION, FOR PERSONAL INJURY, LOSS OF PRIVACY ARISING OUT OF OR IN ANY WAY RELATED TO THE USE OF OR INABILITY TO USE THE SERVICE) EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
54 |
55 |
7. "AS IS" and "AS AVAILABLE" Disclaimer
56 |
THE SERVICE IS PROVIDED TO YOU "AS IS" AND "AS AVAILABLE" AND WITH ALL FAULTS AND DEFECTS WITHOUT WARRANTY OF ANY KIND. TO THE MAXIMUM EXTENT PERMITTED UNDER APPLICABLE LAW, WE, ON OUR OWN BEHALF AND ON BEHALF OF OUR AFFILIATES AND OUR AND THEIR RESPECTIVE LICENSORS AND SERVICE PROVIDERS, EXPRESSLY DISCLAIM ALL WARRANTIES, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, WITH RESPECT TO THE SERVICE.
57 |
58 |
8. Governing Law
59 |
The laws of The USA, excluding its conflicts of law rules, shall govern this Terms and Your use of the Service. Your use of the Application may also be subject to other local, state, national, or international laws.
60 |
61 |
9. Changes to These Terms and Conditions
62 |
We reserve the right, at Our sole discretion, to modify or replace these Terms at any time. We will make reasonable efforts to provide at least 30 days' notice prior to any new terms taking effect. By continuing to access or use Our Service after those revisions become effective, You agree to be bound by the revised terms.
63 |
64 |
10. Contact Us
65 |
If you have any questions about these Terms and Conditions, You can contact us by email: admin@sign-net.app
66 |
67 |
← Back to Homepage
68 |
69 |
70 |
--------------------------------------------------------------------------------
/public/js/navigation.js:
--------------------------------------------------------------------------------
1 | // public/js/navigation.js
2 |
3 | import * as UI from './ui.js';
4 | import { renderScheduler } from './ui-scheduler.js';
5 |
6 | const pageConfig = {
7 | 'dashboard': { title: 'Dashboard Overview', buttonText: 'Refresh View', icon: 'fa-sync-alt' },
8 | 'screens': { title: 'Manage Screens', buttonText: 'Add Screen', icon: 'fa-plus' },
9 | 'media': {
10 | title: 'Media Library',
11 | actions: [
12 | { id: 'header-upload-btn', text: 'Upload Media', icon: 'fa-upload', classes: 'btn btn-secondary' },
13 | { id: 'header-playlist-btn', text: 'Create Playlist', icon: 'fa-plus-circle', classes: 'btn btn-primary' }
14 | ]
15 | },
16 | 'scheduler': { title: 'Event Scheduler', buttonText: 'Create Schedule', icon: 'fa-plus' }
17 | };
18 |
19 | const pageTemplates = {
20 | dashboard: () => `
21 |
31 |
32 |
33 |
Live Screen View
34 |
35 |
36 |
37 |
38 |
Upcoming Events
39 |
40 |
41 | `,
42 | screens: () => `Screen Name Screen ID Status Actions
`,
43 | media: () => `
44 |
52 |
56 |
57 |
61 | `,
62 | scheduler: () => `
63 |
64 |
65 |
66 |
67 | Today
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | Day View
78 | Screen View
79 |
80 |
81 |
82 |
85 | `,
86 | };
87 |
88 | export function navigateTo(pageId, appState) {
89 |
90 | // 1. Update sidebar active link
91 | document.querySelectorAll('.nav-link').forEach(nav => nav.classList.toggle('active', nav.getAttribute('data-page') === pageId));
92 |
93 | // 2. Switch the visible page content
94 | document.querySelectorAll('.page').forEach(page => {
95 | const currentPageId = page.id.replace('page-', '');
96 | const shouldBeActive = currentPageId === pageId;
97 |
98 | if (shouldBeActive && !page.classList.contains('active')) {
99 | // This page is becoming active, populate its template
100 | page.innerHTML = pageTemplates[pageId]();
101 | }
102 |
103 | page.classList.toggle('active', shouldBeActive);
104 | });
105 |
106 | // 3. Render the content for the now-active page
107 | if (pageId === 'dashboard') {
108 | UI.renderDashboard(appState.calculateCurrentScreenStates(), appState.allMedia, appState.allSchedules);
109 | } else if (pageId === 'screens') {
110 | UI.renderScreens(appState.allScreens);
111 | } else if (pageId === 'media') {
112 | // --- FIX: Pass the correct userSettings property ---
113 | UI.renderMedia(appState.allMedia, appState.userSettings.globalDefaultContent);
114 | UI.renderPlaylists(appState.allPlaylists, appState.userSettings.globalDefaultContent);
115 | } else if (pageId === 'scheduler') {
116 | const screenSelector = document.getElementById('screen-selector');
117 | if (screenSelector) {
118 | screenSelector.innerHTML = appState.allScreens.map(s => `${s.name} `).join('');
119 | if (appState.selectedScreenId) screenSelector.value = appState.selectedScreenId;
120 | }
121 | appState.renderCurrentSchedulerView();
122 | }
123 |
124 | // 4. Update the page title and header actions
125 | const config = pageConfig[pageId];
126 | document.getElementById('page-title').textContent = config.title;
127 | const headerActions = document.querySelector('.header-actions');
128 | headerActions.innerHTML = ''; // Always clear previous buttons
129 |
130 | if (config.actions && Array.isArray(config.actions)) {
131 | config.actions.forEach(action => {
132 | const button = document.createElement('button');
133 | button.id = action.id;
134 | button.className = action.classes;
135 | button.innerHTML = ` ${action.text} `;
136 | headerActions.appendChild(button);
137 | });
138 | } else if (config.buttonText) {
139 | const button = document.createElement('button');
140 | button.id = 'header-action-button';
141 | button.className = 'btn btn-primary';
142 | button.innerHTML = ` ${config.buttonText} `;
143 | headerActions.appendChild(button);
144 | }
145 |
146 | // 5. Ensure all images (especially lazy-loaded ones) are handled
147 | appState.handleImageLoading();
148 |
149 | // 6. Close the sidebar if it's open on mobile
150 | const sidebar = document.getElementById('sidebar');
151 | if (sidebar.classList.contains('active')) {
152 | sidebar.classList.remove('active');
153 | document.getElementById('sidebar-overlay').classList.remove('active');
154 | }
155 | }
--------------------------------------------------------------------------------
/public/privacy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Privacy Policy - Signet
7 |
8 |
9 |
10 |
11 |
22 |
23 |
24 |
25 |
Privacy Policy for Signet
26 |
Last updated: 7/10/2025
27 |
28 |
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure of Your information when You use the Signet service ("Service") and tells You about Your privacy rights and how the law protects You. We use Your Personal data to provide and improve the Service. By using the Service, You agree to the collection and use of information in accordance with this Privacy Policy.
29 |
30 |
Interpretation and Definitions
31 |
"We" , "Us" , or "Our" in this Agreement refer to the Signet Project Contributors.
32 |
"You" means the individual accessing or using the Service, or the company, or other legal entity on behalf of which such individual is accessing or using the Service, as applicable.
33 |
"Account" means a unique account created for You to access our Service or parts of our Service.
34 |
"Service" refers to the Signet web application.
35 |
"Personal Data" is any information that relates to an identified or identifiable individual.
36 |
37 |
Collecting and Using Your Personal Data
38 |
Types of Data Collected
39 |
40 |
41 | Personal Information: When you register for an Account using Google Authentication, we receive Personal Data from Google, which may include your full name, email address, and profile picture URL. We do not collect passwords.
42 |
43 |
44 | User-Generated Content: We collect and store the content you upload or create within the Service. This includes media files (such as images and graphics), screen configurations, and schedule data ("Content Data"). This Content Data is directly linked to your Account.
45 |
46 |
47 | Usage Data: We may automatically collect standard log information, including your IP address, browser type, browser version, the pages of our Service that You visit, the time and date of Your visit, the time spent on those pages, and other diagnostic data.
48 |
49 |
50 |
51 |
Use of Your Personal Data
52 |
We may use Personal Data for the following purposes:
53 |
54 | To provide and maintain our Service, including to monitor the usage of our Service.
55 | To manage Your Account: to manage Your registration as a user of the Service. The Personal Data You provide can give You access to different functionalities of the Service that are available to You as a registered user.
56 | To contact You: To contact You by email regarding updates or informative communications related to the functionalities, products or contracted services, including security updates, when necessary or reasonable for their implementation.
57 | To provide You with news, special offers and general information about other goods, services and events which we offer unless You have opted not to receive such information.
58 | For other purposes: We may use Your information for other purposes, such as data analysis, identifying usage trends, and to evaluate and improve our Service, products, services, marketing and your experience.
59 |
60 |
61 |
Storage and Security of Your Data
62 |
Your Personal Data and Content Data are stored on Firebase, a platform provided by Google. We take reasonable measures to protect your data, but remember that no method of transmission over the Internet, or method of electronic storage is 100% secure. While We strive to use commercially acceptable means to protect Your Personal Data, We cannot guarantee its absolute security.
63 |
64 |
Retention of Your Data
65 |
We will retain Your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply with our legal obligations, resolve disputes, and enforce our legal agreements and policies. Content Data is retained as long as your Account is active.
66 |
67 |
Disclosure of Your Personal Data
68 |
We do not sell your Personal Data. We may disclose Your Personal Data in the good faith belief that such action is necessary to:
69 |
70 | Comply with a legal obligation
71 | Protect and defend the rights or property of the Signet Project
72 | Prevent or investigate possible wrongdoing in connection with the Service
73 | Protect the personal safety of Users of the Service or the public
74 | Protect against legal liability
75 |
76 |
77 |
Your Data Protection Rights
78 |
You have the right to access, update, or delete the information we have on you. You can delete media items, schedules, and screens directly within the Service. To delete your entire account and associated data, please contact us.
79 |
80 |
Children's Privacy
81 |
Our Service does not address anyone under the age of 13. We do not knowingly collect personally identifiable information from anyone under the age of 13. If You are a parent or guardian and You are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware that We have collected Personal Data from anyone under the age of 13 without verification of parental consent, We take steps to remove that information from Our servers.
82 |
83 |
Changes to this Privacy Policy
84 |
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the new Privacy Policy on this page. You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page.
85 |
86 |
Contact Us
87 |
If you have any questions about this Privacy Policy, You can contact us by email: admin@sign-net.app
88 |
89 |
← Back to Homepage
90 |
91 |
92 |
--------------------------------------------------------------------------------
/public/guides/getting-started.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Getting Started with Signet | A Step-by-Step Guide
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
Set Up Your First Screen in Minutes
32 |
Welcome to Signet! This guide will walk you through the five simple steps to get your content live on your first screen.
33 |
34 |
35 |
36 |
37 |
38 |
39 |
01
40 |
41 |
Create a Screen
42 |
Everything in Signet starts with a virtual screen. In the dashboard, navigate to the "Screens" page and click the "Add Screen" button. Give your screen a memorable name, like "Lobby TV" or "Cafeteria Menu".
43 |
52 |
53 |
54 |
55 |
56 |
57 |
02
58 |
59 |
Upload Your Content
60 |
Next, you need something to display. Go to the "Media Library" and drag-and-drop your images (or a whole playlist!) into the upload area. This will be the content you schedule to your screens.
61 |
68 |
69 |
70 |
71 |
72 |
73 |
03
74 |
75 |
Pair Your Device
76 |
Now, let's link your physical screen (like a TV with a Raspberry Pi) to Signet. In the dashboard, click the link icon on your new screen. Then, on your physical screen device, open a web browser to sign-net.app/view and enter the 6-digit code.
77 |
78 |
82 |
83 |
Pair This Screen
84 |
123 456
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
04
93 |
94 |
Schedule the Content
95 |
This is the magic step. Go to the "Scheduler" and create a new event. Select the screen you want to target ("Lobby TV"), the content you want to show, and the start and end times.
96 |
105 |
106 |
107 |
108 |
109 |
110 |
05
111 |
112 |
You're Live!
113 |
That's it! Your physical screen will now display the content you scheduled. You can confirm everything is working from the Live Dashboard, which shows a preview of what's currently playing on all your online screens.
114 |
115 |
116 |
117 |
Lobby TV Online
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
What's Next?
126 |
You've mastered the basics! Now you can explore more powerful features.
127 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
--------------------------------------------------------------------------------
/public/js/ui-scheduler.js:
--------------------------------------------------------------------------------
1 | // public/js/ui-scheduler.js
2 |
3 | export function renderScheduler(viewMode, items, screens, selectedDate, selectedScreenId) {
4 | const container = document.getElementById('scheduler-view-container');
5 | if (!container) return;
6 | container.innerHTML = '';
7 |
8 | const now = new Date();
9 | const isToday = now.getFullYear() === selectedDate.getFullYear() &&
10 | now.getMonth() === selectedDate.getMonth() &&
11 | now.getDate() === selectedDate.getDate();
12 | const currentHour = isToday ? now.getHours() : -1;
13 |
14 | const grid = document.createElement('div');
15 | grid.className = 'scheduler-container';
16 |
17 | const headerRow = document.createElement('div');
18 | headerRow.className = 'scheduler-header';
19 | grid.appendChild(headerRow);
20 |
21 | const timeline = document.createElement('div');
22 | timeline.className = 'timeline';
23 | for (let i = 0; i <= 23; i++) {
24 | const timeSlot = document.createElement('div');
25 | timeSlot.className = 'time-slot';
26 | if (i === currentHour) {
27 | timeSlot.classList.add('current-hour');
28 | }
29 |
30 | const ampm = i >= 12 ? 'PM' : 'AM';
31 | let hour = i % 12;
32 | if (hour === 0) hour = 12;
33 | timeSlot.textContent = `${hour} ${ampm}`;
34 |
35 | timeline.appendChild(timeSlot);
36 | }
37 | grid.appendChild(timeline);
38 |
39 | if (viewMode === 'day') {
40 | const columnDefinition = `repeat(${screens.length}, minmax(170px, 1fr))`;
41 | grid.style.gridTemplateColumns = `80px ${columnDefinition}`;
42 | headerRow.style.gridTemplateColumns = `80px ${columnDefinition}`;
43 |
44 | headerRow.appendChild(document.createElement('div'));
45 | screens.forEach(screen => {
46 | const header = document.createElement('div');
47 | header.textContent = screen.name;
48 | headerRow.appendChild(header);
49 | });
50 | } else if (viewMode === 'screen') {
51 | const columnDefinition = `repeat(7, minmax(170px, 1fr))`;
52 | grid.style.gridTemplateColumns = `80px ${columnDefinition}`;
53 | headerRow.style.gridTemplateColumns = `80px ${columnDefinition}`;
54 |
55 | headerRow.appendChild(document.createElement('div'));
56 | const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
57 | days.forEach(day => {
58 | const header = document.createElement('div');
59 | header.textContent = day;
60 | headerRow.appendChild(header);
61 | });
62 | }
63 |
64 | container.appendChild(grid);
65 |
66 | if (isToday && headerRow.offsetHeight > 0) {
67 | const headerHeight = headerRow.offsetHeight;
68 | const hourHeight = 60;
69 | const highlight = document.createElement('div');
70 | highlight.className = 'current-hour-highlight';
71 | highlight.style.top = `${headerHeight + (currentHour * hourHeight)}px`;
72 | grid.appendChild(highlight);
73 | }
74 |
75 | if (viewMode === 'day') {
76 | screens.forEach(screen => {
77 | const column = document.createElement('div');
78 | column.className = 'screen-column';
79 | column.dataset.screenId = screen.id;
80 | grid.appendChild(column);
81 | });
82 |
83 | const dayStart = new Date(selectedDate);
84 | dayStart.setHours(0, 0, 0, 0);
85 | const dayEnd = new Date(selectedDate);
86 | dayEnd.setHours(23, 59, 59, 999);
87 |
88 | const filteredItems = items.filter(item => {
89 | const startTime = item.startTime.toDate();
90 | const endTime = item.endTime.toDate();
91 | // Item is relevant if its time range overlaps with the current day's time range
92 | return startTime <= dayEnd && endTime >= dayStart;
93 | });
94 |
95 | filteredItems.forEach(item => {
96 | const startTime = item.startTime.toDate();
97 | const endTime = item.endTime.toDate();
98 |
99 | // Clamp the start and end times to the current day's boundaries
100 | const displayStartTime = startTime < dayStart ? dayStart : startTime;
101 | const displayEndTime = endTime > dayEnd ? dayEnd : endTime;
102 |
103 | const startHour = displayStartTime.getHours() + displayStartTime.getMinutes() / 60;
104 | // Add a small fraction for the end hour to ensure it fills to the end of the minute
105 | const endHour = displayEndTime.getHours() + displayEndTime.getMinutes() / 60 + (displayEndTime.getSeconds() / 3600);
106 |
107 | // Render only if there's a visible duration on this day
108 | if (endHour > startHour) {
109 | const top = startHour * 60;
110 | const height = (endHour - startHour) * 60;
111 |
112 | item.screenIds.forEach(screenId => {
113 | const column = grid.querySelector(`.screen-column[data-screen-id='${screenId}']`);
114 | if (column) {
115 | const scheduledDiv = document.createElement('div');
116 | scheduledDiv.className = 'scheduled-item';
117 | scheduledDiv.style.top = `${top}px`;
118 | scheduledDiv.style.height = `${height}px`;
119 | scheduledDiv.dataset.id = item.id;
120 | const contentName = item.content?.data?.name || 'Untitled Content';
121 | scheduledDiv.innerHTML = `${contentName}
`;
122 | column.appendChild(scheduledDiv);
123 | }
124 | });
125 | }
126 | });
127 |
128 | } else if (viewMode === 'screen') {
129 | // This view is less affected by multi-day issues but we'll apply similar logic for consistency
130 | const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // Match JS getDay()
131 | days.forEach((_, index) => {
132 | const column = document.createElement('div');
133 | column.className = 'day-column';
134 | column.dataset.day = index; // 0 for Sunday, 1 for Monday, etc.
135 | grid.appendChild(column);
136 | });
137 |
138 | const startOfWeek = new Date(selectedDate);
139 | startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay() + (startOfWeek.getDay() === 0 ? -6 : 1));
140 | startOfWeek.setHours(0, 0, 0, 0);
141 |
142 | const endOfWeek = new Date(startOfWeek);
143 | endOfWeek.setDate(startOfWeek.getDate() + 7);
144 |
145 | if (!selectedScreenId) {
146 | return;
147 | }
148 |
149 | const filteredItems = items.filter(item => {
150 | if (!item.screenIds.includes(selectedScreenId)) return false;
151 | const itemTime = item.startTime.toDate();
152 | return itemTime >= startOfWeek && itemTime < endOfWeek;
153 | });
154 |
155 | filteredItems.forEach(item => {
156 | const startTime = item.startTime.toDate();
157 | const endTime = item.endTime.toDate();
158 | const startHour = startTime.getHours() + startTime.getMinutes() / 60;
159 | const endHour = endTime.getHours() + endTime.getMinutes() / 60;
160 | const top = startHour * 60;
161 | const height = (endHour - startHour) * 60;
162 |
163 | const day = startTime.getDay(); // 0 for Sunday, 1 for Monday, etc.
164 | const column = grid.querySelector(`.day-column[data-day='${day}']`);
165 |
166 | if (column) {
167 | const scheduledDiv = document.createElement('div');
168 | scheduledDiv.className = 'scheduled-item';
169 | scheduledDiv.style.top = `${top}px`;
170 | scheduledDiv.style.height = `${height}px`;
171 | scheduledDiv.dataset.id = item.id;
172 | const contentName = item.content?.data?.name || 'Untitled Content';
173 | scheduledDiv.innerHTML = `${contentName}
`;
174 | column.appendChild(scheduledDiv);
175 | }
176 | });
177 | }
178 | }
--------------------------------------------------------------------------------
/public/css/guides.css:
--------------------------------------------------------------------------------
1 | /* /css/guides.css */
2 |
3 | /* ====== GUIDE HERO STYLING ====== */
4 | .page-hero {
5 | text-align: center;
6 | padding: 140px 20px 80px 20px;
7 | background-color: var(--bg-dark);
8 | }
9 |
10 | .page-hero h1 {
11 | font-size: 3.2rem;
12 | margin-bottom: 1rem;
13 | color: var(--text-light);
14 | font-weight: 800;
15 | }
16 |
17 | .page-hero p {
18 | font-size: 1.25rem;
19 | max-width: 700px;
20 | margin: 0 auto;
21 | color: var(--text-muted);
22 | }
23 |
24 | /* ====== GUIDE LAYOUT ====== */
25 | .guide-container {
26 | max-width: 900px;
27 | margin: 80px auto;
28 | padding: 0 20px;
29 | }
30 |
31 | .guide-step {
32 | display: grid;
33 | grid-template-columns: 100px 1fr;
34 | gap: 40px;
35 | padding-bottom: 60px;
36 | margin-bottom: 60px;
37 | border-bottom: 1px solid var(--border-color);
38 | }
39 | .guide-step:last-of-type {
40 | border-bottom: none;
41 | }
42 |
43 | .step-number {
44 | font-size: 5rem;
45 | font-weight: 800;
46 | color: rgba(229, 231, 235, 0.1); /* --text-main at 10% opacity */
47 | line-height: 1;
48 | }
49 |
50 | .step-content h2 {
51 | font-size: 2rem;
52 | font-weight: 700;
53 | margin-bottom: 1rem;
54 | }
55 |
56 | .step-content p {
57 | font-size: 1.1rem;
58 | line-height: 1.7;
59 | color: var(--text-muted);
60 | }
61 |
62 | .step-content strong {
63 | color: var(--text-main);
64 | font-weight: 600;
65 | }
66 |
67 | .guide-visual {
68 | margin-top: 30px;
69 | background-color: var(--bg-light);
70 | border-radius: 12px;
71 | padding: 32px;
72 | border: 1px solid var(--border-color);
73 | display: flex;
74 | flex-direction: column;
75 | align-items: center;
76 | justify-content: center;
77 | }
78 |
79 | /* ====== RECREATED UI ELEMENT STYLES ====== */
80 |
81 | /* --- Modal Demo --- */
82 | .modal-content-demo { background-color: var(--bg-dark); padding: 32px; border-radius: 16px; width: 100%; max-width: 450px; border: 1px solid var(--border-color); }
83 | .modal-content-demo.large { max-width: 550px; }
84 | .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
85 | .modal-header h3 { font-size: 20px; color: var(--text-light); }
86 | .modal-body { padding-top: 10px; }
87 | .modal-footer { margin-top: 32px; display: flex; justify-content: flex-end; gap: 12px; }
88 | .form-group { margin-bottom: 20px; }
89 | .form-group label { display: block; font-weight: 500; margin-bottom: 8px; font-size: 14px; }
90 | .form-group input { width: 100%; background-color: var(--bg-light); border: 1px solid var(--border-color); color: var(--text-main); padding: 10px 12px; border-radius: 8px; font-size: 14px; }
91 | .multi-select-container { display: flex; flex-wrap: wrap; gap: 8px; }
92 | .multi-select-item { background-color: var(--bg-light); padding: 6px 12px; border-radius: 20px; font-size: 13px; border: 1px solid var(--border-color); }
93 | .multi-select-item.selected { background-color: var(--primary-color); color: #fff; }
94 |
95 | /* --- Media Upload Demo --- */
96 | .media-upload-area-demo { border: 2px dashed var(--border-color); border-radius: 12px; padding: 48px; text-align: center; background-color: var(--bg-dark); width: 100%; }
97 | .media-upload-area-demo i { font-size: 48px; color: var(--primary-color); margin-bottom: 16px; }
98 | .media-upload-area-demo h3 { font-size: 20px; color: var(--text-light); }
99 | .media-upload-area-demo p { font-size: 1rem; color: var(--text-muted); }
100 |
101 | /* --- Pairing Demo --- */
102 | .guide-visual.dual-visual { flex-direction: row; gap: 20px; flex-wrap: wrap; }
103 | .data-table-card-demo { background-color: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; display: flex; justify-content: space-between; align-items: center; width: 100%; max-width: 280px; font-weight: 600; }
104 | .action-buttons i { margin-left: 16px; color: var(--text-muted); font-size: 16px; }
105 | .action-buttons i.active { color: var(--primary-color); }
106 | .pairing-box-demo { background-color: var(--bg-dark); padding: 30px; border-radius: 12px; text-align: center; border: 1px solid var(--border-color); width: 100%; max-width: 280px;}
107 | .pairing-box-demo h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #fff; }
108 | #pairing-pin-display { font-size: 2.5rem; font-weight: bold; letter-spacing: 10px; color: #fff; background-color: var(--bg-light); padding: 10px 15px; border-radius: 8px; font-family: "Courier New", Courier, monospace; }
109 |
110 | /* --- Image Picker Demo --- */
111 | .image-picker-demo { background: var(--bg-light); border: 2px solid var(--primary-color); border-radius: 8px; padding: 8px; display: flex; align-items: center; gap: 12px; }
112 | .image-picker-demo img { width: 50px; height: 50px; border-radius: 4px; object-fit: cover; }
113 | .image-picker-demo .picker-label { font-weight: 500; }
114 |
115 | /* --- Screen Preview Demo --- */
116 | .screen-preview-card-demo { width: 100%; max-width: 400px; border-radius: 12px; background-color: var(--bg-dark); border: 1px solid var(--border-color); box-shadow: 0 10px 20px rgba(0,0,0,0.2); overflow: hidden; }
117 | .screen-preview-card-demo .image-container { height: 200px; background-color: var(--bg-dark); }
118 | .screen-preview-card-demo .image-container img { width: 100%; height: 100%; object-fit: cover; }
119 | .screen-preview-card-demo .screen-info { padding: 16px; display: flex; justify-content: space-between; align-items: center; }
120 | .screen-preview-card-demo .screen-info .name { font-weight: 600; }
121 |
122 | /* --- Next Steps CTA --- */
123 | .next-steps-section {
124 | text-align: center;
125 | background-color: var(--bg-light);
126 | padding: 40px;
127 | border-radius: 16px;
128 | border: 1px solid var(--border-color);
129 | }
130 | .next-steps-section h3 { font-size: 1.8rem; margin-bottom: 1rem; }
131 | .next-steps-section p { color: var(--text-muted); margin-bottom: 30px; }
132 | .next-steps-buttons { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; }
133 |
134 |
135 | /* ====== RESPONSIVE STYLES ====== */
136 | @media (max-width: 768px) {
137 | .page-hero h1 { font-size: 2.5rem; }
138 | .page-hero p { font-size: 1.1rem; }
139 |
140 | .guide-step {
141 | grid-template-columns: 1fr;
142 | gap: 20px;
143 | }
144 | .step-number {
145 | justify-self: center;
146 | font-size: 4rem;
147 | }
148 | .step-content h2 { font-size: 1.8rem; }
149 | }
150 |
151 | @media (max-width: 480px) {
152 | .page-hero { padding: 120px 20px 60px; }
153 | .guide-container { margin: 60px auto; }
154 | }
155 |
156 | /* Add these new rules to the end of /css/guides.css */
157 |
158 | /* --- Playlist Editor Demo --- */
159 | .playlist-slides-container-demo { display: flex; gap: 15px; overflow-x: auto; padding: 15px; border: 1px solid var(--border-color); background-color: var(--bg-light); border-radius: 8px; min-height: 150px; align-items: center; }
160 | .add-slide-card-demo { flex-shrink: 0; width: 120px; height: 120px; border: 2px dashed var(--border-color); display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-muted); border-radius: 6px; }
161 | .add-slide-card-demo i { font-size: 1.5rem; margin-bottom: 8px; }
162 |
163 | .playlist-editor-demo { background: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; display: flex; align-items: center; gap: 16px; position: relative; }
164 | .playlist-slide-demo { width: 110px; height: 110px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border-color); flex-shrink: 0; position: relative; }
165 | .playlist-slide-demo img { width: 100%; height: 100%; object-fit: cover; }
166 | .playlist-slide-demo.is-dragging-demo { transform: translateY(-10px); box-shadow: 0 10px 20px rgba(0,0,0,0.4); }
167 | .duration-editor-demo { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: #fff; padding: 4px 8px; border-radius: 20px; font-size: 13px; font-weight: 600; }
168 | .drop-placeholder-demo { width: 110px; height: 110px; border-radius: 8px; background: var(--bg-light); border: 2px dashed var(--primary-color); flex-shrink: 0; }
169 | .drag-cursor-demo { position: absolute; bottom: -5px; right: 90px; font-size: 20px; color: var(--text-light); transform: rotate(20deg); }
170 |
171 |
172 | /* --- Scheduler Modal Tab Demo --- */
173 | .modal-tabs-demo { display: flex; gap: 5px; border-bottom: 1px solid var(--border-color); margin-bottom: 12px; }
174 | .modal-tab { padding: 8px 16px; background: none; border: none; color: var(--text-muted); font-size: 1rem; font-weight: 500; border-bottom: 3px solid transparent; }
175 | .modal-tab.active { color: var(--primary-color); border-bottom-color: var(--primary-color); }
176 |
177 | .playlist-picker-demo { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 6px; background-color: var(--bg-light); border: 2px solid var(--primary-color); }
178 | .playlist-picker-demo i { font-size: 1.5rem; color: var(--primary-color); }
179 | .playlist-picker-demo span { font-weight: 500; }
180 | .playlist-picker-demo .count { margin-left: auto; color: var(--text-muted); font-size: 0.9rem; }
--------------------------------------------------------------------------------
/public/css/style.css:
--------------------------------------------------------------------------------
1 | /* public/css/style.css */
2 |
3 | /* --- IMPORT COMPONENT STYLES --- */
4 | @import url('./components/_buttons.css');
5 | @import url('./components/_dashboard.css');
6 | @import url('./components/_loaders.css');
7 | @import url('./components/_media-library.css');
8 | @import url('./components/_modals.css');
9 | @import url('./components/_scheduler.css');
10 | @import url('./components/_tables.css');
11 | @import url('./components/_toast.css');
12 |
13 |
14 | :root {
15 | --bg-dark: #121212;
16 | --bg-light: #1E1E1E;
17 | --bg-lighter: #2a2a2a;
18 | --border-color: #333333;
19 | --primary-color: #3B82F6;
20 | --primary-hover: #2563EB;
21 | --danger-color: #EF4444;
22 | --danger-hover: #DC2626;
23 | --text-main: #E5E7EB;
24 | --text-muted: #9CA3AF;
25 | --green-status: #10B981;
26 | --font-family: 'Inter', sans-serif;
27 | }
28 |
29 | body.light-mode {
30 | --bg-dark: #F3F4F6;
31 | --bg-light: #FFFFFF;
32 | --bg-lighter: #F9FAFB;
33 | --border-color: #E5E7EB;
34 | --text-main: #1F2937;
35 | --text-muted: #6B7280;
36 | }
37 |
38 | * {
39 | margin: 0;
40 | padding: 0;
41 | box-sizing: border-box;
42 | }
43 |
44 | body {
45 | font-family: var(--font-family);
46 | background-color: var(--bg-dark);
47 | color: var(--text-main);
48 | overflow-x: hidden;
49 | transition: background-color 0.3s ease, color 0.3s ease;
50 | }
51 |
52 | #sidebar-overlay {
53 | position: fixed;
54 | top: 0;
55 | left: 0;
56 | width: 100%;
57 | height: 100%;
58 | background-color: rgba(0,0,0,0.5);
59 | z-index: 999; /* Below sidebar */
60 | opacity: 0;
61 | visibility: hidden;
62 | transition: opacity 0.3s ease, visibility 0.3s ease;
63 | }
64 |
65 | #sidebar-overlay.active {
66 | opacity: 1;
67 | visibility: visible;
68 | }
69 |
70 | /* --- LOGIN PAGE STYLES --- */
71 | .login-page-body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
72 | .login-container { text-align: center; padding: 16px; }
73 | .login-box { background-color: var(--bg-light); padding: 48px; border-radius: 16px; border: 1px solid var(--border-color); }
74 | .login-box .sidebar-header { justify-content: center; }
75 | .login-box p { margin-top: -30px; margin-bottom: 30px; color: var(--text-muted); }
76 | .btn-google { display: inline-flex; align-items: center; gap: 12px; padding: 12px 24px; background-color: #FFF; color: #333; border: 1px solid #CCC; border-radius: 8px; font-weight: 600; font-size: 16px; cursor: pointer; transition: all 0.2s ease; }
77 | .btn-google:hover { background-color: #f7f7f7; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
78 | .login-error-message { color: var(--danger-color); margin-top: 16px; height: 20px; font-weight: 500; }
79 |
80 | /* --- MAIN LAYOUT --- */
81 | .dashboard-container { display: flex; min-height: 100vh; }
82 |
83 | .sidebar {
84 | width: 260px;
85 | background-color: var(--bg-light);
86 | border-right: 1px solid var(--border-color);
87 | padding: 24px;
88 | display: flex;
89 | flex-direction: column;
90 | justify-content: space-between;
91 | position: fixed;
92 | height: 100%;
93 | z-index: 1000;
94 | transition: background-color 0.3s ease, border-color 0.3s ease, transform 0.3s ease;
95 | }
96 |
97 | .main-content {
98 | flex: 1;
99 | margin-left: 260px;
100 | transition: margin-left 0.3s ease;
101 | display: grid;
102 | grid-template-rows: auto 1fr;
103 | height: 100vh;
104 | padding: 32px;
105 | }
106 |
107 | /* --- SIDEBAR --- */
108 | .sidebar-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 40px; }
109 | .sidebar-header .logo-wrapper { display: flex; align-items: center; gap: 12px; }
110 | .sidebar-header .logo-icon { font-size: 28px; color: var(--primary-color); }
111 | .sidebar-header h1 { font-size: 24px; font-weight: 700; }
112 | .sidebar-nav ul { list-style-type: none; }
113 | .sidebar-nav a { display: flex; align-items: center; gap: 16px; padding: 12px 16px; text-decoration: none; color: var(--text-muted); font-weight: 500; border-radius: 8px; transition: all 0.2s ease; margin-bottom: 4px; }
114 | .sidebar-nav a:hover { background-color: var(--bg-lighter); color: var(--text-main); }
115 | .sidebar-nav a.active { background-color: var(--primary-color); color: #FFF; }
116 | .sidebar-nav a i { width: 20px; text-align: center; }
117 | .sidebar-close-btn { font-size: 24px; color: var(--text-muted); cursor: pointer; display: none; }
118 |
119 | /* --- SIDEBAR FOOTER --- */
120 | .sidebar-footer { border-top: 1px solid var(--border-color); padding-top: 16px; }
121 | .user-info { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
122 | .user-info img { width: 40px; height: 40px; border-radius: 50%; }
123 | .user-info .name { font-weight: 600; font-size: 14px; }
124 | .sidebar-footer-actions { display: flex; justify-content: space-between; align-items: center; }
125 | .logout-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text-muted); font-weight: 500; border-radius: 8px; transition: all 0.2s ease; padding: 8px; }
126 | .logout-link:hover { color: var(--danger-color); }
127 | .theme-toggle { background: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 50px; padding: 4px; cursor: pointer; display: flex; }
128 | .theme-toggle .icon { font-size: 16px; padding: 4px; color: var(--text-muted); }
129 | .theme-toggle .icon.sun { display: none; }
130 | body.light-mode .theme-toggle .icon.sun { display: block; }
131 | body.light-mode .theme-toggle .icon.moon { display: none; }
132 |
133 | /* --- MAIN CONTENT HEADER --- */
134 | .content-header {
135 | display: flex;
136 | justify-content: space-between;
137 | align-items: center;
138 | margin-bottom: 32px;
139 | gap: 16px;
140 | }
141 |
142 | .header-actions {
143 | display: flex;
144 | align-items: center;
145 | gap: 12px;
146 | }
147 |
148 | .content-header .header-left {
149 | display: flex;
150 | align-items: center;
151 | gap: 16px;
152 | min-width: 0;
153 | }
154 | .content-header h2 {
155 | font-size: 28px;
156 | font-weight: 600;
157 | white-space: nowrap;
158 | overflow: hidden;
159 | text-overflow: ellipsis;
160 | }
161 |
162 | .menu-toggle {
163 | font-size: 24px;
164 | color: var(--text-muted);
165 | cursor: pointer;
166 | display: none;
167 | }
168 |
169 | .page {
170 | display: none;
171 | animation: fadeIn 0.5s ease;
172 | overflow: auto;
173 | }
174 |
175 | .date-mobile {
176 | display: none; /* Hide mobile date on desktop by default */
177 | }
178 |
179 | .page.active {
180 | display: block;
181 | }
182 | @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
183 |
184 | /* ========================================================= */
185 | /* --- RESPONSIVE STYLES --- */
186 | /* ========================================================= */
187 |
188 | @media (max-width: 1024px) {
189 | .sidebar { transform: translateX(-100%); }
190 | .sidebar.active { transform: translateX(0); }
191 | .sidebar-close-btn { display: block; }
192 | .main-content { margin-left: 0; }
193 | .menu-toggle { display: block; }
194 | }
195 |
196 | @media (max-width: 768px) {
197 | .main-content { padding: 24px; }
198 | .content-header h2 { font-size: 22px; }
199 | .dashboard-grid { grid-template-columns: 1fr; }
200 | .scheduler-controls { flex-direction: column; align-items: stretch; gap: 16px; }
201 | .scheduler-nav { display: flex; justify-content: space-between; align-items: center; width: 100%; }
202 | .scheduler-nav h3 { font-size: 16px; }
203 | .scheduler-view-options { margin-left: 0; width: 100%; justify-content: space-between; }
204 | .form-group-row { flex-direction: column; gap: 0; }
205 | .header-actions .btn .btn-text { display: none; }
206 | .date-desktop {
207 | display: none; /* Hide desktop date on mobile */
208 | }
209 | .date-mobile {
210 | display: inline; /* Show mobile date on mobile */
211 | }
212 | }
213 |
214 | @media (max-width: 480px) {
215 | .main-content { padding: 16px; }
216 | .content-header { margin-bottom: 24px; }
217 | .header-actions .btn { padding: 8px 12px; font-size: 13px; }
218 | .login-box { padding: 32px 24px; }
219 | .modal-content { padding: 24px; }
220 | }
221 |
222 | /* ========================================================= */
223 | /* --- FIX: CUSTOM SCROLLBAR STYLES --- */
224 | /* ========================================================= */
225 |
226 | /* For Firefox */
227 | * {
228 | scrollbar-width: thin;
229 | scrollbar-color: var(--text-muted) var(--bg-dark);
230 | }
231 |
232 | /* For Chrome, Safari, and Edge */
233 | ::-webkit-scrollbar {
234 | width: 8px;
235 | height: 8px;
236 | }
237 |
238 | ::-webkit-scrollbar-track {
239 | background: var(--bg-dark);
240 | }
241 |
242 | ::-webkit-scrollbar-thumb {
243 | background-color: var(--text-muted);
244 | border-radius: 10px;
245 | border: 2px solid var(--bg-dark);
246 | }
247 |
248 | ::-webkit-scrollbar-thumb:hover {
249 | background-color: var(--primary-color);
250 | }
--------------------------------------------------------------------------------
/public/css/components/_media-library.css:
--------------------------------------------------------------------------------
1 | /* public/css/components/_media-library.css */
2 |
3 | .media-section {
4 | margin-top: 32px;
5 | }
6 |
7 | .media-section-header {
8 | font-size: 20px;
9 | font-weight: 600;
10 | padding-bottom: 12px;
11 | border-bottom: 1px solid var(--border-color);
12 | margin-bottom: 20px;
13 | }
14 |
15 | .media-upload-area {
16 | border: 2px dashed var(--border-color);
17 | border-radius: 12px;
18 | padding: 48px;
19 | text-align: center;
20 | background-color: var(--bg-light);
21 | cursor: pointer;
22 | transition: all 0.2s ease;
23 | position: relative;
24 | }
25 |
26 | .media-upload-area:hover {
27 | border-color: var(--primary-color);
28 | background-color: var(--bg-lighter);
29 | }
30 |
31 | .media-upload-area i {
32 | font-size: 48px;
33 | color: var(--primary-color);
34 | margin-bottom: 16px;
35 | }
36 |
37 | .media-upload-area h3 {
38 | font-size: 20px;
39 | }
40 |
41 | .media-upload-area p {
42 | color: var(--text-muted);
43 | }
44 |
45 | .progress-bar-container {
46 | width: 100%;
47 | height: 10px;
48 | background-color: var(--bg-dark);
49 | border-radius: 5px;
50 | margin-top: 20px;
51 | overflow: hidden;
52 | display: none;
53 | }
54 |
55 | .progress-bar {
56 | width: 0%;
57 | height: 100%;
58 | background-color: var(--primary-color);
59 | border-radius: 5px;
60 | transition: width 0.3s ease;
61 | }
62 |
63 | .media-upload-area.is-uploading .progress-bar-container {
64 | display: block;
65 | }
66 |
67 | .media-upload-area.is-uploading h3 {
68 | font-size: 18px;
69 | }
70 |
71 | .media-grid {
72 | display: grid;
73 | grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
74 | gap: 16px;
75 | }
76 |
77 | .media-item {
78 | position: relative;
79 | border-radius: 8px;
80 | overflow: hidden;
81 | border: 1px solid var(--border-color);
82 | aspect-ratio: 1/1;
83 | }
84 |
85 | .media-item:hover .lazy-image {
86 | transform: scale(1.05);
87 | }
88 |
89 | .media-overlay {
90 | position: absolute;
91 | top: 0;
92 | left: 0;
93 | right: 0;
94 | bottom: 0;
95 | background: linear-gradient(to top, rgba(0, 0, 0, 0.85), transparent 60%);
96 | display: flex;
97 | flex-direction: column;
98 | justify-content: flex-end;
99 | padding: 12px;
100 | opacity: 0;
101 | transition: opacity 0.3s ease;
102 | pointer-events: none;
103 | z-index: 2;
104 | }
105 |
106 | .media-item:hover .media-overlay {
107 | opacity: 1;
108 | pointer-events: auto;
109 | }
110 |
111 | .media-filename {
112 | font-size: 13px;
113 | word-break: break-all;
114 | line-height: 1.4;
115 | color: #fff;
116 | font-weight: 500;
117 | padding: 2px 4px;
118 | border-radius: 3px;
119 | outline: none;
120 | }
121 |
122 | .media-filename[contenteditable="true"]:focus {
123 | background-color: rgba(0, 0, 0, 0.5);
124 | box-shadow: 0 0 0 2px var(--primary-color);
125 | }
126 |
127 | .media-actions-bar {
128 | position: absolute;
129 | top: 8px;
130 | right: 8px;
131 | background: rgba(0, 0, 0, 0.6);
132 | border-radius: 6px;
133 | padding: 4px;
134 | display: flex;
135 | gap: 10px;
136 | opacity: 0;
137 | transition: opacity 0.3s ease;
138 | z-index: 3;
139 | }
140 |
141 | .media-item:hover .media-actions-bar {
142 | opacity: 1;
143 | }
144 |
145 | .media-actions-bar i {
146 | font-size: 16px;
147 | color: #fff;
148 | cursor: pointer;
149 | transition: color 0.2s ease;
150 | }
151 |
152 | .media-actions-bar i:hover {
153 | color: var(--primary-color);
154 | }
155 |
156 | .media-save-btn {
157 | color: var(--green-status) !important;
158 | }
159 |
160 | .media-item .global-fallback-indicator,
161 | .playlist-card .global-fallback-indicator {
162 | position: absolute;
163 | top: 8px;
164 | left: 8px;
165 | font-size: 18px;
166 | color: var(--text-muted);
167 | cursor: pointer;
168 | transition: all 0.2s ease;
169 | background: rgba(0, 0, 0, 0.6);
170 | border-radius: 50%;
171 | width: 32px;
172 | height: 32px;
173 | display: flex;
174 | align-items: center;
175 | justify-content: center;
176 | z-index: 3;
177 | }
178 |
179 | .media-item .global-fallback-indicator:hover,
180 | .playlist-card .global-fallback-indicator:hover {
181 | color: #FFD700;
182 | transform: scale(1.1);
183 | }
184 |
185 | .media-item .global-fallback-indicator.active,
186 | .playlist-card .global-fallback-indicator.active {
187 | color: #FFD700;
188 | text-shadow: 0 0 8px #FFD700;
189 | }
190 |
191 | .media-upload-area.drag-over {
192 | border-color: var(--primary-color);
193 | background-color: var(--bg-lighter);
194 | }
195 |
196 | .media-upload-area.is-uploading {
197 | pointer-events: none;
198 | border-style: solid;
199 | border-color: var(--primary-color);
200 | }
201 |
202 | .playlist-grid-container {
203 | display: grid;
204 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
205 | gap: 20px;
206 | margin-top: 20px;
207 | }
208 |
209 | .playlist-card,
210 | .playlist-card-cta {
211 | background-color: var(--bg-light);
212 | border: 1px solid var(--border-color);
213 | border-radius: 8px;
214 | padding: 20px;
215 | display: flex;
216 | flex-direction: column;
217 | align-items: center;
218 | justify-content: center;
219 | text-align: center;
220 | cursor: pointer;
221 | transition: all 0.2s ease;
222 | min-height: 160px;
223 | position: relative;
224 | }
225 |
226 | .playlist-card:hover,
227 | .playlist-card-cta:hover {
228 | transform: translateY(-4px);
229 | border-color: var(--primary-color);
230 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
231 | }
232 |
233 | .playlist-card-content {
234 | display: flex;
235 | flex-direction: column;
236 | align-items: center;
237 | justify-content: center;
238 | }
239 |
240 | .playlist-card .icon {
241 | font-size: 3rem;
242 | color: var(--primary-color);
243 | margin-bottom: 15px;
244 | }
245 |
246 | .playlist-card .name {
247 | font-weight: 600;
248 | color: var(--text-main);
249 | margin-bottom: 5px;
250 | }
251 |
252 | .playlist-card .count {
253 | font-size: 0.9rem;
254 | color: var(--text-muted);
255 | }
256 |
257 | .playlist-card-cta .icon {
258 | font-size: 2.5rem;
259 | color: var(--text-muted);
260 | margin-bottom: 15px;
261 | }
262 |
263 | .playlist-card-cta .text {
264 | font-weight: 500;
265 | color: var(--text-muted);
266 | }
267 |
268 | .playlist-slides-container {
269 | display: flex;
270 | gap: 15px;
271 | overflow-x: auto;
272 | padding: 15px;
273 | border: 2px solid var(--border-color);
274 | background-color: var(--bg-dark);
275 | border-radius: 8px;
276 | min-height: 200px;
277 | align-items: center;
278 | }
279 |
280 | .playlist-slide {
281 | flex-shrink: 0;
282 | width: 150px;
283 | height: 150px;
284 | position: relative;
285 | border-radius: 6px;
286 | overflow: hidden;
287 | cursor: grab;
288 | transition: transform 0.2s ease;
289 | }
290 |
291 | /* --- FIX: Style for the slide being dragged --- */
292 | .playlist-slide.dragging {
293 | opacity: 0.5;
294 | cursor: grabbing;
295 | }
296 |
297 | .playlist-slide img {
298 | width: 100%;
299 | height: 100%;
300 | object-fit: cover;
301 | }
302 |
303 | .playlist-slide .remove-slide-btn {
304 | position: absolute;
305 | top: 5px;
306 | right: 5px;
307 | width: 24px;
308 | height: 24px;
309 | background-color: rgba(0, 0, 0, 0.6);
310 | color: white;
311 | border-radius: 50%;
312 | display: flex;
313 | align-items: center;
314 | justify-content: center;
315 | cursor: pointer;
316 | opacity: 0;
317 | transition: opacity 0.2s ease;
318 | }
319 |
320 | .playlist-slide:hover .remove-slide-btn {
321 | opacity: 1;
322 | }
323 |
324 | .playlist-slide .duration-editor {
325 | position: absolute;
326 | bottom: 5px;
327 | right: 5px;
328 | background-color: rgba(0, 0, 0, 0.7);
329 | color: white;
330 | padding: 4px 8px;
331 | border-radius: 15px;
332 | font-size: 0.9rem;
333 | cursor: pointer;
334 | display: flex;
335 | align-items: center;
336 | gap: 4px;
337 | }
338 |
339 | /* --- FIX: Constrain width and style the duration input --- */
340 | .duration-editor .duration-input {
341 | width: 40px; /* Constrain width */
342 | background: transparent;
343 | border: none;
344 | color: white;
345 | text-align: center;
346 | padding: 0;
347 | font-size: 0.9rem;
348 | font-family: inherit;
349 | font-weight: 500;
350 | }
351 |
352 | .duration-editor .duration-input:focus {
353 | outline: none;
354 | }
355 | /* Hide number input arrows */
356 | .duration-editor input::-webkit-outer-spin-button,
357 | .duration-editor input::-webkit-inner-spin-button {
358 | -webkit-appearance: none;
359 | margin: 0;
360 | }
361 |
362 | .duration-editor input[type=number] {
363 | -moz-appearance: textfield;
364 | }
365 |
366 | .add-slide-card {
367 | border: 2px dashed var(--border-color);
368 | display: flex;
369 | flex-direction: column;
370 | align-items: center;
371 | justify-content: center;
372 | color: var(--text-muted);
373 | cursor: pointer;
374 | transition: all 0.2s ease;
375 | }
376 |
377 | .add-slide-card:hover {
378 | border-color: var(--primary-color);
379 | color: var(--primary-color);
380 | }
--------------------------------------------------------------------------------
/public/css/features.css:
--------------------------------------------------------------------------------
1 | /* /css/features.css */
2 |
3 | /* ====== PAGE HERO STYLING ====== */
4 | .page-hero {
5 | text-align: center;
6 | padding: 140px 20px 80px 20px; /* Increased top padding */
7 | background-color: var(--bg-dark);
8 | }
9 |
10 | .page-hero h1 {
11 | font-size: 3.2rem;
12 | margin-bottom: 1rem;
13 | color: var(--text-light);
14 | font-weight: 800;
15 | }
16 |
17 | .page-hero p {
18 | font-size: 1.25rem;
19 | max-width: 700px;
20 | margin: 0 auto;
21 | color: var(--text-muted);
22 | }
23 |
24 | /* ====== FEATURE SECTION LAYOUT ====== */
25 | .feature-section {
26 | padding: 100px 0;
27 | border-bottom: 1px solid var(--border-color);
28 | overflow-x: clip;
29 | }
30 |
31 | .feature-section:last-of-type {
32 | border-bottom: none;
33 | }
34 |
35 | .feature-row {
36 | display: grid;
37 | grid-template-columns: repeat(2, 1fr);
38 | gap: 80px;
39 | align-items: center;
40 | }
41 |
42 | .feature-row.reverse .feature-visual-content {
43 | grid-column: 1 / 2;
44 | grid-row: 1 / 2;
45 | }
46 |
47 | .feature-row.reverse .feature-text-content {
48 | grid-column: 2 / 3;
49 | grid-row: 1 / 2;
50 | }
51 |
52 | /* ====== FEATURE TEXT STYLING ====== */
53 | .feature-text-content h2 {
54 | font-size: 2.5rem;
55 | margin-bottom: 1.5rem;
56 | font-weight: 700;
57 | }
58 |
59 | .feature-text-content p {
60 | font-size: 1rem; /* Reduced font size as requested */
61 | line-height: 1.7;
62 | margin-bottom: 2rem;
63 | color: var(--text-muted);
64 | }
65 |
66 | .feature-text-content ul {
67 | list-style: none;
68 | padding: 0;
69 | margin: 0 0 2rem 0;
70 | }
71 |
72 | .feature-text-content li {
73 | font-size: 1.1rem;
74 | margin-bottom: 1rem;
75 | display: flex;
76 | align-items: flex-start;
77 | gap: 12px;
78 | }
79 |
80 | .feature-text-content li .fa-check-circle {
81 | color: var(--primary-color);
82 | margin-top: 5px;
83 | }
84 |
85 | /* ====== RECREATED UI VISUALS - General ====== */
86 | .feature-visual-content {
87 | display: flex;
88 | align-items: center;
89 | justify-content: center;
90 | min-height: 400px;
91 | position: relative;
92 | }
93 |
94 | /* ====== DASHBOARD VISUAL ====== */
95 | .dashboard-visual-wrapper {
96 | width: 100%;
97 | height: 350px;
98 | position: relative;
99 | perspective: 1500px;
100 | }
101 | .screen-preview-card {
102 | position: absolute;
103 | width: 300px;
104 | border-radius: 12px;
105 | background-color: var(--bg-light);
106 | border: 1px solid var(--border-color);
107 | box-shadow: 0 20px 40px rgba(0,0,0,0.3);
108 | overflow: hidden;
109 | transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
110 | /* Centering logic for absolute elements */
111 | left: 50%;
112 | top: 50%;
113 | }
114 | /* Re-centered transforms */
115 | .screen-preview-card[data-index="1"] { transform: translateX(-50%) translateY(-50%) rotateZ(-10deg) translate(-110px, 0px); }
116 | .screen-preview-card[data-index="2"] { transform: translateX(-50%) translateY(-50%) rotateZ(2deg) translate(0px, -20px); z-index: 2; }
117 | .screen-preview-card[data-index="3"] { transform: translateX(-50%) translateY(-50%) rotateZ(10deg) translate(110px, 0px); }
118 | .screen-preview-card .image-container { height: 160px; background-color: var(--bg-dark); }
119 | .screen-preview-card .image-container img { width: 100%; height: 100%; object-fit: cover; }
120 | .screen-preview-card .screen-info { padding: 16px; display: flex; justify-content: space-between; align-items: center; }
121 | .screen-preview-card .screen-info .name { font-weight: 600; }
122 | .offline-overlay { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 1.2rem; font-weight: 500; }
123 |
124 | /* UNIFIED HOVER ANIMATION FOR DASHBOARD */
125 | .dashboard-visual-wrapper:hover .screen-preview-card[data-index="1"] {
126 | transform: translateX(-50%) translateY(-50%) rotateZ(-14deg) translate(-130px, -10px) scale(1.03);
127 | }
128 | .dashboard-visual-wrapper:hover .screen-preview-card[data-index="2"] {
129 | transform: translateX(-50%) translateY(-50%) rotateZ(0deg) translate(0px, -35px) scale(1.03);
130 | }
131 | .dashboard-visual-wrapper:hover .screen-preview-card[data-index="3"] {
132 | transform: translateX(-50%) translateY(-50%) rotateZ(14deg) translate(130px, -10px) scale(1.03);
133 | }
134 |
135 | /* ====== PLAYLIST VISUAL ====== */
136 | .playlist-editor-demo {
137 | background: var(--bg-light);
138 | border: 1px solid var(--border-color);
139 | border-radius: 12px;
140 | padding: 20px;
141 | display: flex;
142 | align-items: center;
143 | gap: 16px;
144 | transform: rotateZ(-5deg);
145 | box-shadow: 0 20px 40px rgba(0,0,0,0.3);
146 | position: relative;
147 | }
148 | .playlist-slide-demo {
149 | width: 110px; height: 110px;
150 | border-radius: 8px;
151 | overflow: hidden;
152 | border: 1px solid var(--border-color);
153 | flex-shrink: 0;
154 | transition: all 0.3s ease;
155 | position: relative;
156 | }
157 | .playlist-slide-demo img { width: 100%; height: 100%; object-fit: cover; }
158 | .playlist-slide-demo.is-dragging-demo {
159 | transform: translateY(-15px) scale(1.05);
160 | box-shadow: 0 15px 30px rgba(0,0,0,0.4);
161 | cursor: grabbing;
162 | }
163 | .duration-editor-demo {
164 | position: absolute;
165 | bottom: 8px; right: 8px;
166 | background: rgba(0,0,0,0.7);
167 | color: #fff;
168 | padding: 4px 8px;
169 | border-radius: 20px;
170 | font-size: 13px;
171 | font-weight: 600;
172 | }
173 | .drop-placeholder-demo {
174 | width: 110px; height: 110px;
175 | border-radius: 8px;
176 | background: var(--bg-dark);
177 | border: 2px dashed var(--primary-color);
178 | flex-shrink: 0;
179 | }
180 | .drag-cursor-demo {
181 | position: absolute;
182 | bottom: -15px; right: 100px;
183 | font-size: 24px;
184 | color: var(--text-light);
185 | transform: rotate(20deg);
186 | }
187 |
188 | /* ====== SCHEDULER VISUAL ====== */
189 | .scheduler-demo-wrapper {
190 | width: 100%;
191 | height: 280px;
192 | display: grid;
193 | grid-template-columns: 60px 1fr 1fr 1fr;
194 | grid-template-rows: 40px 1fr;
195 | position: relative;
196 | overflow: hidden;
197 | background-color: var(--bg-light);
198 | border: 1px solid var(--border-color);
199 | border-radius: 12px;
200 | box-shadow: 0 10px 30px rgba(0,0,0,0.2);
201 | }
202 | .scheduler-header-demo { grid-column: 1 / -1; grid-row: 1 / 2; display: contents; background-color: var(--bg-lighter); }
203 | .scheduler-header-demo > div { border-bottom: 1px solid var(--border-color); border-left: 1px solid var(--border-color); font-size: 13px; display: flex; align-items: center; justify-content: center; font-weight: 500; }
204 | .scheduler-header-demo > div:first-child { border-left: none; }
205 | .timeline-demo { grid-column: 1 / 2; grid-row: 2 / -1; display: flex; flex-direction: column; }
206 | .timeline-demo > div { flex: 1; display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--text-muted); border-top: 1px solid var(--border-color); border-right: 1px solid var(--border-color); }
207 | .schedule-column-demo { grid-row: 2 / -1; border-top: 1px solid var(--border-color); position: relative; border-left: 1px solid var(--border-color); }
208 | .schedule-block-demo { position: absolute; left: 6px; right: 6px; background-color: var(--primary-color); border-radius: 6px; padding: 8px; overflow: hidden; color: #fff; font-weight: 500; font-size: 13px; display: flex; align-items: center;}
209 |
210 | /* ====== GITHUB VISUAL ====== */
211 | .github-card-demo {
212 | background-color: var(--bg-light);
213 | border: 1px solid var(--border-color);
214 | border-radius: 12px;
215 | padding: 24px;
216 | width: 100%;
217 | max-width: 450px;
218 | transform: rotateZ(3deg);
219 | box-shadow: 0 20px 40px rgba(0,0,0,0.3);
220 | }
221 | .gh-header { display: flex; align-items: center; gap: 12px; font-size: 1.2rem; margin-bottom: 20px; }
222 | .gh-header i { font-size: 1.8rem; }
223 | .gh-header .repo-name { color: var(--text-muted); }
224 | .gh-header .repo-name strong { color: var(--text-main); font-weight: 600; }
225 | .gh-stats { display: flex; gap: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 20px; margin-bottom: 20px; }
226 | .gh-stats span { display: flex; align-items: center; gap: 8px; }
227 | .gh-stats i { color: var(--text-muted); }
228 | .gh-count { background-color: var(--bg-dark); border-radius: 20px; padding: 2px 8px; font-size: 13px; font-weight: 500; }
229 | .gh-issue-card { background-color: var(--bg-dark); border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; display: flex; align-items: center; gap: 12px; }
230 | .gh-issue-card i { color: var(--green-status); font-size: 1.2rem; }
231 | .gh-issue-card .issue-title { font-weight: 500; }
232 | .gh-label { background-color: #3B82F6; color: #fff; font-size: 12px; padding: 3px 8px; border-radius: 20px; margin-left: auto; white-space: nowrap;}
233 |
234 |
235 | /* ======================================================= */
236 | /* ====== FINAL RESPONSIVE & MOBILE OVERLAP FIX ====== */
237 | /* ======================================================= */
238 | @media (max-width: 992px) {
239 | .feature-row {
240 | display: block;
241 | }
242 | .feature-text-content {
243 | text-align: center;
244 | margin-bottom: 60px;
245 | }
246 | .feature-visual-content {
247 | min-height: unset;
248 | }
249 | .feature-text-content ul { display: inline-block; text-align: left; }
250 | .feature-text-content .btn { margin: 0 auto; }
251 |
252 | /* --- CRITICAL MOBILE VISUAL RESETS --- */
253 | .playlist-editor-demo,
254 | .github-card-demo,
255 | .scheduler-demo-wrapper {
256 | transform: none !important;
257 | width: 100%;
258 | max-width: 500px;
259 | margin: 0 auto;
260 | }
261 |
262 | .dashboard-visual-wrapper {
263 | perspective: none;
264 | height: auto;
265 | }
266 |
267 | /* Disable hover animations on mobile for dashboard */
268 | .dashboard-visual-wrapper:hover .screen-preview-card,
269 | .dashboard-visual-wrapper:hover .screen-preview-card,
270 | .dashboard-visual-wrapper:hover .screen-preview-card {
271 | transform: none !important;
272 | }
273 |
274 | /* REFINED MOBILE DASHBOARD VISUAL */
275 | .dashboard-visual-wrapper {
276 | perspective: 800px;
277 | height: 300px;
278 | }
279 | .screen-preview-card {
280 | width: 240px;
281 | left: 50%;
282 | top: 50%;
283 | }
284 | .screen-preview-card[data-index="1"] { transform: translateX(-50%) translateY(-50%) rotateZ(-6deg) translate(-40px, 20px); }
285 | .screen-preview-card[data-index="2"] { transform: translateX(-50%) translateY(-50%) rotateZ(2deg) translate(0px, 0px); }
286 | .screen-preview-card[data-index="3"] { transform: translateX(-50%) translateY(-50%) rotateZ(7deg) translate(40px, 30px); }
287 | }
288 |
289 | @media (max-width: 768px) {
290 | .page-hero { padding-top: 120px; padding-bottom: 60px; }
291 | .page-hero h1 { font-size: 2.5rem; }
292 | .feature-text-content h2 { font-size: 2rem; }
293 | .feature-section { padding: 60px 0; }
294 | }
295 |
296 | @media (max-width: 480px) {
297 | .page-hero h1 { font-size: 2.2rem; }
298 | .feature-text-content h2 { font-size: 1.8rem; }
299 | .feature-text-content { margin-bottom: 40px; }
300 | .playlist-editor-demo {
301 | padding: 16px;
302 | gap: 12px;
303 | }
304 | .playlist-slide-demo, .drop-placeholder-demo {
305 | width: 80px; height: 80px;
306 | }
307 |
308 | .dashboard-visual-wrapper {
309 | height: 250px;
310 | }
311 | .screen-preview-card {
312 | width: 220px;
313 | }
314 | }
--------------------------------------------------------------------------------
/public/js/main.js:
--------------------------------------------------------------------------------
1 | // public/js/main.js
2 |
3 | import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
4 | import { getAuth, connectAuthEmulator, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-auth.js";
5 | import { getFirestore, connectFirestoreEmulator, Timestamp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
6 | import { getStorage, connectStorageEmulator } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-storage.js";
7 | import { getDatabase, connectDatabaseEmulator, ref, onValue } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-database.js";
8 | import { getFunctions, connectFunctionsEmulator } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-functions.js";
9 | import { initializeAppCheck, ReCaptchaV3Provider } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app-check.js";
10 | import * as UI from './ui.js';
11 | import { renderScheduler } from './ui-scheduler.js';
12 | import { navigateTo } from './navigation.js';
13 | import { initializeEventListeners } from './event-handler.js';
14 | import * as ScreenManager from './screens-manager.js';
15 | import * as MediaManager from './media-manager.js';
16 | import * as SchedulerManager from './scheduler-manager.js';
17 | import * as SettingsManager from './settings-manager.js';
18 | import * as PlaylistManager from './playlist-manager.js';
19 |
20 | const adminApp = initializeApp(firebaseConfig, "ADMIN");
21 |
22 | initializeAppCheck(adminApp, {
23 | provider: new ReCaptchaV3Provider('PASTE_YOUR_RECAPTCHA_V3_SITE_KEY_HERE'),
24 | isTokenAutoRefreshEnabled: true
25 | });
26 |
27 | const auth = getAuth(adminApp);
28 | const db = getFirestore(adminApp);
29 | const storage = getStorage(adminApp);
30 | const rtdb = getDatabase(adminApp);
31 | const functions = getFunctions(adminApp);
32 |
33 | if (window.location.hostname === "127.0.0.1" || window.location.hostname === "localhost") {
34 | connectAuthEmulator(auth, "http://127.0.0.1:9099");
35 | connectFunctionsEmulator(functions, "127.0.0.1", 5001);
36 | }
37 |
38 | const appState = {
39 | auth: auth,
40 | db: db,
41 | storage: storage,
42 | rtdb: rtdb,
43 | functions: functions,
44 | currentUser: null,
45 | userSettings: {},
46 | allScreens: [],
47 | screenStatuses: {},
48 | allMedia: [],
49 | allPlaylists: [],
50 | allSchedules: [],
51 | currentSchedulerView: 'day',
52 | selectedDate: new Date(),
53 | selectedScreenId: null,
54 |
55 | calculateCurrentScreenStates() {
56 | const now = new Date();
57 | return this.allScreens.map(screen => {
58 | const activeSchedule = this.allSchedules.find(schedule =>
59 | schedule.screenIds.includes(screen.id) &&
60 | schedule.startTime.toDate() <= now &&
61 | schedule.endTime.toDate() > now
62 | );
63 |
64 | let contentToShow = null;
65 | if (activeSchedule) {
66 | contentToShow = activeSchedule.content;
67 | } else if (screen.defaultContent) {
68 | contentToShow = screen.defaultContent;
69 | } else if (this.userSettings.globalDefaultContent) {
70 | contentToShow = this.userSettings.globalDefaultContent;
71 | }
72 |
73 | let url = null;
74 | if (contentToShow) {
75 | if (contentToShow.type === 'image') {
76 | url = contentToShow.data.url;
77 | } else if (contentToShow.type === 'playlist') {
78 | const playlist = this.allPlaylists.find(p => p.id === contentToShow.data.id);
79 | url = playlist?.items?.[0]?.media?.url || 'https://via.placeholder.com/400x200/1E1E1E/9CA3AF?text=Playlist';
80 | }
81 | }
82 |
83 | return { ...screen, currentImageURL: url };
84 | });
85 | },
86 |
87 | renderCurrentSchedulerView() {
88 | renderScheduler(this.currentSchedulerView, this.allSchedules, this.allScreens, this.selectedDate, this.selectedScreenId);
89 |
90 | const dateDisplay = document.getElementById('schedule-current-date');
91 | if (!dateDisplay) return;
92 |
93 | if (this.currentSchedulerView === 'day') {
94 | const mobileOptions = { weekday: 'short', month: 'numeric', day: 'numeric', year: '2-digit' };
95 | const desktopOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
96 | dateDisplay.innerHTML = `${this.selectedDate.toLocaleDateString(undefined, desktopOptions)} ${this.selectedDate.toLocaleDateString(undefined, mobileOptions)} `;
97 | } else {
98 | const startOfWeek = new Date(this.selectedDate);
99 | startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay() + (startOfWeek.getDay() === 0 ? -6 : 1));
100 | const endOfWeek = new Date(startOfWeek);
101 | endOfWeek.setDate(startOfWeek.getDate() + 6);
102 | const mobileOptions = { month: 'numeric', day: 'numeric' };
103 | const desktopOptions = { month: 'short', day: 'numeric' };
104 | const desktopEndDateString = endOfWeek.toLocaleDateString(undefined, desktopOptions);
105 | dateDisplay.innerHTML = `${startOfWeek.toLocaleDateString(undefined, desktopOptions)} - ${desktopEndDateString} ${startOfWeek.toLocaleDateString(undefined, mobileOptions)} - ${endOfWeek.toLocaleDateString(undefined, mobileOptions)} `;
106 | }
107 |
108 | const screenSelectorContainer = document.getElementById('screen-selector-container');
109 | if (screenSelectorContainer) {
110 | screenSelectorContainer.classList.toggle('hidden', this.currentSchedulerView !== 'screen');
111 | }
112 |
113 | const viewport = document.getElementById('scheduler-viewport');
114 | if (viewport) {
115 | const hourHeight = 60;
116 | const isToday = new Date().toDateString() === this.selectedDate.toDateString();
117 | viewport.scrollTop = isToday ? (new Date().getHours()) * hourHeight : 7.5 * hourHeight;
118 | }
119 | this.handleImageLoading();
120 | },
121 |
122 | handleImageLoading() {
123 | const images = document.querySelectorAll('.lazy-image');
124 | images.forEach(img => {
125 | if (img.complete && img.naturalHeight > 0) {
126 | img.classList.add('is-loaded');
127 | } else {
128 | img.addEventListener('load', () => img.classList.add('is-loaded'), { once: true });
129 | img.addEventListener('error', () => console.warn("Image failed to load:", img.src), { once: true });
130 | }
131 | });
132 | }
133 | };
134 |
135 | function initApp(user) {
136 | appState.currentUser = user;
137 | const userInfoEl = document.getElementById('user-info');
138 | if (userInfoEl) {
139 | userInfoEl.innerHTML = `${user.displayName} `;
140 | }
141 |
142 | initializeEventListeners(appState);
143 |
144 | const reRenderDashboard = () => {
145 | if (document.querySelector('#page-dashboard.active')) {
146 | UI.renderDashboard(appState.calculateCurrentScreenStates(), appState.allMedia, appState.allSchedules);
147 | appState.handleImageLoading();
148 | }
149 | };
150 |
151 | const connectionsRef = ref(rtdb, `/connections/${user.uid}`);
152 | onValue(connectionsRef, (snapshot) => {
153 | appState.screenStatuses = snapshot.val() || {};
154 | let needsUIRefresh = false;
155 | appState.allScreens.forEach(screen => {
156 | const connectionInfo = appState.screenStatuses[screen.id];
157 | // A screen is online if its connection list exists and has at least one entry.
158 | const newStatus = (connectionInfo && Object.keys(connectionInfo).length > 0) ? 'online' : 'offline';
159 | if (screen.status !== newStatus) {
160 | screen.status = newStatus;
161 | needsUIRefresh = true;
162 | }
163 | });
164 | if (needsUIRefresh) {
165 | if (document.querySelector('#page-screens.active')) UI.renderScreens(appState.allScreens);
166 | reRenderDashboard();
167 | }
168 | });
169 |
170 | SettingsManager.listenForSettingsChanges(user.uid, db, settings => {
171 | appState.userSettings = settings;
172 | if (document.querySelector('#page-media.active')) {
173 | UI.updateGlobalDefaultIndicator(settings.globalDefaultContent);
174 | }
175 | reRenderDashboard();
176 | });
177 |
178 | ScreenManager.listenForScreenChanges(user.uid, db, newScreenData => {
179 | appState.allScreens = newScreenData.map(newScreen => ({
180 | id: newScreen.id,
181 | name: newScreen.name,
182 | defaultContent: newScreen.defaultContent,
183 | status: (() => {
184 | const connectionInfo = appState.screenStatuses[newScreen.id];
185 | return (connectionInfo && Object.keys(connectionInfo).length > 0) ? 'online' : 'offline';
186 | })()
187 | }));
188 |
189 | if (document.querySelector('#page-screens.active')) UI.renderScreens(appState.allScreens);
190 |
191 | const screenSelector = document.getElementById('screen-selector');
192 | if (screenSelector) {
193 | const currentVal = screenSelector.value;
194 | screenSelector.innerHTML = appState.allScreens.map(s => `${s.name} `).join('');
195 | if (!appState.selectedScreenId && appState.allScreens.length > 0) appState.selectedScreenId = appState.allScreens[0].id;
196 | else if (appState.allScreens.length === 0) appState.selectedScreenId = null;
197 | screenSelector.value = appState.allScreens.some(s => s.id === currentVal) ? currentVal : appState.selectedScreenId;
198 | }
199 | reRenderDashboard();
200 | if (document.querySelector('#page-scheduler.active')) appState.renderCurrentSchedulerView();
201 | });
202 |
203 | MediaManager.listenForMediaChanges(user.uid, db, (snapshot) => {
204 | appState.allMedia = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
205 | if (document.querySelector('#page-media.active')) {
206 | UI.renderMedia(appState.allMedia, appState.userSettings.globalDefaultContent);
207 | appState.handleImageLoading();
208 | }
209 | reRenderDashboard();
210 | });
211 |
212 | PlaylistManager.listenForPlaylistsChanges(user.uid, db, (snapshot) => {
213 | appState.allPlaylists = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
214 | if (document.querySelector('#page-media.active')) {
215 | UI.renderPlaylists(appState.allPlaylists, appState.userSettings.globalDefaultContent);
216 | }
217 | reRenderDashboard();
218 | });
219 |
220 | SchedulerManager.listenForScheduleChanges(user.uid, db, schedules => {
221 | appState.allSchedules = schedules;
222 | reRenderDashboard();
223 | if (document.querySelector('#page-scheduler.active')) appState.renderCurrentSchedulerView();
224 | });
225 |
226 | navigateTo('dashboard', appState);
227 | }
228 |
229 | onAuthStateChanged(auth, (user) => {
230 | if (user) {
231 | if (window.location.pathname === '/' || window.location.pathname === '/index.html') {
232 | window.location.replace('/dashboard');
233 | } else {
234 | initApp(user);
235 | }
236 | } else {
237 | if (window.location.pathname.includes('/dashboard')) {
238 | window.location.replace('/');
239 | }
240 | }
241 | });
--------------------------------------------------------------------------------
/public/features.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Features | Signet - Free, Open-Source Digital Signage Platform
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
The Modern Toolkit for Digital Signage
34 |
Signet is engineered to be powerful yet intuitive. See how our core features give you complete command over your digital displays.
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
Central Command Center
43 |
Don't guess, know. The dashboard is your mission control, providing a real-time, bird's-eye view of your entire screen network. Instantly see what's online, what's offline, and what content is currently playing on every display.
44 |
45 | Monitor the health of all screens at a glance.
46 | Visually confirm the content playing on each screen.
47 | Access key stats with quick-view widgets.
48 |
49 |
50 |
51 |
52 |
53 |
54 |
Cafeteria Screen Online
55 |
56 |
57 |
58 |
Main Lobby Online
59 |
60 |
61 |
62 |
Meeting Room 3 Offline
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
96 |
97 |
98 |
99 |
100 |
101 |
Effortless Scheduling
102 |
Take the guesswork out of content planning. Our visual, timeline-based scheduler lets you organize content for specific screens and times. Plan promotions, announcements, and daily menus with precision and ease.
103 |
104 | Plan by the day or see a whole week per screen.
105 | Schedule content to run for minutes, hours, or across multiple days.
106 | High-priority events automatically override default content.
107 |
108 |
109 |
110 |
111 |
114 |
115 |
9 AM
10 AM
11 AM
12 PM
116 |
117 |
120 |
123 |
124 |
Safety Notice
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
Transparent & Community-Driven
136 |
Signet is built on trust. As a 100% open-source project, its code is available for anyone to review, audit, and contribute to. This isn't a black box; it's a collaborative platform shaped by its users.
137 |
138 | Full source code available on GitHub.
139 | No data tracking. No hidden telemetry. Ever.
140 | Suggest and vote on new features you want to see.
141 |
142 |
View Source on GitHub
143 |
144 |
145 |
146 |
150 |
151 |
Star 142
152 |
Fork 21
153 |
154 |
155 |
156 | Feature Request: Add video support
157 | enhancement
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
Ready to Revolutionize Your Displays?
169 |
Get started in minutes with our free hosted service or deploy it yourself. The powerful, open, and free digital signage platform is here.
170 |
Create Your Free Account
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Signet - Open Source Digital Signage
2 |
3 |
4 |
5 | ### 💖 Support The Project
6 |
7 | If you find Signet useful and want to support its development, you can buy me a coffee! It's a small gesture that is greatly appreciated.
8 |
9 |
10 |
11 |
12 |
13 | Signet is a free, open-source, and self-hostable digital signage management system. Built entirely on the Firebase platform, it provides a powerful admin dashboard to manage content across multiple screens from anywhere.
14 |
15 | 
16 |
17 | ## Features
18 |
19 | - **Live Dashboard:** Get a real-time, bird's-eye view of your entire screen network. Instantly see connection status and what's currently playing.
20 | - **Dynamic Playlists:** Group images into reusable playlists. Set custom durations for each slide and reorder with a simple drag-and-drop.
21 | - **Intuitive Scheduler:** A visual, timeline-based scheduler to queue content for specific screens at specific times, with support for multi-day events.
22 | - **Secure Screen Pairing:** A simple and secure PIN-based system to pair physical screens to your account without exposing credentials.
23 | - **Default & Fallback Content:** Set default content on a per-screen basis or a global fallback for when nothing is scheduled.
24 | - **Unified Media Library:** Upload, rename, and manage all your image assets (PNG, JPG, GIF, SVG) in one central place.
25 | - **100% Open Source & Self-Hostable:** Take full control. Host it on your own Firebase project for free and own your data completely. No hidden telemetry.
26 | - **Modern UI:** A clean, responsive interface that works on desktop and mobile, with both light and dark themes.
27 |
28 | ## Tech Stack
29 |
30 | - **Frontend:** Vanilla JavaScript (ES6 Modules), HTML5, CSS3
31 | - **Backend & Database:** Google Firebase
32 | - **Authentication:** For secure user logins via Google.
33 | - **Firestore:** For storing all app data (screens, media metadata, playlists, schedules).
34 | - **Storage:** For hosting uploaded image files.
35 | - **Realtime Database:** For tracking screen presence and live online/offline status.
36 | - **Cloud Functions:** For handling secure server-side logic like screen pairing.
37 | - **Hosting:** For deploying the entire web application.
38 |
39 | ---
40 |
41 | ## Getting Started
42 |
43 | Follow these instructions to get a copy of the project up and running on your own Firebase account.
44 |
45 | ### Prerequisites
46 |
47 | - A Google Firebase account (the free "Spark" plan is sufficient to start).
48 | - [Node.js](https://nodejs.org/) (v20 or higher) and npm installed on your local machine.
49 |
50 | ### Installation & Setup
51 |
52 | 1. **Clone the repository:**
53 | ```bash
54 | git clone https://github.com/yf19770/sign-net.git
55 | cd sign-net
56 | ```
57 |
58 | 2. **Create a Firebase Project:**
59 | - Go to the [Firebase Console](https://console.firebase.google.com/) and create a new project.
60 | - In your new project, you must enable the following services:
61 | - **Authentication:** Go to the "Authentication" section, click "Get started," and enable **Google** as a Sign-in provider.
62 | - **Firestore Database:** Create a new Firestore database. Start in **production mode**.
63 | - **Storage:** Enable Cloud Storage.
64 | - **Realtime Database:** Create a Realtime Database. Start in **locked mode**.
65 | - **Functions:** If this is your first time using functions, you may need to upgrade your project to the "Blaze (Pay-as-you-go)" plan. However, the free tier for Cloud Functions is very generous and is unlikely to incur any costs for this project's usage.
66 |
67 | 3. **Get your Firebase Configuration:**
68 | - In your Firebase project console, go to **Project Settings** (click the gear icon ⚙️).
69 | - In the "General" tab, scroll down to "Your apps."
70 | - Click the web icon (`>`) to create a new Web App.
71 | - Give it a nickname (e.g., "Signet App") and register the app. **Do not** check the box for Firebase Hosting at this stage.
72 | - Firebase will provide you with a `firebaseConfig` object. **Copy this entire object.**
73 |
74 | 4. **Configure the Project (`firebase-config.js`):**
75 | - In the cloned project, open the file `public/firebase-config.js`.
76 | - **Replace the entire placeholder `firebaseConfig` object with the one you just copied from your Firebase console.**
77 |
78 | 5. **Configure App Check (Security):**
79 | - **Enable App Check:** In the Firebase Console, go to **App Check** in the left-hand menu (under the "Build" category). Click "Get Started."
80 | - Select your web app under the "Apps" tab and click on **reCAPTCHA v3** as the provider.
81 | - You will be given a **Site Key**. Copy this key.
82 | - **Update the code:** Open the file `public/js/main.js`. Find the `initializeAppCheck` block and replace the placeholder key with your new Site Key.
83 | ```javascript
84 | // In public/js/main.js
85 | initializeAppCheck(adminApp, {
86 | provider: new ReCaptchaV3Provider('PASTE_YOUR_RECAPTCHA_V3_SITE_KEY_HERE'),
87 | isTokenAutoRefreshEnabled: true
88 | });
89 | ```
90 | - Finally, back in the Firebase Console (App Check section), click "Enforce" for Cloud Functions to protect them.
91 |
92 | 6. **Install Firebase CLI & Link Project:**
93 | - Install the Firebase command-line tools globally:
94 | ```bash
95 | npm install -g firebase-tools
96 | ```
97 | - Log in to your Google account:
98 | ```bash
99 | firebase login
100 | ```
101 | - Link your local directory to your Firebase project. Replace `YOUR_PROJECT_ID` with the ID of the project you created.
102 | ```bash
103 | firebase use --add YOUR_PROJECT_ID
104 | ```
105 |
106 | 7. **Install Function Dependencies:**
107 | - Cloud Functions have their own dependencies. You need to install them.
108 | ```bash
109 | cd functions
110 | npm install
111 | cd ..
112 | ```
113 |
114 | ---
115 |
116 | ## 🔐 Security Rules - IMPORTANT!
117 |
118 | By default, your databases and storage are locked down. You **must** apply these security rules to allow the app to function correctly.
119 |
120 |
121 | Click to expand Firestore Security Rules
122 |
123 | Go to your **Firebase Console -> Firestore Database -> Rules** tab and paste the following, then click **Publish**:
124 |
125 | ```javascript
126 | rules_version = '2';
127 | service cloud.firestore {
128 | match /databases/{database}/documents {
129 |
130 | // Helper function to check if the requester is the owner of a document.
131 | function isOwner(resource) {
132 | return request.auth.uid == resource.data.adminUid;
133 | }
134 |
135 | // --- User Data ---
136 | match /users/{userId}/{document=**} {
137 | allow read, write: if request.auth.uid == userId;
138 | }
139 |
140 | // --- Screens Collection ---
141 | match /screens/{screenId} {
142 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
143 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/screens/$(screenId)).data.adminUid;
144 | allow list: if request.auth.uid == resource.data.adminUid;
145 | allow get: if (request.auth.uid == resource.data.adminUid) || (request.auth.uid == screenId);
146 | }
147 |
148 | // --- Media Collection ---
149 | match /media/{mediaId} {
150 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
151 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/media/$(mediaId)).data.adminUid;
152 | allow read: if request.auth.uid == resource.data.adminUid;
153 | }
154 |
155 | // --- Playlists Collection (THIS IS THE FIX) ---
156 | match /playlists/{playlistId} {
157 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
158 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/playlists/$(playlistId)).data.adminUid;
159 |
160 | // An Admin can read their own playlists.
161 | // A Screen can read a playlist IF that playlist belongs to the screen's owner.
162 | allow read: if request.auth.uid == resource.data.adminUid ||
163 | (exists(/databases/$(database)/documents/screens/$(request.auth.uid)) &&
164 | get(/databases/$(database)/documents/screens/$(request.auth.uid)).data.adminUid == resource.data.adminUid);
165 | }
166 |
167 | // --- Schedules Collection ---
168 | match /schedules/{scheduleId} {
169 | allow create, update: if request.auth.uid == request.resource.data.adminUid;
170 | allow delete: if request.auth.uid == get(/databases/$(database)/documents/schedules/$(scheduleId)).data.adminUid;
171 | allow read: if (request.auth.uid == resource.data.adminUid) || (request.auth.uid in resource.data.screenIds);
172 | }
173 |
174 | // --- Special Rule for a Screen to read its owner's settings ---
175 | match /users/{userId}/settings/main {
176 | allow get: if exists(/databases/$(database)/documents/screens/$(request.auth.uid)) &&
177 | get(/databases/$(database)/documents/screens/$(request.auth.uid)).data.adminUid == userId;
178 | }
179 |
180 | // --- Pairing Requests ---
181 | match /pairingRequests/{pairingId} {
182 | allow read, create: if true;
183 | allow update: if request.auth.token.firebase.sign_in_provider != 'custom';
184 | }
185 | }
186 | }
187 | ```
188 |
189 |
190 |
191 |
192 | Click to expand Storage Security Rules
193 |
194 | Go to your **Firebase Console -> Storage -> Rules** tab and paste the following, then click **Publish**:
195 |
196 | ```javascript
197 | rules_version = '2';
198 | service firebase.storage {
199 | match /b/{bucket}/o {
200 |
201 | match /media/{userId}/{allPaths=**} {
202 | // Allow authenticated owners to read their own files.
203 | allow read: if request.auth != null && request.auth.uid == userId;
204 |
205 | // Allow write if the user is the owner AND one of the following is true:
206 | // 1. It's a delete operation.
207 | // 2. It's an image upload under 5MB.
208 | // 3. It's a video upload under 15MB.
209 | allow write: if request.auth != null && request.auth.uid == userId && (
210 |
211 | // Condition 1: Allow DELETES
212 | request.resource == null ||
213 |
214 | // Condition 2: Allow IMAGE uploads under 5MB
215 | (request.resource.contentType.matches('image/.*') &&
216 | request.resource.size < 5 * 1024 * 1024) ||
217 |
218 | // Condition 3: Allow VIDEO uploads under 15MB
219 | (request.resource.contentType.matches('video/.*') &&
220 | request.resource.size < 15 * 1024 * 1024)
221 | );
222 | }
223 |
224 | // Explicitly deny access to all other paths for security.
225 | match /{allPaths=**} {
226 | allow read, write: if false;
227 | }
228 | }
229 | }
230 | ```
231 |
232 |
233 |
234 | Click to expand Realtime Database Security Rules
235 |
236 | Go to your **Firebase Console -> Realtime Database -> Rules** tab and paste the following, then click **Publish**:
237 |
238 | ```json
239 | {
240 | "rules": {
241 | "connections": {
242 | "$uid": {
243 | // Only the owner of the data can read or write their screen connections
244 | ".read": "auth != null && auth.uid === $uid",
245 | ".write": "auth != null && auth.uid === $uid"
246 | }
247 | }
248 | }
249 | }
250 | ```
251 |
252 |
253 |
254 | ---
255 |
256 | ## Deployment
257 |
258 | Deployment is a two-step process: first the backend functions, then the frontend hosting.
259 |
260 | 1. **Deploy Cloud Functions:**
261 | ```bash
262 | firebase deploy --only functions
263 | ```
264 |
265 | 2. **Deploy Hosting:**
266 | ```bash
267 | firebase deploy --only hosting
268 | ```
269 |
270 | Firebase will give you a URL where your application is live (e.g., `your-project-id.web.app`).
271 |
272 |
--------------------------------------------------------------------------------
/public/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Signet - Admin Dashboard
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
54 |
55 |
56 |
57 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
83 |
84 |
85 | Screen Name
86 |
87 |
88 |
93 |
94 |
98 |
99 |
100 |
101 |
102 |
103 |
107 |
108 |
109 | On your viewer device, open:
110 |
111 | sign-net.app/view.html
112 |
113 |
114 |
115 |
116 |
Enter the 6-digit code displayed on your physical screen.
117 |
118 |
119 |
120 |
121 |
125 |
126 |
127 |
128 |
129 |
130 |
134 |
135 |
136 |
137 | Screen Name
138 |
139 |
140 |
145 |
146 |
150 |
151 |
152 |
153 |
154 |
155 |
159 |
160 |
161 |
165 |
169 |
179 |
180 |
185 |
186 |
187 |
188 |
189 |
190 |
194 |
195 |
196 |
197 | Playlist Name
198 |
199 |
200 |
210 |
211 |
216 |
217 |
218 |
219 |
220 |
221 |
225 |
226 |
Click one or more images to add to your playlist.
227 |
228 |
229 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
--------------------------------------------------------------------------------
/public/js/view.js:
--------------------------------------------------------------------------------
1 | // js/view.js
2 | import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
3 | import { getAuth, onAuthStateChanged, setPersistence, browserLocalPersistence, signInWithCustomToken, signOut } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-auth.js";
4 | import { getFirestore, doc, onSnapshot, getDoc, collection, where, query } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
5 | import { getDatabase, ref, onValue, set, remove, serverTimestamp, onDisconnect, push } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-database.js";
6 | import { getFunctions, httpsCallable } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-functions.js";
7 |
8 | if (typeof firebaseConfig === 'undefined') { throw new Error("Firebase SDK or firebaseConfig is not loaded."); }
9 |
10 | const screenApp = initializeApp(firebaseConfig, "SCREEN");
11 | const auth = getAuth(screenApp);
12 | const db = getFirestore(screenApp);
13 | const rtdb = getDatabase(screenApp);
14 | const functions = getFunctions(screenApp);
15 |
16 | const DOMElements = {
17 | errorContainer: document.getElementById('error-container'),
18 | pairingContainer: document.getElementById('pairing-container'),
19 | pairingBox: document.querySelector('.pairing-box'),
20 | pinDisplay: document.getElementById('pairing-pin-display'),
21 | pairingStatusText: document.querySelector('.status-text'),
22 | logoutModal: document.getElementById('logout-confirm-modal'),
23 | confirmLogoutBtn: document.getElementById('confirm-logout-btn'),
24 | cancelLogoutBtn: document.getElementById('cancel-logout-btn'),
25 | generateNewCodeBtn: document.getElementById('generate-new-code-btn'),
26 | imageSlots: [document.getElementById('image-slot-a'), document.getElementById('image-slot-b')]
27 | };
28 |
29 | let state = {
30 | screenId: null,
31 | adminUid: null,
32 | schedules: [],
33 | screenDefaultContent: null,
34 | globalDefaultContent: null,
35 | unsubscribeListeners: [],
36 | playlistUnsubscribe: null,
37 | pairingAttemptCount: 0,
38 | pinExpirationTimer: null,
39 | logoutPressTimer: null,
40 | contentTimer: null,
41 | playlistTimer: null,
42 | currentPlaylist: null,
43 | currentPlaylistIndex: 0,
44 | visibleImageSlot: 0,
45 | connectionRef: null, // Stores the unique reference for this specific connection
46 | contentSessionId: 0
47 | };
48 |
49 | const MAX_AUTO_ATTEMPTS = 2;
50 |
51 | function showError(message = "An Error Occurred") {
52 | console.error("Showing Error:", message);
53 | DOMElements.errorContainer.querySelector('h1').textContent = message;
54 | DOMElements.pairingContainer.classList.add('hidden');
55 | DOMElements.logoutModal.classList.remove('visible');
56 | DOMElements.errorContainer.classList.add('visible');
57 | }
58 |
59 | function cleanupListeners() {
60 | state.unsubscribeListeners.forEach(unsubscribe => unsubscribe());
61 | state.unsubscribeListeners = [];
62 | if (state.pinExpirationTimer) clearTimeout(state.pinExpirationTimer);
63 | if (state.contentTimer) clearTimeout(state.contentTimer);
64 | stopPlaylist();
65 | if (state.connectionRef) {
66 | remove(state.connectionRef); // Cleanly remove presence on major state changes
67 | state.connectionRef = null;
68 | }
69 | }
70 |
71 | function displayContent(content) {
72 | const url = content?.data?.url || null;
73 | const visibleSlot = DOMElements.imageSlots[state.visibleImageSlot];
74 | const hiddenSlot = DOMElements.imageSlots[1 - state.visibleImageSlot];
75 |
76 | for (let i = 0; i < DOMElements.imageSlots.length; i++) {
77 | if (DOMElements.imageSlots[i].src === url && url !== null) {
78 | if (i !== state.visibleImageSlot) {
79 | DOMElements.imageSlots[state.visibleImageSlot].classList.remove('visible');
80 | DOMElements.imageSlots[i].classList.add('visible');
81 | state.visibleImageSlot = i;
82 | }
83 | return;
84 | }
85 | }
86 |
87 | if (!url) {
88 | visibleSlot.classList.remove('visible');
89 | return;
90 | }
91 |
92 | hiddenSlot.src = url;
93 | hiddenSlot.onload = () => {
94 | visibleSlot.classList.remove('visible');
95 | hiddenSlot.classList.add('visible');
96 | state.visibleImageSlot = 1 - state.visibleImageSlot;
97 | hiddenSlot.onload = null;
98 | };
99 | hiddenSlot.onerror = () => {
100 | console.error(`[Display] Failed to load image: ${url}`);
101 | hiddenSlot.onload = null;
102 | hiddenSlot.onerror = null;
103 | };
104 | }
105 |
106 | function stopPlaylist() {
107 | if (state.playlistTimer) clearTimeout(state.playlistTimer);
108 | state.playlistTimer = null;
109 | state.currentPlaylist = null;
110 | state.currentPlaylistIndex = 0;
111 | if (state.playlistUnsubscribe) {
112 | state.playlistUnsubscribe();
113 | state.playlistUnsubscribe = null;
114 | }
115 | }
116 |
117 | function playNextInPlaylist(sessionId) {
118 | if (sessionId !== state.contentSessionId) return;
119 | if (!state.currentPlaylist || state.currentPlaylist.items.length === 0) {
120 | stopPlaylist();
121 | displayContent(null);
122 | return;
123 | }
124 | if (state.currentPlaylistIndex >= state.currentPlaylist.items.length) {
125 | state.currentPlaylistIndex = 0;
126 | }
127 |
128 | const currentItem = state.currentPlaylist.items[state.currentPlaylistIndex];
129 | const duration = (currentItem.duration || 10) * 1000;
130 |
131 | displayContent({ type: 'image', data: currentItem.media });
132 |
133 | state.currentPlaylistIndex++;
134 | state.playlistTimer = setTimeout(() => playNextInPlaylist(sessionId), duration);
135 | }
136 |
137 | function startPlaylist(playlistId, sessionId) {
138 | stopPlaylist();
139 | const playlistRef = doc(db, 'playlists', playlistId);
140 |
141 | state.playlistUnsubscribe = onSnapshot(playlistRef, (docSnap) => {
142 | if (sessionId !== state.contentSessionId) {
143 | stopPlaylist();
144 | return;
145 | }
146 |
147 | if (state.playlistTimer) clearTimeout(state.playlistTimer);
148 |
149 | if (docSnap.exists()) {
150 | state.currentPlaylist = docSnap.data();
151 | if (state.currentPlaylistIndex >= state.currentPlaylist.items.length) {
152 | state.currentPlaylistIndex = 0;
153 | }
154 | playNextInPlaylist(sessionId);
155 | } else {
156 | stopPlaylist();
157 | evaluateAndDisplay();
158 | }
159 | }, (error) => {
160 | console.error("Error listening to playlist:", error);
161 | stopPlaylist();
162 | });
163 | }
164 |
165 | function evaluateAndDisplay() {
166 | state.contentSessionId++;
167 | const currentSessionId = state.contentSessionId;
168 | if (state.contentTimer) clearTimeout(state.contentTimer);
169 | stopPlaylist();
170 |
171 | const now = new Date();
172 | let contentToShow = null;
173 | let nextChangeTime = null;
174 |
175 | const activeSchedule = state.schedules.find(s => s.startTime.toDate() <= now && s.endTime.toDate() > now);
176 |
177 | if (activeSchedule) {
178 | contentToShow = activeSchedule.content;
179 | nextChangeTime = activeSchedule.endTime.toDate();
180 | } else {
181 | contentToShow = state.screenDefaultContent || state.globalDefaultContent || null;
182 | const upcomingSchedules = state.schedules.filter(s => s.startTime.toDate() > now);
183 | if (upcomingSchedules.length > 0) {
184 | nextChangeTime = new Date(Math.min(...upcomingSchedules.map(s => s.startTime.toDate())));
185 | }
186 | }
187 |
188 | if (contentToShow) {
189 | if (contentToShow.type === 'playlist') {
190 | startPlaylist(contentToShow.data.id, currentSessionId);
191 | } else {
192 | displayContent(contentToShow);
193 | }
194 | } else {
195 | displayContent(null);
196 | }
197 |
198 | if (nextChangeTime) {
199 | const delay = nextChangeTime.getTime() - now.getTime();
200 | if (delay > 0) {
201 | state.contentTimer = setTimeout(evaluateAndDisplay, delay + 500);
202 | }
203 | }
204 | }
205 |
206 | function initializeAuthenticatedScreen(screenId) {
207 | cleanupListeners();
208 | state.pairingAttemptCount = 0;
209 | state.screenId = screenId;
210 | DOMElements.pairingContainer.classList.add('hidden');
211 | DOMElements.errorContainer.classList.remove('visible');
212 |
213 | const onDataUpdate = () => evaluateAndDisplay();
214 |
215 | const screenUnsub = onSnapshot(doc(db, 'screens', screenId), (docSnap) => {
216 | if (docSnap.exists()) {
217 | const screenData = docSnap.data();
218 | if (!state.adminUid) {
219 | state.adminUid = screenData.adminUid;
220 | listenForSettings(state.adminUid, onDataUpdate);
221 | setupPresence(state.adminUid, state.screenId);
222 | }
223 | state.screenDefaultContent = screenData.defaultContent || null;
224 | onDataUpdate();
225 | } else {
226 | showError("This screen has been deleted.");
227 | signOut(auth);
228 | }
229 | });
230 |
231 | const scheduleQuery = query(collection(db, 'schedules'), where('screenIds', 'array-contains', screenId));
232 | const scheduleUnsub = onSnapshot(scheduleQuery, (snapshot) => {
233 | state.schedules = snapshot.docs.map(d => ({ id: d.id, ...d.data() }));
234 | onDataUpdate();
235 | });
236 |
237 | state.unsubscribeListeners.push(screenUnsub, scheduleUnsub);
238 | }
239 |
240 | function listenForSettings(userId, callback) {
241 | const settingsUnsub = onSnapshot(doc(db, `users/${userId}/settings`, 'main'), (docSnap) => {
242 | state.globalDefaultContent = docSnap.exists() ? (docSnap.data().globalDefaultContent || null) : null;
243 | callback();
244 | });
245 | state.unsubscribeListeners.push(settingsUnsub);
246 | }
247 |
248 | async function startPairingProcess() {
249 | cleanupListeners();
250 | Object.assign(state, { screenId: null, adminUid: null, contentSessionId: 0 });
251 | DOMElements.errorContainer.classList.remove('visible');
252 | DOMElements.pairingContainer.classList.remove('hidden');
253 | state.pairingAttemptCount++;
254 |
255 | if (state.pairingAttemptCount > MAX_AUTO_ATTEMPTS) {
256 | DOMElements.pairingBox.classList.add('stale-session-active');
257 | return;
258 | }
259 | DOMElements.pairingBox.classList.remove('stale-session-active');
260 | DOMElements.pairingStatusText.textContent = "Requesting pairing code...";
261 |
262 | try {
263 | const result = await httpsCallable(functions, 'generatePairingCode')();
264 | const { pin, pairingSessionId } = result.data;
265 | DOMElements.pinDisplay.textContent = `${pin.slice(0, 3)} ${pin.slice(3, 6)}`;
266 | DOMElements.pairingStatusText.textContent = "Waiting for admin to confirm...";
267 | state.pinExpirationTimer = setTimeout(() => { startPairingProcess(); }, 5 * 60 * 1000);
268 |
269 | const unsub = onSnapshot(doc(db, 'pairingRequests', pairingSessionId), async (docSnap) => {
270 | if (docSnap.exists() && docSnap.data().status === 'completed') {
271 | DOMElements.pairingStatusText.textContent = "Pairing complete. Authenticating...";
272 | unsub();
273 | if(state.pinExpirationTimer) clearTimeout(state.pinExpirationTimer);
274 | await signInWithCustomToken(auth, docSnap.data().customToken);
275 | }
276 | });
277 | state.unsubscribeListeners.push(unsub);
278 | } catch (error) {
279 | showError("Could not start pairing process. Refresh.");
280 | }
281 | }
282 |
283 | function setupPresence(userId, screenId) {
284 | const connectionsListRef = ref(rtdb, `/connections/${userId}/${screenId}`);
285 |
286 | onValue(ref(rtdb, '.info/connected'), (snap) => {
287 | if (snap.val() !== true) {
288 | // If we lose connection, the onDisconnect hook will handle cleanup.
289 | // If there's a current connectionRef, we assume it's now stale.
290 | if (state.connectionRef) {
291 | remove(state.connectionRef);
292 | state.connectionRef = null;
293 | }
294 | return;
295 | }
296 |
297 | // We are connected. Create a new, unique connection entry.
298 | state.connectionRef = push(connectionsListRef);
299 |
300 | // When this specific connection is severed, remove only its own entry.
301 | onDisconnect(state.connectionRef).remove();
302 |
303 | // Set the connection entry to true to mark our presence.
304 | set(state.connectionRef, true);
305 | console.log(`[Presence] Established connection with ID: ${state.connectionRef.key}`);
306 | });
307 | }
308 |
309 | function handleLogoutPressStart() {
310 | if (auth.currentUser) {
311 | state.logoutPressTimer = setTimeout(() => DOMElements.logoutModal.classList.add('visible'), 2000);
312 | }
313 | }
314 | function handleLogoutPressEnd() { clearTimeout(state.logoutPressTimer); }
315 |
316 | function init() {
317 | setPersistence(auth, browserLocalPersistence)
318 | .then(() => onAuthStateChanged(auth, (user) => user ? initializeAuthenticatedScreen(user.uid) : startPairingProcess()))
319 | .catch((error) => { showError("Could not enable persistence."); });
320 |
321 | document.body.addEventListener('pointerdown', handleLogoutPressStart);
322 | document.body.addEventListener('pointerup', handleLogoutPressEnd);
323 | document.body.addEventListener('pointerleave', handleLogoutPressEnd);
324 |
325 | DOMElements.cancelLogoutBtn.addEventListener('click', () => DOMElements.logoutModal.classList.remove('visible'));
326 | DOMElements.confirmLogoutBtn.addEventListener('click', async () => {
327 | DOMElements.logoutModal.classList.remove('visible');
328 | if (state.connectionRef) {
329 | await remove(state.connectionRef);
330 | state.connectionRef = null;
331 | }
332 | signOut(auth);
333 | });
334 | DOMElements.generateNewCodeBtn.addEventListener('click', () => {
335 | state.pairingAttemptCount = 0;
336 | startPairingProcess();
337 | });
338 | }
339 |
340 | init();
--------------------------------------------------------------------------------