first commit

This commit is contained in:
Fabio 2025-12-23 16:48:22 +01:00
commit 25de9638dd
30 changed files with 4541 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# Dashboard per tutte le mie applicazioni
entrare in frontend: installare e fare la build
```sh
cd /frontend
npm ci install
npm run build
cd ..
```
entrare nella backend e installare
```sh
cd /backend
npm ci install
```
editare il file app.json con host e port
vedi esempio apps1.json
facendo partire il server il file diventerà come apps.json
infine far partire il server
```sh
npm start
```
se si vogliono fare delle prove di creazione del file apps.json si può copiare apps1.json
```sh
cp apps1.json apps.json
```

226
backend/apps.json Normal file
View file

@ -0,0 +1,226 @@
[
{
"host": "192.168.1.4",
"port": 3100,
"name": "Forgejo: Beyo..",
"icon": "http://192.168.1.4:3100/assets/img/favicon.svg",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 9000,
"name": "Portainer",
"icon": "http://192.168.1.4:9000/63a301f0574f1a696ce6.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 5557,
"name": "no_name",
"icon": "./default.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 2222,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 5558,
"name": "@Joxit",
"icon": "./default.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8123,
"name": "Home Assistant",
"icon": "http://192.168.1.4:8123/static/icons/favicon.ico",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 3333,
"name": "PairDrop",
"icon": "http://192.168.1.4:3333/images/favicon-96x96.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8889,
"name": "assets",
"icon": "http://192.168.1.4:8889/filebrowser/static/img/icons/favicon-32x32.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 4444,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 32400,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 13378,
"name": "Audiobookshelf",
"icon": "http://192.168.1.4:13378/audiobookshelf/favicon.ico",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8083,
"name": "Calibre-Web |..",
"icon": "http://192.168.1.4:8083/static/favicon.ico",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 3222,
"name": "Gitea: Git wi..",
"icon": "http://192.168.1.4:3222/assets/img/favicon.svg",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8111,
"name": "gpx.studio",
"icon": "http://192.168.1.4:8111/android-icon-192x192.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 17777,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8112,
"name": "Favycon",
"icon": "http://192.168.1.4:8112/favicon-192x192.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8113,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8115,
"name": "TileServer GL..",
"icon": "./default.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8116,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 4533,
"name": "Navidrome",
"icon": "http://192.168.1.4:4533/android-chrome-192x192.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 2342,
"name": "PhotoPrism",
"icon": "http://192.168.1.4:2342/static/icons/app/1024.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8888,
"name": "Photoview",
"icon": "http://192.168.1.4:8888/photoview-logo.svg",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 3000,
"name": "hass-addon-fe",
"icon": "http://192.168.1.4:3000/favicon.ico",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 3300,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8006,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8200,
"name": "Online Markdo..",
"icon": "http://192.168.1.4:8200/apple-touch-icon.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 51929,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 9900,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 6080,
"name": "noVNC",
"icon": "http://192.168.1.4:6080/app/images/icons/novnc-ios-180.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8840,
"name": "WatchYourLAN",
"icon": "http://192.168.1.4:8840/fs/public/favicon.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 4000,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 2322,
"name": "no_name",
"icon": "/icons/error.png",
"controllato": true
}
]

30
backend/apps.json.orig Normal file
View file

@ -0,0 +1,30 @@
[
{
"name": "Portainer",
"host": "192.168.1.3",
"port": 9000,
"icon": "http://192.168.1.3:9000/b1f2baef7b5736909c25.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 8112,
"name": "Favycon",
"icon": "http://192.168.1.4:8112/favicon-192x192.png",
"controllato": true
},
{
"host": "192.168.1.4",
"port": 3100,
"name": "Forgejo",
"icon": "http://192.168.1.4:3100/assets/img/favicon.svg",
"controllato": true
},
{
"host": "192.168.1.3",
"port": 8081,
"name": "mongoDB",
"icon": "/icons/error.png",
"controllato": true
}
]

161
backend/apps1.json Normal file
View file

