mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-14 02:36:55 +02:00
Merge pull request #13 from R0m1k3/claude/dockerized-notes-todo-app-011CV1roGRbnm21wgtB2LMxP
Claude/dockerized notes todo app 011 cv1ro g rbnm21wgt b2 l mx p
This commit is contained in:
11 files changed
+1638
-32
No files matched your search
@@ -88,11 +88,52 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Table settings (paramètres globaux)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Table rss_feeds (flux RSS)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_fetched_at DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
// Table rss_articles (articles des flux RSS)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS rss_articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feed_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
link TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
pub_date DATETIME,
|
||||
content TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (feed_id) REFERENCES rss_feeds(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Créer les index pour la performance
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_files ON note_files(note_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_feed ON rss_articles(feed_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_date ON rss_articles(pub_date)');
|
||||
|
||||
logger.info('✓ Tables de base de données créées avec succès');
|
||||
|
||||
|
||||
Generated
+159
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
@@ -17,6 +18,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
@@ -295,6 +297,23 @@
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -656,6 +675,18 @@
|
||||
"color-support": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -771,6 +802,15 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
@@ -887,6 +927,15 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -934,6 +983,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -1081,6 +1145,42 @@
|
||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1286,6 +1386,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
@@ -2456,6 +2571,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
@@ -2587,6 +2708,16 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rss-parser": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz",
|
||||
"integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^2.0.3",
|
||||
"xml2js": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -2622,6 +2753,12 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -3384,6 +3521,28 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
+3
-1
@@ -23,6 +23,7 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
@@ -31,6 +32,7 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
@@ -40,4 +42,4 @@
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,12 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.smooth-transition {
|
||||
transition: all var(--transition-base);
|
||||
|
||||
@@ -987,11 +987,50 @@
|
||||
/* Admin Modal */
|
||||
.admin-modal {
|
||||
min-width: 700px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
padding: 0 var(--spacing-xl);
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.admin-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.admin-tab-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -1011,6 +1050,31 @@
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.action-btn.btn-secondary {
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.action-btn.btn-secondary:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.rss-url-input {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.rss-url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.users-table-container {
|
||||
padding: var(--spacing-xl);
|
||||
overflow-x: auto;
|
||||
@@ -1134,3 +1198,236 @@
|
||||
background: #c0392b;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* RSS Feeds List */
|
||||
.rss-feeds-list {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.rss-feed-item {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.rss-feed-item:hover {
|
||||
background: var(--color-hover);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.rss-feed-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rss-feed-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rss-feed-url {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rss-feed-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rss-feed-toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rss-feed-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.rss-toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-border);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.rss-toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rss-feed-toggle input:checked + .rss-toggle-slider {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.rss-feed-toggle input:checked + .rss-toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.btn-delete-feed {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-delete-feed:hover {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* OpenRouter Settings */
|
||||
.openrouter-settings {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.setting-group label {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.setting-group small {
|
||||
display: block;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.setting-group small a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.setting-group small a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.setting-input,
|
||||
.setting-select {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-background);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.setting-input:focus,
|
||||
.setting-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.setting-textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-background);
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.setting-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.setting-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Change Password Modal */
|
||||
.small-modal {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-background);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
+122
-2
@@ -229,7 +229,7 @@ body {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr var(--todo-box-width);
|
||||
grid-template-columns: 1fr var(--todo-box-width) var(--todo-box-width);
|
||||
gap: var(--spacing-xl);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -365,13 +365,133 @@ body {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* RSS Box */
|
||||
.rss-box {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + var(--spacing-xl));
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
max-height: calc(100vh - var(--header-height) - var(--spacing-2xl) * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rss-box-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.rss-box-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-summarize {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-summarize:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-summarize svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rss-articles-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.rss-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-2xl);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rss-article {
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-background);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rss-article:hover {
|
||||
background: var(--color-hover);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.rss-article-title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rss-article-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.rss-article-source {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.rss-article-description {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.content-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.todo-box {
|
||||
.todo-box,
|
||||
.rss-box {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
+116
-18
@@ -146,6 +146,24 @@
|
||||
<!-- Todos will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSS Box -->
|
||||
<div class="rss-box">
|
||||
<div class="rss-box-header">
|
||||
<h2 class="rss-box-title">📰 Actualités</h2>
|
||||
<button class="btn-summarize" id="summarizeRssBtn" title="Générer un résumé avec IA">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rss-articles-list" id="rssArticlesList">
|
||||
<p class="rss-empty">Aucun article pour le moment</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -153,29 +171,109 @@
|
||||
<div class="modal-backdrop" id="adminModal" style="display: none;">
|
||||
<div class="modal-content admin-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Administration des utilisateurs</h2>
|
||||
<h2>Administration</h2>
|
||||
<button class="modal-close" id="closeAdminModal">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-actions">
|
||||
<button class="action-btn" id="createUserBtn">+ Créer un utilisateur</button>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-tab="users">👥 Utilisateurs</button>
|
||||
<button class="admin-tab" data-tab="rss">📰 Flux RSS</button>
|
||||
<button class="admin-tab" data-tab="openrouter">🤖 OpenRouter</button>
|
||||
</div>
|
||||
|
||||
<div class="users-table-container">
|
||||
<table class="users-table" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom d'utilisateur</th>
|
||||
<th>Admin</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- Users will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Onglet Utilisateurs -->
|
||||
<div class="admin-tab-content" id="tab-users">
|
||||
<div class="admin-actions">
|
||||
<button class="action-btn" id="createUserBtn">+ Créer un utilisateur</button>
|
||||
</div>
|
||||
|
||||
<div class="users-table-container">
|
||||
<table class="users-table" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom d'utilisateur</th>
|
||||
<th>Admin</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="usersTableBody">
|
||||
<!-- Users will be inserted here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglet RSS -->
|
||||
<div class="admin-tab-content" id="tab-rss" style="display: none;">
|
||||
<div class="admin-actions">
|
||||
<input type="url" id="rssUrlInput" class="rss-url-input" placeholder="https://example.com/feed.xml">
|
||||
<button class="action-btn" id="addRssFeedBtn">+ Ajouter un flux</button>
|
||||
<button class="action-btn btn-secondary" id="fetchRssBtn">🔄 Récupérer les articles</button>
|
||||
</div>
|
||||
|
||||
<div class="rss-feeds-list" id="rssFeedsList">
|
||||
<!-- Feeds will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglet OpenRouter -->
|
||||
<div class="admin-tab-content" id="tab-openrouter" style="display: none;">
|
||||
<div class="openrouter-settings">
|
||||
<div class="setting-group">
|
||||
<label for="openrouterApiKey">Clé API OpenRouter</label>
|
||||
<input type="password" id="openrouterApiKey" class="setting-input" placeholder="sk-or-...">
|
||||
<small>Obtenez votre clé sur <a href="https://openrouter.ai" target="_blank">openrouter.ai</a></small>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label for="openrouterModel">Modèle</label>
|
||||
<input type="text" id="modelSearch" class="setting-input" placeholder="Rechercher un modèle...">
|
||||
<select id="openrouterModel" class="setting-select" size="8">
|
||||
<!-- Models will be inserted here -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label for="rssSummaryEnabled">
|
||||
<input type="checkbox" id="rssSummaryEnabled">
|
||||
Activer les résumés automatiques
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<label for="rssSummaryPrompt">Prompt de résumé</label>
|
||||
<textarea id="rssSummaryPrompt" class="setting-textarea" rows="10" placeholder="Le prompt sera chargé..."></textarea>
|
||||
</div>
|
||||
|
||||
<button class="action-btn" id="saveOpenRouterSettings">💾 Sauvegarder les paramètres</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Change Password -->
|
||||
<div class="modal-backdrop" id="changePasswordModal" style="display: none;">
|
||||
<div class="modal-content small-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Changer le mot de passe</h3>
|
||||
<button class="modal-close" id="closeChangePasswordModal">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="changePasswordUsername"></p>
|
||||
<div class="form-group">
|
||||
<label for="newPassword">Nouveau mot de passe</label>
|
||||
<input type="password" id="newPassword" class="form-input" placeholder="Minimum 6 caractères">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirmer le mot de passe</label>
|
||||
<input type="password" id="confirmPassword" class="form-input">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" id="cancelChangePassword">Annuler</button>
|
||||
<button class="btn-confirm" id="confirmChangePassword">Changer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+469
-11
@@ -1160,6 +1160,34 @@ function setupSearch() {
|
||||
}
|
||||
|
||||
// ==================== ADMIN ====================
|
||||
|
||||
// Tab switching
|
||||
function switchAdminTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.admin-tab').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
|
||||
// Update tab content
|
||||
document.querySelectorAll('.admin-tab-content').forEach(content => {
|
||||
content.style.display = 'none';
|
||||
});
|
||||
|
||||
const targetTab = document.getElementById(`tab-${tabName}`);
|
||||
if (targetTab) {
|
||||
targetTab.style.display = 'block';
|
||||
}
|
||||
|
||||
// Load data for the selected tab
|
||||
if (tabName === 'users') {
|
||||
loadUsers();
|
||||
} else if (tabName === 'rss') {
|
||||
loadRssFeeds();
|
||||
} else if (tabName === 'openrouter') {
|
||||
loadOpenRouterSettings();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const users = await api.get('/api/users');
|
||||
@@ -1200,7 +1228,9 @@ async function openAdminModal() {
|
||||
|
||||
modal.style.display = 'flex';
|
||||
setTimeout(() => modal.classList.add('active'), 10);
|
||||
await loadUsers();
|
||||
|
||||
// Switch to users tab by default
|
||||
switchAdminTab('users');
|
||||
}
|
||||
|
||||
function closeAdminModal() {
|
||||
@@ -1248,32 +1278,74 @@ async function createUser() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeUserPassword(id, username) {
|
||||
const password = prompt(`Nouveau mot de passe pour "${username}" :`);
|
||||
// Password Change Modal
|
||||
let currentPasswordChangeUserId = null;
|
||||
let currentPasswordChangeUsername = null;
|
||||
|
||||
if (!password) return;
|
||||
function openPasswordChangeModal(userId, username) {
|
||||
currentPasswordChangeUserId = userId;
|
||||
currentPasswordChangeUsername = username;
|
||||
|
||||
if (password.length < 6) {
|
||||
const modal = document.getElementById('changePasswordModal');
|
||||
const usernameDisplay = document.getElementById('changePasswordUsername');
|
||||
const newPasswordInput = document.getElementById('newPassword');
|
||||
const confirmPasswordInput = document.getElementById('confirmPassword');
|
||||
|
||||
if (usernameDisplay) {
|
||||
usernameDisplay.textContent = `Modifier le mot de passe de "${username}"`;
|
||||
}
|
||||
|
||||
if (newPasswordInput) newPasswordInput.value = '';
|
||||
if (confirmPasswordInput) confirmPasswordInput.value = '';
|
||||
|
||||
modal.style.display = 'flex';
|
||||
setTimeout(() => modal.classList.add('active'), 10);
|
||||
|
||||
if (newPasswordInput) newPasswordInput.focus();
|
||||
}
|
||||
|
||||
function closePasswordChangeModal() {
|
||||
const modal = document.getElementById('changePasswordModal');
|
||||
modal.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
modal.style.display = 'none';
|
||||
}, 300);
|
||||
|
||||
currentPasswordChangeUserId = null;
|
||||
currentPasswordChangeUsername = null;
|
||||
}
|
||||
|
||||
async function confirmPasswordChange() {
|
||||
const newPasswordInput = document.getElementById('newPassword');
|
||||
const confirmPasswordInput = document.getElementById('confirmPassword');
|
||||
|
||||
const newPassword = newPasswordInput.value;
|
||||
const confirmPassword = confirmPasswordInput.value;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
alert('Le mot de passe doit contenir au moins 6 caractères');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmPassword = prompt('Confirmer le nouveau mot de passe :');
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put(`/api/users/${id}`, { password });
|
||||
await api.put(`/api/users/${currentPasswordChangeUserId}`, { password: newPassword });
|
||||
alert('Mot de passe modifié avec succès');
|
||||
closePasswordChangeModal();
|
||||
} catch (error) {
|
||||
console.error('Erreur modification mot de passe:', error);
|
||||
alert('Erreur lors de la modification du mot de passe');
|
||||
}
|
||||
}
|
||||
|
||||
async function changeUserPassword(id, username) {
|
||||
openPasswordChangeModal(id, username);
|
||||
}
|
||||
|
||||
async function deleteUser(id, username) {
|
||||
const confirmed = await confirmDialog.show({
|
||||
icon: '⚠️',
|
||||
@@ -1294,13 +1366,299 @@ async function deleteUser(id, username) {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RSS FEEDS ====================
|
||||
async function loadRssFeeds() {
|
||||
try {
|
||||
const feeds = await api.get('/api/rss/feeds');
|
||||
renderRssFeeds(feeds);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement flux RSS:', error);
|
||||
alert('Erreur lors du chargement des flux RSS');
|
||||
}
|
||||
}
|
||||
|
||||
function renderRssFeeds(feeds) {
|
||||
const container = document.getElementById('rssFeedsList');
|
||||
if (!container) return;
|
||||
|
||||
if (feeds.length === 0) {
|
||||
container.innerHTML = '<p style="text-align: center; color: var(--color-text-secondary); padding: 20px;">Aucun flux RSS configuré</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = feeds.map(feed => `
|
||||
<div class="rss-feed-item">
|
||||
<div class="rss-feed-info">
|
||||
<div class="rss-feed-title">${escapeHtml(feed.title || feed.url)}</div>
|
||||
<div class="rss-feed-url">${escapeHtml(feed.url)}</div>
|
||||
${feed.last_fetched_at ? `<div class="rss-feed-meta">Dernière maj: ${new Date(feed.last_fetched_at).toLocaleString('fr-FR')}</div>` : ''}
|
||||
</div>
|
||||
<div class="rss-feed-actions">
|
||||
<label class="rss-feed-toggle">
|
||||
<input type="checkbox" ${feed.enabled ? 'checked' : ''} data-feed-id="${feed.id}">
|
||||
<span class="rss-toggle-slider"></span>
|
||||
</label>
|
||||
<button class="btn-delete-feed" data-feed-id="${feed.id}" title="Supprimer">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function addRssFeed() {
|
||||
const input = document.getElementById('rssUrlInput');
|
||||
const url = input.value.trim();
|
||||
|
||||
if (!url) {
|
||||
alert('Veuillez entrer une URL de flux RSS');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
alert('L\'URL doit commencer par http:// ou https://');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post('/api/rss/feeds', { url });
|
||||
input.value = '';
|
||||
alert('Flux RSS ajouté avec succès');
|
||||
await loadRssFeeds();
|
||||
} catch (error) {
|
||||
console.error('Erreur ajout flux RSS:', error);
|
||||
alert('Erreur lors de l\'ajout du flux RSS. Vérifiez l\'URL.');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRssFeed(feedId, enabled) {
|
||||
try {
|
||||
await api.put(`/api/rss/feeds/${feedId}`, { enabled });
|
||||
} catch (error) {
|
||||
console.error('Erreur toggle flux RSS:', error);
|
||||
alert('Erreur lors de la modification du flux RSS');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRssFeed(feedId) {
|
||||
const confirmed = await confirmDialog.show({
|
||||
icon: '📰',
|
||||
title: 'Supprimer ce flux RSS',
|
||||
message: 'Êtes-vous sûr de vouloir supprimer ce flux RSS ?\n\nTous les articles associés seront également supprimés.',
|
||||
okText: 'Supprimer'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/api/rss/feeds/${feedId}`);
|
||||
alert('Flux RSS supprimé avec succès');
|
||||
await loadRssFeeds();
|
||||
} catch (error) {
|
||||
console.error('Erreur suppression flux RSS:', error);
|
||||
alert('Erreur lors de la suppression du flux RSS');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRssArticles() {
|
||||
const btn = document.getElementById('fetchRssBtn');
|
||||
if (!btn) return;
|
||||
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '🔄 Récupération en cours...';
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/rss/fetch', {});
|
||||
alert(result.message || 'Articles récupérés avec succès');
|
||||
await loadRssArticles();
|
||||
} catch (error) {
|
||||
console.error('Erreur fetch articles RSS:', error);
|
||||
alert('Erreur lors de la récupération des articles RSS');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRssArticles() {
|
||||
try {
|
||||
const articles = await api.get('/api/rss/articles');
|
||||
renderRssArticles(articles);
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement articles RSS:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRssArticles(articles) {
|
||||
const container = document.getElementById('rssArticlesList');
|
||||
if (!container) return;
|
||||
|
||||
if (!articles || articles.length === 0) {
|
||||
container.innerHTML = '<p class="rss-empty">Aucun article pour le moment</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = articles.map(article => `
|
||||
<div class="rss-article" data-link="${escapeHtml(article.link)}">
|
||||
<div class="rss-article-title">${escapeHtml(article.title)}</div>
|
||||
<div class="rss-article-meta">
|
||||
<span class="rss-article-source">${escapeHtml(article.feed_title)}</span>
|
||||
<span>${new Date(article.pub_date).toLocaleDateString('fr-FR')}</span>
|
||||
</div>
|
||||
${article.description ? `<div class="rss-article-description">${escapeHtml(article.description)}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function summarizeRss() {
|
||||
const btn = document.getElementById('summarizeRssBtn');
|
||||
if (!btn) return;
|
||||
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" opacity="0.75"/></svg>';
|
||||
|
||||
try {
|
||||
const result = await api.post('/api/rss/summarize', {});
|
||||
|
||||
// Create a new note with the summary
|
||||
const newNote = await api.post('/api/notes', {
|
||||
title: `📰 Résumé RSS - ${new Date().toLocaleDateString('fr-FR')}`,
|
||||
content: result.summary
|
||||
});
|
||||
|
||||
alert('Résumé généré et sauvegardé dans une nouvelle note !');
|
||||
await loadNotes();
|
||||
|
||||
// Expand the new note
|
||||
state.expandedNoteId = newNote.id;
|
||||
renderNotes();
|
||||
} catch (error) {
|
||||
console.error('Erreur génération résumé RSS:', error);
|
||||
if (error.message && error.message.includes('400')) {
|
||||
alert('Veuillez configurer votre clé API OpenRouter dans l\'administration');
|
||||
} else {
|
||||
alert('Erreur lors de la génération du résumé RSS');
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OPENROUTER ====================
|
||||
let allModels = [];
|
||||
|
||||
async function loadOpenRouterSettings() {
|
||||
try {
|
||||
const settings = await api.get('/api/settings');
|
||||
const models = await api.get('/api/rss/models');
|
||||
|
||||
allModels = models;
|
||||
|
||||
// Populate form
|
||||
const apiKeyInput = document.getElementById('openrouterApiKey');
|
||||
const modelSelect = document.getElementById('openrouterModel');
|
||||
const summaryEnabled = document.getElementById('rssSummaryEnabled');
|
||||
const summaryPrompt = document.getElementById('rssSummaryPrompt');
|
||||
|
||||
if (apiKeyInput) {
|
||||
apiKeyInput.value = settings.openrouter_api_key || '';
|
||||
}
|
||||
|
||||
if (summaryEnabled) {
|
||||
summaryEnabled.checked = settings.rss_summary_enabled === 'true' || settings.rss_summary_enabled === '1';
|
||||
}
|
||||
|
||||
if (summaryPrompt) {
|
||||
summaryPrompt.value = settings.rss_summary_prompt || `Tu es un assistant spécialisé dans la synthèse d'actualités. Analyse les articles suivants et crée un résumé structuré et informatif.
|
||||
|
||||
Pour chaque article, identifie:
|
||||
- Le sujet principal
|
||||
- Les points clés
|
||||
- L'importance de l'information
|
||||
|
||||
Ensuite, génère un résumé global qui:
|
||||
1. Regroupe les thèmes communs
|
||||
2. Hiérarchise les informations par importance
|
||||
3. Présente une vue d'ensemble claire et concise
|
||||
4. Utilise un style professionnel mais accessible
|
||||
|
||||
Format de sortie en Markdown avec des sections claires.`;
|
||||
}
|
||||
|
||||
// Populate model selector
|
||||
renderModelSelect(models, settings.openrouter_model);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement paramètres OpenRouter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModelSelect(models, selectedModel) {
|
||||
const select = document.getElementById('openrouterModel');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = models.map(model => `
|
||||
<option value="${escapeHtml(model.id)}" ${model.id === selectedModel ? 'selected' : ''}>
|
||||
${escapeHtml(model.name)} (${escapeHtml(model.provider)})
|
||||
</option>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function filterModels() {
|
||||
const searchInput = document.getElementById('modelSearch');
|
||||
const select = document.getElementById('openrouterModel');
|
||||
|
||||
if (!searchInput || !select) return;
|
||||
|
||||
const query = searchInput.value.toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
renderModelSelect(allModels, select.value);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allModels.filter(model =>
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
model.provider.toLowerCase().includes(query) ||
|
||||
model.id.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
renderModelSelect(filtered, select.value);
|
||||
}
|
||||
|
||||
async function saveOpenRouterSettings() {
|
||||
const apiKeyInput = document.getElementById('openrouterApiKey');
|
||||
const modelSelect = document.getElementById('openrouterModel');
|
||||
const summaryEnabled = document.getElementById('rssSummaryEnabled');
|
||||
const summaryPrompt = document.getElementById('rssSummaryPrompt');
|
||||
|
||||
try {
|
||||
// Save all settings
|
||||
await api.put('/api/settings/openrouter_api_key', { value: apiKeyInput.value });
|
||||
await api.put('/api/settings/openrouter_model', { value: modelSelect.value });
|
||||
await api.put('/api/settings/rss_summary_enabled', { value: summaryEnabled.checked ? '1' : '0' });
|
||||
await api.put('/api/settings/rss_summary_prompt', { value: summaryPrompt.value });
|
||||
|
||||
alert('Paramètres OpenRouter sauvegardés avec succès');
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde paramètres OpenRouter:', error);
|
||||
alert('Erreur lors de la sauvegarde des paramètres');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== INIT ====================
|
||||
async function init() {
|
||||
// Vérifier l'authentification
|
||||
await initAuth();
|
||||
|
||||
// Charger les données
|
||||
await Promise.all([loadNotes(), loadTodos()]);
|
||||
await Promise.all([loadNotes(), loadTodos(), loadRssArticles()]);
|
||||
|
||||
// Setup search
|
||||
setupSearch();
|
||||
@@ -1584,10 +1942,110 @@ async function init() {
|
||||
collapseNote();
|
||||
} else {
|
||||
closeNoteModal();
|
||||
document.getElementById('adminModal').style.display = 'none';
|
||||
closeAdminModal();
|
||||
closePasswordChangeModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ADMIN TAB SWITCHING ====================
|
||||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
switchAdminTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== PASSWORD CHANGE MODAL ====================
|
||||
const closePasswordModal = document.getElementById('closeChangePasswordModal');
|
||||
if (closePasswordModal) {
|
||||
closePasswordModal.addEventListener('click', closePasswordChangeModal);
|
||||
}
|
||||
|
||||
const cancelPasswordChange = document.getElementById('cancelChangePassword');
|
||||
if (cancelPasswordChange) {
|
||||
cancelPasswordChange.addEventListener('click', closePasswordChangeModal);
|
||||
}
|
||||
|
||||
const confirmPasswordBtn = document.getElementById('confirmChangePassword');
|
||||
if (confirmPasswordBtn) {
|
||||
confirmPasswordBtn.addEventListener('click', confirmPasswordChange);
|
||||
}
|
||||
|
||||
// Enter key to confirm password change
|
||||
const newPasswordInput = document.getElementById('newPassword');
|
||||
const confirmPasswordInput = document.getElementById('confirmPassword');
|
||||
if (newPasswordInput && confirmPasswordInput) {
|
||||
[newPasswordInput, confirmPasswordInput].forEach(input => {
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') confirmPasswordChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== RSS FEEDS ====================
|
||||
const addRssFeedBtn = document.getElementById('addRssFeedBtn');
|
||||
if (addRssFeedBtn) {
|
||||
addRssFeedBtn.addEventListener('click', addRssFeed);
|
||||
}
|
||||
|
||||
const rssUrlInput = document.getElementById('rssUrlInput');
|
||||
if (rssUrlInput) {
|
||||
rssUrlInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') addRssFeed();
|
||||
});
|
||||
}
|
||||
|
||||
const fetchRssBtn = document.getElementById('fetchRssBtn');
|
||||
if (fetchRssBtn) {
|
||||
fetchRssBtn.addEventListener('click', fetchRssArticles);
|
||||
}
|
||||
|
||||
// Event delegation for RSS feeds list
|
||||
const rssFeedsList = document.getElementById('rssFeedsList');
|
||||
if (rssFeedsList) {
|
||||
rssFeedsList.addEventListener('change', (e) => {
|
||||
if (e.target.type === 'checkbox' && e.target.dataset.feedId) {
|
||||
const feedId = parseInt(e.target.dataset.feedId);
|
||||
toggleRssFeed(feedId, e.target.checked);
|
||||
}
|
||||
});
|
||||
|
||||
rssFeedsList.addEventListener('click', (e) => {
|
||||
const deleteBtn = e.target.closest('.btn-delete-feed');
|
||||
if (deleteBtn) {
|
||||
const feedId = parseInt(deleteBtn.dataset.feedId);
|
||||
deleteRssFeed(feedId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// RSS articles click to open in new tab
|
||||
const rssArticlesList = document.getElementById('rssArticlesList');
|
||||
if (rssArticlesList) {
|
||||
rssArticlesList.addEventListener('click', (e) => {
|
||||
const article = e.target.closest('.rss-article');
|
||||
if (article && article.dataset.link) {
|
||||
window.open(article.dataset.link, '_blank');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// RSS summarize button
|
||||
const summarizeRssBtn = document.getElementById('summarizeRssBtn');
|
||||
if (summarizeRssBtn) {
|
||||
summarizeRssBtn.addEventListener('click', summarizeRss);
|
||||
}
|
||||
|
||||
// ==================== OPENROUTER ====================
|
||||
const modelSearch = document.getElementById('modelSearch');
|
||||
if (modelSearch) {
|
||||
modelSearch.addEventListener('input', utils.debounce(filterModels, 300));
|
||||
}
|
||||
|
||||
const saveOpenRouterBtn = document.getElementById('saveOpenRouterSettings');
|
||||
if (saveOpenRouterBtn) {
|
||||
saveOpenRouterBtn.addEventListener('click', saveOpenRouterSettings);
|
||||
}
|
||||
}
|
||||
|
||||
// Démarrer l'application
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// Routes de gestion des flux RSS
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Parser = require('rss-parser');
|
||||
const axios = require('axios');
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
// Routes admin (gestion des flux)
|
||||
router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/rss/feeds
|
||||
* Liste tous les flux RSS
|
||||
*/
|
||||
router.get('/feeds', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const feeds = await getAll('SELECT * FROM rss_feeds ORDER BY created_at DESC');
|
||||
res.json(feeds || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/feeds
|
||||
* Ajouter un nouveau flux RSS
|
||||
*/
|
||||
router.post('/feeds', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL requise' });
|
||||
}
|
||||
|
||||
// Vérifier si le flux existe déjà
|
||||
const existing = await getOne('SELECT id FROM rss_feeds WHERE url = ?', [url]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Ce flux existe déjà' });
|
||||
}
|
||||
|
||||
// Tester le flux
|
||||
try {
|
||||
const feed = await parser.parseURL(url);
|
||||
|
||||
// Ajouter le flux
|
||||
const result = await runQuery(
|
||||
'INSERT INTO rss_feeds (url, title, description) VALUES (?, ?, ?)',
|
||||
[url, feed.title || url, feed.description || '']
|
||||
);
|
||||
|
||||
logger.info(`Flux RSS ajouté: ${feed.title} (${url})`);
|
||||
|
||||
res.json({
|
||||
id: result.id,
|
||||
url,
|
||||
title: feed.title || url,
|
||||
description: feed.description || '',
|
||||
enabled: 1
|
||||
});
|
||||
} catch (parseError) {
|
||||
logger.error('Erreur lors du parsing du flux RSS:', parseError);
|
||||
return res.status(400).json({ error: 'Impossible de parser ce flux RSS. Vérifiez l\'URL.' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de l\'ajout du flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/rss/feeds/:id
|
||||
* Mettre à jour un flux RSS (activer/désactiver)
|
||||
*/
|
||||
router.put('/feeds/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { enabled } = req.body;
|
||||
|
||||
await runQuery(
|
||||
'UPDATE rss_feeds SET enabled = ? WHERE id = ?',
|
||||
[enabled ? 1 : 0, req.params.id]
|
||||
);
|
||||
|
||||
logger.info(`Flux RSS ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
|
||||
res.json({ message: 'Flux mis à jour avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la mise à jour du flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/rss/feeds/:id
|
||||
* Supprimer un flux RSS
|
||||
*/
|
||||
router.delete('/feeds/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await runQuery('DELETE FROM rss_feeds WHERE id = ?', [req.params.id]);
|
||||
|
||||
logger.info(`Flux RSS supprimé: ${req.params.id}`);
|
||||
res.json({ message: 'Flux supprimé avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression du flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/fetch
|
||||
* Récupérer tous les articles des flux actifs
|
||||
*/
|
||||
router.post('/fetch', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const feeds = await getAll('SELECT * FROM rss_feeds WHERE enabled = 1');
|
||||
|
||||
let totalArticles = 0;
|
||||
|
||||
for (const feed of feeds) {
|
||||
try {
|
||||
const parsedFeed = await parser.parseURL(feed.url);
|
||||
|
||||
// Mettre à jour les infos du flux
|
||||
await runQuery(
|
||||
'UPDATE rss_feeds SET title = ?, description = ?, last_fetched_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[parsedFeed.title || feed.url, parsedFeed.description || '', feed.id]
|
||||
);
|
||||
|
||||
// Ajouter les articles
|
||||
for (const item of parsedFeed.items) {
|
||||
try {
|
||||
// Vérifier si l'article existe déjà
|
||||
const existing = await getOne('SELECT id FROM rss_articles WHERE link = ?', [item.link]);
|
||||
|
||||
if (!existing) {
|
||||
await runQuery(
|
||||
'INSERT INTO rss_articles (feed_id, title, link, description, pub_date, content) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
feed.id,
|
||||
item.title || '',
|
||||
item.link || '',
|
||||
item.contentSnippet || item.description || '',
|
||||
item.pubDate || item.isoDate || new Date().toISOString(),
|
||||
item.content || item['content:encoded'] || ''
|
||||
]
|
||||
);
|
||||
totalArticles++;
|
||||
}
|
||||
} catch (articleError) {
|
||||
// Ignorer les articles en double ou invalides
|
||||
logger.warn(`Erreur lors de l'ajout d'un article: ${articleError.message}`);
|
||||
}
|
||||
}
|
||||
} catch (feedError) {
|
||||
logger.error(`Erreur lors du fetch du flux ${feed.url}:`, feedError);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`${totalArticles} nouveaux articles récupérés`);
|
||||
res.json({ message: `${totalArticles} nouveaux articles récupérés` });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors du fetch des flux RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/articles
|
||||
* Récupérer les 10 derniers articles
|
||||
*/
|
||||
router.get('/articles', async (req, res) => {
|
||||
try {
|
||||
const articles = await getAll(`
|
||||
SELECT
|
||||
a.id, a.title, a.link, a.description, a.pub_date, a.content,
|
||||
f.title as feed_title, f.url as feed_url
|
||||
FROM rss_articles a
|
||||
JOIN rss_feeds f ON a.feed_id = f.id
|
||||
ORDER BY a.pub_date DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
res.json(articles || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des articles RSS:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/rss/summarize
|
||||
* Générer un résumé des 10 derniers articles avec OpenRouter
|
||||
*/
|
||||
router.post('/summarize', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Récupérer les paramètres
|
||||
const apiKey = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
|
||||
const model = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_model']);
|
||||
const prompt = await getOne('SELECT value FROM settings WHERE key = ?', ['rss_summary_prompt']);
|
||||
|
||||
if (!apiKey || !apiKey.value) {
|
||||
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' });
|
||||
}
|
||||
|
||||
// Récupérer les 10 derniers articles
|
||||
const articles = await getAll(`
|
||||
SELECT
|
||||
a.title, a.description, a.link, a.pub_date,
|
||||
f.title as feed_title
|
||||
FROM rss_articles a
|
||||
JOIN rss_feeds f ON a.feed_id = f.id
|
||||
ORDER BY a.pub_date DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
if (articles.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun article à résumer' });
|
||||
}
|
||||
|
||||
// Construire le prompt
|
||||
const defaultPrompt = prompt?.value || `Tu es un assistant spécialisé dans la synthèse d'actualités. Analyse les articles suivants et crée un résumé structuré et informatif.
|
||||
|
||||
Pour chaque article, identifie:
|
||||
- Le sujet principal
|
||||
- Les points clés
|
||||
- L'importance de l'information
|
||||
|
||||
Ensuite, génère un résumé global qui:
|
||||
1. Regroupe les thèmes communs
|
||||
2. Hiérarchise les informations par importance
|
||||
3. Présente une vue d'ensemble claire et concise
|
||||
4. Utilise un style professionnel mais accessible
|
||||
|
||||
Format de sortie en Markdown avec des sections claires.`;
|
||||
|
||||
const articlesText = articles.map((a, i) =>
|
||||
`## Article ${i + 1}: ${a.title}\n**Source**: ${a.feed_title}\n**Date**: ${new Date(a.pub_date).toLocaleDateString('fr-FR')}\n**Description**: ${a.description}\n**Lien**: ${a.link}\n`
|
||||
).join('\n---\n');
|
||||
|
||||
const fullPrompt = `${defaultPrompt}\n\n# Articles à résumer:\n\n${articlesText}`;
|
||||
|
||||
// Appeler OpenRouter
|
||||
const selectedModel = model?.value || 'openai/gpt-3.5-turbo';
|
||||
|
||||
logger.info(`Génération du résumé avec le modèle ${selectedModel}...`);
|
||||
|
||||
const response = await axios.post(
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
{
|
||||
model: selectedModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: fullPrompt
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey.value}`,
|
||||
'HTTP-Referer': 'https://noteflow.app',
|
||||
'X-Title': 'NoteFlow RSS Summarizer',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 60000
|
||||
}
|
||||
);
|
||||
|
||||
const summary = response.data.choices[0].message.content;
|
||||
|
||||
logger.info('Résumé généré avec succès');
|
||||
|
||||
res.json({
|
||||
summary,
|
||||
model: selectedModel,
|
||||
articles_count: articles.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la génération du résumé:', error);
|
||||
|
||||
if (error.response) {
|
||||
logger.error('Réponse OpenRouter:', error.response.data);
|
||||
return res.status(error.response.status).json({
|
||||
error: 'Erreur API OpenRouter',
|
||||
details: error.response.data
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/rss/models
|
||||
* Récupérer la liste des modèles OpenRouter
|
||||
*/
|
||||
router.get('/models', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Liste des modèles populaires pour le résumé
|
||||
const models = [
|
||||
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo', provider: 'OpenAI' },
|
||||
{ id: 'openai/gpt-4', name: 'GPT-4', provider: 'OpenAI' },
|
||||
{ id: 'openai/gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'OpenAI' },
|
||||
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus', provider: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'Anthropic' },
|
||||
{ id: 'anthropic/claude-3-haiku', name: 'Claude 3 Haiku', provider: 'Anthropic' },
|
||||
{ id: 'google/gemini-pro', name: 'Gemini Pro', provider: 'Google' },
|
||||
{ id: 'meta-llama/llama-3-70b-instruct', name: 'Llama 3 70B', provider: 'Meta' },
|
||||
{ id: 'mistralai/mistral-large', name: 'Mistral Large', provider: 'Mistral AI' },
|
||||
{ id: 'mistralai/mixtral-8x7b-instruct', name: 'Mixtral 8x7B', provider: 'Mistral AI' }
|
||||
];
|
||||
|
||||
res.json(models);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des modèles:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,99 @@
|
||||
// Routes de gestion des paramètres
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getAll, getOne, runQuery } = require('../config/database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
// Toutes les routes nécessitent authentification admin
|
||||
router.use(authenticateToken);
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Récupérer tous les paramètres
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const settings = await getAll('SELECT key, value FROM settings');
|
||||
|
||||
// Convertir en objet key-value
|
||||
const settingsObj = {};
|
||||
settings.forEach(setting => {
|
||||
settingsObj[setting.key] = setting.value;
|
||||
});
|
||||
|
||||
res.json(settingsObj);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des paramètres:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/:key
|
||||
* Récupérer un paramètre spécifique
|
||||
*/
|
||||
router.get('/:key', async (req, res) => {
|
||||
try {
|
||||
const setting = await getOne('SELECT value FROM settings WHERE key = ?', [req.params.key]);
|
||||
|
||||
if (!setting) {
|
||||
return res.status(404).json({ error: 'Paramètre non trouvé' });
|
||||
}
|
||||
|
||||
res.json({ key: req.params.key, value: setting.value });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération du paramètre:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/settings/:key
|
||||
* Mettre à jour un paramètre
|
||||
*/
|
||||
router.put('/:key', async (req, res) => {
|
||||
try {
|
||||
const { value } = req.body;
|
||||
|
||||
// Vérifier si le paramètre existe
|
||||
const existing = await getOne('SELECT id FROM settings WHERE key = ?', [req.params.key]);
|
||||
|
||||
if (existing) {
|
||||
await runQuery(
|
||||
'UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?',
|
||||
[value, req.params.key]
|
||||
);
|
||||
} else {
|
||||
await runQuery(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?)',
|
||||
[req.params.key, value]
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Paramètre mis à jour: ${req.params.key}`);
|
||||
res.json({ key: req.params.key, value });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la mise à jour du paramètre:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/:key
|
||||
* Supprimer un paramètre
|
||||
*/
|
||||
router.delete('/:key', async (req, res) => {
|
||||
try {
|
||||
await runQuery('DELETE FROM settings WHERE key = ?', [req.params.key]);
|
||||
|
||||
logger.info(`Paramètre supprimé: ${req.params.key}`);
|
||||
res.json({ message: 'Paramètre supprimé avec succès' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression du paramètre:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -82,6 +82,8 @@ app.use('/api/auth', require('./routes/auth.routes'));
|
||||
app.use('/api/users', require('./routes/users.routes'));
|
||||
app.use('/api/notes', require('./routes/notes.routes'));
|
||||
app.use('/api/todos', require('./routes/todos.routes'));
|
||||
app.use('/api/settings', require('./routes/settings.routes'));
|
||||
app.use('/api/rss', require('./routes/rss.routes'));
|
||||
|
||||
// Route de recherche
|
||||
app.get('/api/search', require('./routes/notes.routes').searchNotes);
|
||||
|
||||
Reference in new issue
Block a user