WebAuthn biometric + PWA widget + UI header fixes + mobile responsive
- WebAuthn: register/auth options, device management - PWA widget page + manifest shortcuts - Group schedule endpoint (timezones + locations) - UI #3-#6: compact headers on tasks/calendar/projects/goals - UI #9: mobile responsive top bars - webauthn_credentials table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
187
api/package-lock.json
generated
187
api/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@fastify/swagger-ui": "^5.2.5",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@simplewebauthn/server": "^12.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
@@ -389,12 +390,24 @@
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
@@ -404,6 +417,64 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
|
||||
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz",
|
||||
"integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz",
|
||||
"integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz",
|
||||
"integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -479,6 +550,33 @@
|
||||
"@redis/client": "^5.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-12.0.0.tgz",
|
||||
"integrity": "sha512-aJdTe9GikOk40U7Q5Mm/Sqkxcq4a2oPZAcLcnyqMyFqrUaOS6vdsZW8/H3Mnsw9umcr88pcgB7kozPPt+5wOBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"cross-fetch": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abstract-logging": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||
@@ -553,6 +651,20 @@
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -733,6 +845,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
|
||||
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1425,6 +1546,26 @@
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
@@ -1813,6 +1954,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
@@ -2163,12 +2322,24 @@
|
||||
"nodetouch": "bin/nodetouch.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uid2": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
|
||||
@@ -2229,6 +2400,22 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@fastify/swagger": "^9.7.0",
|
||||
"@fastify/swagger-ui": "^5.2.5",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@simplewebauthn/server": "^12.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.3.1",
|
||||
|
||||
@@ -8,6 +8,7 @@ const features = [
|
||||
{ name: "kanban", path: "./kanban", prefix: "/api/v1" },
|
||||
{ name: "ai-briefing", path: "./ai-briefing", prefix: "/api/v1" },
|
||||
{ name: "webhooks-outgoing", path: "./webhooks-outgoing", prefix: "/api/v1" },
|
||||
{ name: "webauthn", path: "./webauthn", prefix: "/api/v1" },
|
||||
];
|
||||
|
||||
async function registerFeatures(app) {
|
||||
|
||||
64
api/src/features/webauthn.js
Normal file
64
api/src/features/webauthn.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Task Team — WebAuthn Biometric Auth
|
||||
// Note: Full WebAuthn needs @simplewebauthn/server, but we create the API structure
|
||||
async function webauthnFeature(app) {
|
||||
await app.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT UNIQUE NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER DEFAULT 0,
|
||||
device_name VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`).catch(() => {});
|
||||
|
||||
// Registration options
|
||||
app.post('/webauthn/register/options', async (req) => {
|
||||
const { user_id } = req.body;
|
||||
const { rows } = await app.db.query('SELECT id,email,name FROM users WHERE id=$1', [user_id]);
|
||||
if (!rows.length) throw { statusCode: 404, message: 'User not found' };
|
||||
return { data: {
|
||||
challenge: require('crypto').randomBytes(32).toString('base64url'),
|
||||
rp: { name: 'Task Team', id: 'tasks.hasdo.info' },
|
||||
user: { id: Buffer.from(rows[0].id).toString('base64url'), name: rows[0].email, displayName: rows[0].name },
|
||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }, { alg: -257, type: 'public-key' }],
|
||||
authenticatorSelection: { authenticatorAttachment: 'platform', userVerification: 'required' },
|
||||
timeout: 60000
|
||||
}};
|
||||
});
|
||||
|
||||
// Save registration
|
||||
app.post('/webauthn/register/verify', async (req) => {
|
||||
const { user_id, credential_id, public_key, device_name } = req.body;
|
||||
const { rows } = await app.db.query(
|
||||
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, device_name) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||
[user_id, credential_id, public_key, device_name || 'Biometric']);
|
||||
return { data: rows[0], status: 'registered' };
|
||||
});
|
||||
|
||||
// Auth options
|
||||
app.post('/webauthn/auth/options', async (req) => {
|
||||
const { user_id } = req.body;
|
||||
const { rows } = await app.db.query('SELECT credential_id FROM webauthn_credentials WHERE user_id=$1', [user_id]);
|
||||
return { data: {
|
||||
challenge: require('crypto').randomBytes(32).toString('base64url'),
|
||||
allowCredentials: rows.map(r => ({ id: r.credential_id, type: 'public-key' })),
|
||||
timeout: 60000, userVerification: 'required'
|
||||
}};
|
||||
});
|
||||
|
||||
// List user's biometric devices
|
||||
app.get('/webauthn/devices/:userId', async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
'SELECT id, device_name, created_at FROM webauthn_credentials WHERE user_id=$1', [req.params.userId]);
|
||||
return { data: rows };
|
||||
});
|
||||
|
||||
// Remove device
|
||||
app.delete('/webauthn/devices/:id', async (req) => {
|
||||
await app.db.query('DELETE FROM webauthn_credentials WHERE id=$1', [req.params.id]);
|
||||
return { status: 'deleted' };
|
||||
});
|
||||
}
|
||||
module.exports = webauthnFeature;
|
||||
@@ -35,12 +35,16 @@ async function groupRoutes(app) {
|
||||
});
|
||||
|
||||
app.put('/groups/:id', async (req) => {
|
||||
const { name, color, icon, order_index, time_zones } = req.body;
|
||||
const { name, color, icon, order_index, time_zones, locations } = req.body;
|
||||
const { rows } = await app.db.query(
|
||||
`UPDATE task_groups SET name=COALESCE($1,name), color=COALESCE($2,color), icon=COALESCE($3,icon),
|
||||
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones), updated_at=NOW()
|
||||
WHERE id=$6 RETURNING *`,
|
||||
[name, color, icon, order_index, time_zones ? JSON.stringify(time_zones) : null, req.params.id]
|
||||
order_index=COALESCE($4,order_index), time_zones=COALESCE($5,time_zones),
|
||||
locations=COALESCE($6,locations), updated_at=NOW()
|
||||
WHERE id=$7 RETURNING *`,
|
||||
[name, color, icon, order_index,
|
||||
time_zones ? JSON.stringify(time_zones) : null,
|
||||
locations ? JSON.stringify(locations) : null,
|
||||
req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||
return { data: rows[0] };
|
||||
@@ -111,6 +115,27 @@ async function groupRoutes(app) {
|
||||
return { data: rows[0] };
|
||||
});
|
||||
|
||||
// Combined schedule: time zones + locations for a group
|
||||
app.get('/groups/:id/schedule', async (req) => {
|
||||
const { rows } = await app.db.query(
|
||||
'SELECT id, name, time_zones, locations FROM task_groups WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) throw { statusCode: 404, message: 'Group not found' };
|
||||
const g = rows[0];
|
||||
return {
|
||||
data: {
|
||||
group_id: g.id,
|
||||
group_name: g.name,
|
||||
time_zones: g.time_zones || [],
|
||||
locations: g.locations || [],
|
||||
summary: {
|
||||
tz_count: (g.time_zones || []).length,
|
||||
loc_count: (g.locations || []).length
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = groupRoutes;
|
||||
|
||||
@@ -63,25 +63,21 @@ export default function CalendarPage() {
|
||||
extendedProps: { status: tk.status, group: tk.group_name },
|
||||
}));
|
||||
|
||||
// Build background events from unique groups
|
||||
const groupColors = new Map<string, string>();
|
||||
tasks.forEach(tk => {
|
||||
if (tk.group_name && tk.group_color) {
|
||||
groupColors.set(tk.group_name, tk.group_color);
|
||||
}
|
||||
});
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">{t('calendar.title')}</h1>
|
||||
<div className="pb-24 sm:pb-8 px-4 sm:px-0">
|
||||
{/* Compact single-row header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<h1 className="text-xl font-bold dark:text-white truncate">{t('calendar.title')}</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-2 sm:p-4 shadow calendar-compact">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="timeGridWeek"
|
||||
|
||||
@@ -254,20 +254,39 @@ main {
|
||||
}
|
||||
|
||||
/* ============================
|
||||
FullCalendar Mobile Layout Fix
|
||||
FullCalendar Compact Layout
|
||||
============================ */
|
||||
|
||||
/* Base toolbar styles - compact */
|
||||
/* Single-row toolbar: date + nav + view switcher all in one line */
|
||||
.fc .fc-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
row-gap: 6px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
/* Title: prevent vertical text wrapping */
|
||||
/* Each toolbar chunk inline */
|
||||
.fc .fc-toolbar-chunk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Center chunk (title) takes remaining space, truncates */
|
||||
.fc .fc-toolbar-chunk:nth-child(2) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Title: compact, truncates */
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 18px !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -275,48 +294,17 @@ main {
|
||||
|
||||
/* Compact buttons */
|
||||
.fc .fc-button {
|
||||
padding: 4px 10px !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.4 !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
line-height: 1.3 !important;
|
||||
min-height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Button group: no extra gaps */
|
||||
/* Button group: pill shape */
|
||||
.fc .fc-button-group {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Mobile breakpoint: stack toolbar rows */
|
||||
@media (max-width: 640px) {
|
||||
.fc .fc-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-chunk {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Title on its own row, centered, smaller */
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 15px !important;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
/* Smaller buttons on mobile */
|
||||
.fc .fc-button {
|
||||
padding: 3px 8px !important;
|
||||
font-size: 12px !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
/* View switcher as pill buttons */
|
||||
.fc .fc-button-group .fc-button {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
@@ -327,6 +315,30 @@ main {
|
||||
border-radius: 0 9999px 9999px 0 !important;
|
||||
}
|
||||
|
||||
/* Mobile: tighten further but keep single row */
|
||||
@media (max-width: 640px) {
|
||||
.fc .fc-toolbar {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
padding: 3px 6px !important;
|
||||
font-size: 11px !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
|
||||
/* Hide text labels on smallest screens - show abbreviated */
|
||||
.fc .fc-dayGridMonth-button,
|
||||
.fc .fc-timeGridWeek-button,
|
||||
.fc .fc-timeGridDay-button {
|
||||
font-size: 10px !important;
|
||||
padding: 3px 5px !important;
|
||||
}
|
||||
|
||||
/* Reduce page padding */
|
||||
.fc {
|
||||
font-size: 12px;
|
||||
@@ -339,22 +351,26 @@ main {
|
||||
|
||||
/* Day header smaller */
|
||||
.fc .fc-col-header-cell-cushion {
|
||||
font-size: 12px;
|
||||
padding: 4px 2px;
|
||||
font-size: 11px;
|
||||
padding: 3px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small mobile (< 400px) - even tighter */
|
||||
@media (max-width: 400px) {
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 13px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
padding: 2px 6px !important;
|
||||
font-size: 11px !important;
|
||||
padding: 2px 4px !important;
|
||||
font-size: 10px !important;
|
||||
min-height: 28px !important;
|
||||
}
|
||||
|
||||
.fc .fc-today-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status dot pulse animation for in_progress tasks */
|
||||
@@ -366,3 +382,41 @@ main {
|
||||
.status-dot-active {
|
||||
animation: statusPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ============================
|
||||
Mobile Responsive Compact Headers (#9)
|
||||
============================ */
|
||||
|
||||
/* Ensure no horizontal overflow on any page */
|
||||
@media (max-width: 640px) {
|
||||
main {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Page action bars: single row, no wrap */
|
||||
.flex.items-center.justify-between {
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Touch targets minimum 44px on mobile */
|
||||
button,
|
||||
a[role='button'],
|
||||
[role='button'] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Exception: status pills and small inline elements */
|
||||
.scrollbar-hide button,
|
||||
.fc button {
|
||||
min-width: auto;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* Compact text on mobile */
|
||||
h1 {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@ import { useAuth } from "@/lib/auth";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { useTranslation, LOCALES } from "@/lib/i18n";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import type { Group } from "@/lib/api";
|
||||
|
||||
interface GroupSetting {
|
||||
from: string;
|
||||
to: string;
|
||||
days: number[];
|
||||
locationName: string;
|
||||
gps: string;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { token, user, logout } = useAuth();
|
||||
@@ -19,6 +29,10 @@ export default function SettingsPage() {
|
||||
dailySummary: false,
|
||||
});
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [groupSettings, setGroupSettings] = useState<Record<string, GroupSetting>>({});
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const [savedGroup, setSavedGroup] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -37,6 +51,84 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [token, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetch("/api/v1/groups", { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
const data: Group[] = res.data || [];
|
||||
setGroups(data);
|
||||
const settings: Record<string, GroupSetting> = {};
|
||||
for (const g of data) {
|
||||
const tz = g.time_zones?.[0];
|
||||
const loc = g.locations?.[0];
|
||||
settings[g.id] = {
|
||||
from: tz?.from || "",
|
||||
to: tz?.to || "",
|
||||
days: tz?.days || [],
|
||||
locationName: loc?.name || "",
|
||||
gps: (loc?.lat != null && loc?.lng != null) ? `${loc.lat}, ${loc.lng}` : "",
|
||||
radius: loc?.radius_m || 200,
|
||||
};
|
||||
}
|
||||
setGroupSettings(settings);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
setExpandedGroup(prev => prev === id ? null : id);
|
||||
}
|
||||
|
||||
function updateGroupSetting(groupId: string, key: keyof GroupSetting, value: string | number | number[]) {
|
||||
setGroupSettings(prev => ({
|
||||
...prev,
|
||||
[groupId]: { ...prev[groupId], [key]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleDay(groupId: string, day: number) {
|
||||
const current = groupSettings[groupId]?.days || [];
|
||||
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
|
||||
updateGroupSetting(groupId, "days", next);
|
||||
}
|
||||
|
||||
function getCurrentLocation(groupId: string) {
|
||||
if (!navigator.geolocation) return;
|
||||
navigator.geolocation.getCurrentPosition(pos => {
|
||||
const gps = `${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`;
|
||||
updateGroupSetting(groupId, "gps", gps);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveGroupSettings(groupId: string) {
|
||||
const s = groupSettings[groupId] || {} as GroupSetting;
|
||||
const timeZones = (s.from && s.to) ? [{
|
||||
days: s.days?.length ? s.days : [0, 1, 2, 3, 4, 5, 6],
|
||||
from: s.from,
|
||||
to: s.to,
|
||||
}] : [];
|
||||
|
||||
const gpsParts = (s.gps || "").split(",").map(x => parseFloat(x.trim()));
|
||||
const lat = gpsParts[0] || null;
|
||||
const lng = gpsParts[1] || null;
|
||||
const locations = s.locationName ? [{
|
||||
name: s.locationName,
|
||||
lat: isNaN(lat as number) ? null : lat,
|
||||
lng: isNaN(lng as number) ? null : lng,
|
||||
radius_m: Number(s.radius) || 200,
|
||||
}] : [];
|
||||
|
||||
await fetch(`/api/v1/groups/${groupId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ time_zones: timeZones, locations }),
|
||||
});
|
||||
|
||||
setSavedGroup(groupId);
|
||||
setTimeout(() => setSavedGroup(null), 2000);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("taskteam_notifications", JSON.stringify(notifications));
|
||||
@@ -193,6 +285,113 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups settings */}
|
||||
{groups.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 12, textTransform: "uppercase", letterSpacing: 1, fontWeight: 600 }}>
|
||||
Skupiny
|
||||
</div>
|
||||
{groups.map(group => (
|
||||
<div key={group.id} style={{
|
||||
background: "#13131A", border: `1px solid #2A2A3A`,
|
||||
borderLeft: `3px solid ${group.color || "#4F46E5"}`,
|
||||
borderRadius: 12, marginBottom: 8, overflow: "hidden",
|
||||
}}>
|
||||
<div onClick={() => toggleGroup(group.id)}
|
||||
style={{ padding: "12px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 18 }}>{group.icon || "📁"}</span>
|
||||
<span style={{ flex: 1, fontWeight: 500, fontSize: 14, color: "#F0F0F5" }}>
|
||||
{group.display_name || group.name}
|
||||
</span>
|
||||
<span style={{ color: "#6B6B85", fontSize: 12 }}>
|
||||
{group.time_zones?.[0] ? `${group.time_zones[0].from}–${group.time_zones[0].to}` : ""}
|
||||
</span>
|
||||
<span style={{ color: "#6B6B85", fontSize: 12 }}>{expandedGroup === group.id ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
|
||||
{expandedGroup === group.id && (
|
||||
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #2A2A3A" }}>
|
||||
{/* CAS AKTIVITY */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
Čas aktivity (volitelné)
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<input type="time" value={groupSettings[group.id]?.from || ""}
|
||||
onChange={e => updateGroupSetting(group.id, "from", e.target.value)}
|
||||
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
|
||||
/>
|
||||
<span style={{ color: "#6B6B85" }}>–</span>
|
||||
<input type="time" value={groupSettings[group.id]?.to || ""}
|
||||
onChange={e => updateGroupSetting(group.id, "to", e.target.value)}
|
||||
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
{/* DNY V TYDNU */}
|
||||
<div style={{ display: "flex", gap: 4, marginTop: 8 }}>
|
||||
{["Ne", "Po", "Út", "St", "Čt", "Pá", "So"].map((d, i) => {
|
||||
const active = (groupSettings[group.id]?.days || []).includes(i);
|
||||
return (
|
||||
<button key={i} onClick={() => toggleDay(group.id, i)} style={{
|
||||
flex: 1, padding: "6px 0", borderRadius: 6, fontSize: 11,
|
||||
border: `1px solid ${active ? (group.color || "#4F46E5") : "#2A2A3A"}`,
|
||||
background: active ? `${group.color || "#4F46E5"}20` : "transparent",
|
||||
color: active ? (group.color || "#4F46E5") : "#6B6B85",
|
||||
cursor: "pointer",
|
||||
}}>{d}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPS MISTO */}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 11, color: "#6B6B85", marginBottom: 6, textTransform: "uppercase", letterSpacing: 0.5 }}>
|
||||
Místo výkonu (volitelné)
|
||||
</div>
|
||||
<input
|
||||
placeholder="Název místa (např. Synagoga, Kancelář...)"
|
||||
value={groupSettings[group.id]?.locationName || ""}
|
||||
onChange={e => updateGroupSetting(group.id, "locationName", e.target.value)}
|
||||
style={{ width: "100%", padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14, marginBottom: 8, boxSizing: "border-box" }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
placeholder="GPS souřadnice (lat, lng)"
|
||||
value={groupSettings[group.id]?.gps || ""}
|
||||
onChange={e => updateGroupSetting(group.id, "gps", e.target.value)}
|
||||
style={{ flex: 1, padding: "8px", borderRadius: 8, border: "1px solid #2A2A3A", background: "#0A0A0F", color: "#F0F0F5", fontSize: 14 }}
|
||||
/>
|
||||
<button onClick={() => getCurrentLocation(group.id)}
|
||||
style={{ padding: "8px 12px", borderRadius: 8, border: "1px solid #1D4ED8", background: "#1D4ED820", color: "#60A5FA", cursor: "pointer", fontSize: 12, whiteSpace: "nowrap" }}>
|
||||
Moje GPS
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#6B6B85" }}>Polomer:</span>
|
||||
<input type="range" min="50" max="1000" step="50"
|
||||
value={groupSettings[group.id]?.radius || 200}
|
||||
onChange={e => updateGroupSetting(group.id, "radius", Number(e.target.value))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#9999AA", minWidth: 50 }}>
|
||||
{groupSettings[group.id]?.radius || 200}m
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ULOZIT */}
|
||||
<button onClick={() => saveGroupSettings(group.id)}
|
||||
style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 10, background: savedGroup === group.id ? "#16A34A" : "#1D4ED8", color: "white", border: "none", cursor: "pointer", fontSize: 14, fontWeight: 500, transition: "background 0.2s" }}>
|
||||
{savedGroup === group.id ? "Uloženo ✓" : "Uložit nastavení skupiny"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function TasksPage() {
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center",
|
||||
padding: "0 8px",
|
||||
position: "sticky", top: 44, zIndex: 40,
|
||||
position: "sticky", top: 40, zIndex: 40,
|
||||
height: 40, maxHeight: 40,
|
||||
background: "var(--background, #fff)",
|
||||
borderBottom: "1px solid var(--border, #e5e7eb)",
|
||||
@@ -216,7 +216,7 @@ export default function TasksPage() {
|
||||
{/* Status pills — horizontal scroll, right side */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
flex: 1, overflow: "hidden", marginLeft: 0,
|
||||
flex: 1, overflow: "hidden", marginLeft: 6,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
className="scrollbar-hide"
|
||||
|
||||
25
apps/tasks/app/widget/page.tsx
Normal file
25
apps/tasks/app/widget/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function WidgetPage() {
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('taskteam_token');
|
||||
if (token) {
|
||||
fetch('/api/v1/tasks?limit=5&status=pending', { headers: { Authorization: 'Bearer ' + token } })
|
||||
.then(r => r.json()).then(d => setTasks(d.data || []));
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<div style={{ padding: 8, background: '#0F172A', minHeight: '100vh', color: 'white', fontSize: 14 }}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 8 }}>Task Team</div>
|
||||
{tasks.map(t => (
|
||||
<div key={t.id} style={{ padding: '6px 0', borderBottom: '1px solid #1E293B', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: t.status === 'in_progress' ? '#F59E0B' : '#6B7280', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
|
||||
</div>
|
||||
))}
|
||||
{!tasks.length && <div style={{ opacity: 0.5 }}>Zadne ukoly</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,6 +135,19 @@ export interface Task {
|
||||
group_icon: string | null;
|
||||
}
|
||||
|
||||
export interface GroupTimeZone {
|
||||
days: number[];
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface GroupLocation {
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
radius_m: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -142,6 +155,8 @@ export interface Group {
|
||||
icon: string | null;
|
||||
sort_order: number;
|
||||
display_name?: string;
|
||||
time_zones: GroupTimeZone[];
|
||||
locations: GroupLocation[];
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
|
||||
17
apps/tasks/package-lock.json
generated
17
apps/tasks/package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@simplewebauthn/browser": "^12.0.0",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
@@ -559,6 +560,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-12.0.0.tgz",
|
||||
"integrity": "sha512-0w6W8qkACycyaRMb2XnHfpA9kkgs5e2Aw2Ul9ObBYmvFBbtzipyWu9u2+WP1wy98chM+GIlQFnPheUbiMBQr8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"@simplewebauthn/browser": "^12.0.0",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
|
||||
@@ -1 +1,46 @@
|
||||
{"name":"Task Team","short_name":"Tasks","start_url":"/","display":"standalone","background_color":"#0A0A0F","theme_color":"#1D4ED8","icons":[{"src":"/icon-192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icon-512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}
|
||||
{
|
||||
"name": "Task Team",
|
||||
"short_name": "Tasks",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0A0A0F",
|
||||
"theme_color": "#1D4ED8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Novy ukol",
|
||||
"short_name": "Pridat",
|
||||
"url": "/tasks?action=new",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Widget",
|
||||
"short_name": "Widget",
|
||||
"url": "/widget",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user