@ -0,0 +1,161 @@
[
{
"host": "192.168.1.4",
"port": 3100
},
{
"host": "192.168.1.4",
"port": 9000
},
{
"host": "192.168.1.4",
"port": 5557
},
{
"host": "192.168.1.4",
"port": 2222
},
{
"host": "192.168.1.4",
"port": 5558
},
{
"host": "192.168.1.4",
"port": 8123
},
{
"host": "192.168.1.4",
"port": 3333
},
{
"host": "192.168.1.4",
"port": 8889
},
{
"host": "192.168.1.4",
"port": 4444
},
{
"host": "192.168.1.4",
"port": 32400
},
{
"host": "192.168.1.4",
"port": 13378
},
{
"host": "192.168.1.4",
"port": 8083
},
{
"host": "192.168.1.4",
"port": 3222
},
{
"host": "192.168.1.4",
"port": 8111
},
{
"host": "192.168.1.4",
"port": 17777
},
{
"host": "192.168.1.4",
"port": 8112
},
{
"host": "192.168.1.4",
"port": 8113
},
{
"host": "192.168.1.4",
"port": 8115
},
{
"host": "192.168.1.4",
"port": 8116
},
{
"host": "192.168.1.4",
"port": 4533
},
{
"host": "192.168.1.4",
"port": 2342
},
{
"host": "192.168.1.4",
"port": 8888
},
{
"host": "192.168.1.4",
"port": 3000
},
{
"host": "192.168.1.4",
"port": 3300
},
{
"host": "192.168.1.4",
"port": 8006
},
{
"host": "192.168.1.4",
"port": 8200
},
{
"host": "192.168.1.4",
"port": 51929
},
{
"host": "192.168.1.4",
"port": 9900
},
{
"host": "192.168.1.4",
"port": 6080
},
{
"host": "192.168.1.4",
"port": 8840
},
{
"host": "192.168.1.4",
"port": 4000
},
{
"host": "192.168.1.4",
"port": 2322
}
]

87
backend/i.js Normal file
View file

@ -0,0 +1,87 @@
// checkApps.js
import fs from "fs";
import path from "path";
import * as cheerio from "cheerio";
import axios from "axios";
const appsFile = path.join(process.cwd(), "apps.json");
function getShortName($) {
return (
$('meta[property="og:site_name"]').attr('content') ||
$('meta[name="application-name"]').attr('content') ||
$('meta[property="og:title"]').attr('content') ||
$("title").text().trim() ||
null
);
}
async function findBestIconAndName(baseUrl) {
try {
const res = await axios.get(baseUrl, { timeout: 2000 });
const $ = cheerio.load(res.data);
//let appName = $("title").text().trim() || null;
let appName = getShortName($) || null;
const icons = [];
$("link[rel*='icon']").each((_, el) => {
const href = $(el).attr("href");
const sizes = $(el).attr("sizes") || "";
if (href) icons.push({ href, sizes });
});
icons.sort((a, b) => {
const sizeA = parseInt(a.sizes.split("x")[0]) || 0;
const sizeB = parseInt(b.sizes.split("x")[0]) || 0;
return sizeB - sizeA;
});
let chosenIcon;
if (icons.length > 0) {
chosenIcon = new URL(icons[0].href, baseUrl).href;
} else {
chosenIcon = "./default.png";
}
// Se non cè nome, metti "no_name"
return { name: appName || "no_name", icon: chosenIcon };
} catch {
return { name: "no_name", icon: "/icons/error.png" };
}
}
async function main() {
const apps = JSON.parse(fs.readFileSync(appsFile, "utf-8"));
for (const app of apps) {
if (app.controllato) {
console.log(`Skip ${app.name} (${app.host}:${app.port}) già controllato`);
continue;
}
const baseUrl = `http://${app.host}:${app.port}`;
console.log(`Controllo ${baseUrl}...`);
const result = await findBestIconAndName(baseUrl);
// Aggiorna nome (anche se "no_name")
app.name = result.name;
// Aggiorna icona
app.icon = result.icon;
// Flag di controllo
app.controllato = true;
}
fs.writeFileSync(appsFile, JSON.stringify(apps, null, 2));
console.log("apps.json aggiornato ✅");
}
main().catch((err) => {
console.error("Errore:", err);
process.exit(1);
});

