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:
LogiFlow authored and GitHub committed 2025-11-11 17:55:31 +01:00
commit fb3e140fc7
11 files changed
+1638 -32

No files matched your search

+41
View File
@@ -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');
+159
View File
@@ -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
View File
@@ -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"
}
}
}
+6
View File
@@ -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);
+297
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+324
View File
@@ -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;
+99
View File
@@ -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;
+2
View File
@@ -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);