1330
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

15
backend/package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "dashboard-backend",
"version": "1.0.0",
"description": "Express server per servire apps.json e frontend build",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.13.2",
"cheerio": "^1.1.2",
"express": "^4.19.2"
}
}

32
backend/server.js Normal file
View file

@ -0,0 +1,32 @@
import express from "express";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import updateLink from "./updatelink.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 4000;
updateLink().catch((err) => {
console.error("Errore:", err);
});
// Serve lista app da apps.json
app.get("/apps", (req, res) => {
const apps = JSON.parse(fs.readFileSync(path.join(__dirname, "apps.json")));
res.json(apps);
});
// Serve frontend build
app.use(express.static(path.join(__dirname, "../frontend/dist")));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "../frontend/dist/index.html"));
});
app.listen(PORT, () => {
console.log(`Dashboard running on http://localhost:${PORT}`);
});

23
backend/server.js.old Normal file
View file

@ -0,0 +1,23 @@
const express = require("express");
const path = require("path");
const fs = require("fs");
const app = express();
const PORT = 4000;
// Serve lista app da apps.json
app.get("/apps", (req, res) => {
const apps = JSON.parse(fs.readFileSync(path.join(__dirname, "apps.json")));
res.json(apps);
});
// Serve frontend build
app.use(express.static(path.join(__dirname, "../frontend/dist")));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "../frontend/dist/index.html"));
});
app.listen(PORT, () => {
console.log(`Dashboard running on http://localhost:${PORT}`);
});

122
backend/updatelink.js Normal file
View file

@ -0,0 +1,122 @@
// updatelink.js
import fs from "fs";
import path from "path";
import * as cheerio from "cheerio";
import axios from "axios";
const appsFile = path.join(process.cwd(), "apps.json");
/*function getShortName($) {
return (
$('meta[property="og:site_name"]').attr("content") ||
$('meta[name="application-name"]').attr("content") ||
$('meta[property="og:title"]').attr("content") ||
$("title").text().trim() ||
null
);
}
*/
function limitText(str,n) {
if (!str) return "";
if (str.length <= n) {
return str;
}
return str.slice(0, n-2) + "..";
}
function getShortName($) {
const candidates = [
/* $('meta[property="og:site_name"]').attr("content"),
$('meta[name="application-name"]').attr("content"),
$('meta[property="og:title"]').attr("content"),
$("title").text().trim(),
*/
$('meta[property="og:site_name"]').attr("content"),
$('meta[name="application-name"]').attr("content"),
$('meta[property="og:title"]').attr("content"),
$('meta[name="apple-mobile-web-app-title"]').attr("content"),
$('meta[name="twitter:title"]').attr("content"),
$('meta[name="twitter:site"]').attr("content"),
$("title").text().trim(),
];
// Filtra solo stringhe non nulle e non vuote
const valid = candidates.filter(s => s && s.trim().length > 0);
if (valid.length === 0) return null;
// Ordina per lunghezza crescente e prende la prima
valid.sort((a, b) => a.length - b.length);
return limitText(valid[0],15);
}
function consolelogName($) {
console.log($('meta[property="og:site_name"]').attr("content"));
console.log($('meta[name="application-name"]').attr("content"));
console.log($('meta[property="og:title"]').attr("content"));
console.log($('meta[name="apple-mobile-web-app-title"]').attr("content"));
console.log($('meta[name="twitter:title"]').attr("content"));
console.log($('meta[name="twitter:site"]').attr("content"));
console.log($("title").text().trim());
}
async function findBestIconAndName(baseUrl) {
try {
const res = await axios.get(baseUrl, { timeout: 2000 });
const $ = cheerio.load(res.data);
let appName = getShortName($) || null;
consolelogName($);
const icons = [];
$("link[rel*='icon']").each((_, el) => {
const href = $(el).attr("href");
const sizes = $(el).attr("sizes") || "";
if (href) icons.push({ href, sizes });
});
icons.sort((a, b) => {
const sizeA = parseInt(a.sizes.split("x")[0]) || 0;
const sizeB = parseInt(b.sizes.split("x")[0]) || 0;
return sizeB - sizeA;
});
let chosenIcon;
if (icons.length > 0) {
chosenIcon = new URL(icons[0].href, baseUrl).href;
} else {
chosenIcon = "./default.png";
}
return { name: appName || "no_name", icon: chosenIcon };
} catch {
return { name: "no_name", icon: "/icons/error.png" };
}
}
/**
* Funzione principale che aggiorna apps.json
*/
export default async function updateLink() {
const apps = JSON.parse(fs.readFileSync(appsFile, "utf-8"));
for (const app of apps) {
if (app.controllato) {
console.log(`Skip ${app.name} (${app.host}:${app.port}) già controllato`);
continue;
}
const baseUrl = `http://${app.host}:${app.port}`;
console.log(`Controllo ${baseUrl}...`);
const result = await findBestIconAndName(baseUrl);
app.name = result.name;
app.icon = result.icon;
app.controllato = true;
}
fs.writeFileSync(appsFile, JSON.stringify(apps, null, 2));
console.log("apps.json aggiornato ✅");
}

40
frontend/dist/assets/index-COhNAj5f.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
.app-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:24px;padding:12px}.app-card{display:flex;flex-direction:column;align-items:center;cursor:pointer;padding:16px;border-radius:16px;background-color:#f5f5f5;transition:background-color .2s ease}.app-card:hover{background-color:#e0e0e0}.app-icon{width:160px;height:160px;object-fit:contain;margin-bottom:12px}@media (min-width: 1024px){.app-icon{width:70px;height:70px}}.app-name{font-weight:600;font-size:2rem;text-align:center}@media (min-width: 1024px){.app-name{font-size:1rem}}@tailwind base;@tailwind components;@tailwind utilities;

BIN
frontend/dist/icons/error.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

13
frontend/dist/index.html vendored Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LAN Dashboard</title>
<script type="module" crossorigin src="/assets/index-COhNAj5f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DQwn-aJw.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

12
frontend/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LAN Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

BIN
frontend/not-applicable.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

2101
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
frontend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "dashboard-frontend",
"version": "1.0.0",
"description": "React + Vite dashboard per visualizzare app locali",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@mui/material": "^7.3.6",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"vite": "^5.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

74
frontend/src/App.css Normal file
View file

@ -0,0 +1,74 @@
/* Griglia adattiva */
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 24px;
padding: 12px;
}
/*@media (min-width: 1200px) {
.app-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 24px;
padding: 32px;
}
*/
/* Card stile folder */
.app-card {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 16px;
border-radius: 16px;
background-color: #f5f5f5;
transition: background-color 0.2s ease;
}
.app-card:hover {
background-color: #e0e0e0;
}
/* Icone raddoppiate
.app-icon {
width: 160px;
height: 160px;
object-fit: contain;
margin-bottom: 12px;
}
*/
.app-icon {
width: 160px;
height: 160px;
object-fit: contain;
margin-bottom: 12px;
}
@media (min-width: 1024px) {
.app-icon {
width: 70px;
height: 70px;
}
}
/* Nome raddoppiato e bold
.app-name {
font-weight: 600;
font-size: 2rem;
text-align: center;
}
*/
.app-name {
font-weight: 600;
font-size: 2rem; /* circa 32px */
text-align: center;
}
@media (min-width: 1024px) {
.app-name {
font-size: 1rem; /* circa 19px */
}
}

30
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,30 @@
import React, { useEffect, useState } from "react";
import "./App.css"; // importiamo lo stile CSS
function AppGrid() {
const [apps, setApps] = useState([]);
useEffect(() => {
fetch("/apps")
.then(res => res.json())
.then(data => setApps(Array.isArray(data) ? data : []))
.catch(() => setApps([]));
}, []);
return (
<div className="app-grid">
{apps.map(app => (
<div
key={app.name}
className="app-card"
onClick={() => window.open(`http://${app.host}:${app.port}`, "_blank")}
>
<img src={app.icon} alt={app.name} className="app-icon" />
<div className="app-name">{app.name}</div>
</div>
))}
</div>
);
}
export default AppGrid;

48
frontend/src/App.jsx.old Normal file
View file

@ -0,0 +1,48 @@
import React, { useEffect, useState } from "react";
function AppGrid() {
const [apps, setApps] = useState([]);
useEffect(() => {
fetch("/apps")
.then(res => res.json())
.then(data => setApps(data));
}, []);
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: "20px",
padding: "20px"
}}
>
{apps.map(app => (
<div
key={app.name}
style={{
textAlign: "center",
cursor: "pointer",
border: "1px solid #ddd",
padding: "10px"
}}
onClick={() => window.open(`http://${app.host}:${app.port}`, "_blank")}
>
{/* Se app.icon è un URL o un path locale */}
<img
src={app.icon}
alt={app.name}
style={{ width: "40px", height: "40px", objectFit: "contain" }}
/>
<div style={{ fontWeight: "bold" }}>{app.name}</div>
<div style={{ fontSize: "12px", color: "gray" }}>
{app.host}:{app.port}
</div>
</div>
))}
</div>
);
}
export default AppGrid;

45
frontend/src/App.jsx.old1 Normal file
View file

@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";
function AppGrid() {
const [apps, setApps] = useState([]);
useEffect(() => {
fetch("/apps")
.then(res => res.json())
.then(data => setApps(data));
}, []);
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: "10px",
padding: "10px"
}}
>
{apps.map(app => (
<div
key={app.name}
style={{
textAlign: "center",
cursor: "pointer",
padding: "10px" // niente border
}}
onClick={() => window.open(`http://${app.host}:${app.port}`, "_blank")}
>
{/* Icona grande il doppio */}
<img
src={app.icon}
alt={app.name}
style={{ width: "80px", height: "80px", objectFit: "contain" }}
/>
{/* Nome sotto l'icona */}
<div style={{ fontWeight: "bold", marginTop: "8px" }}>{app.name}</div>
</div>
))}
</div>
);
}
export default AppGrid;

45
frontend/src/App.jsx.old2 Normal file
View file

@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";
function AppGrid() {
const [apps, setApps] = useState([]);
useEffect(() => {
fetch("/apps")
.then(res => res.json())
.then(data => setApps(data));
}, []);
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
gap: "10px",
padding: "10px"
}}
>
{apps.map(app => (
<div
key={app.name}
style={{
textAlign: "center",
cursor: "pointer",
padding: "10px" // niente border
}}
onClick={() => window.open(`http://${app.host}:${app.port}`, "_blank")}
>
{/* Icona grande il doppio */}
<img
src={app.icon}
alt={app.name}
style={{ width: "80px", height: "80px", objectFit: "contain" }}
/>
{/* Nome sotto l'icona */}
<div style={{ fontWeight: "bold", marginTop: "8px" }}>{app.name}</div>
</div>
))}
</div>
);
}
export default AppGrid;

BIN
frontend/src/errore.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

3
frontend/src/index.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import AppGrid from "./App.jsx";
import "./index.css"; // importa Tailwind
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<AppGrid />
</React.StrictMode>
);

View file

@ -0,0 +1,25 @@
// tailwind.config.js
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}"
],
theme: {
extend: {
screens: {
xs: "480px", // breakpoint extra per smartphone piccoli
sm: "640px", // tablet piccoli
md: "768px", // tablet medi
lg: "1024px", // laptop
xl: "1280px", // desktop grandi
},
spacing: {
18: "4.5rem", // utile per padding/margin extra
},
colors: {
brand: "#1e40af", // blu personalizzato per titoli o hover
},
},
},
plugins: [],
}

9
frontend/vite.config.js Normal file
View file

@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist"
}
});