diff --git a/backend/app.js b/backend/app.js index 63ec283..afbdbb4 100644 --- a/backend/app.js +++ b/backend/app.js @@ -20,6 +20,7 @@ import taxiMapRouter from './routers/taxiMapRouter.js'; import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js'; import termineRouter from './routers/termineRouter.js'; import vocabRouter from './routers/vocabRouter.js'; +import dashboardRouter from './routers/dashboardRouter.js'; import cors from 'cors'; import './jobs/sessionCleanup.js'; @@ -78,6 +79,7 @@ app.use('/api/friendships', friendshipRouter); app.use('/api/models', modelsProxyRouter); app.use('/api/blog', blogRouter); app.use('/api/termine', termineRouter); +app.use('/api/dashboard', dashboardRouter); // Serve frontend SPA for non-API routes to support history mode clean URLs // /models/* nicht statisch ausliefern – nur über /api/models (Proxy mit Komprimierung) diff --git a/backend/controllers/dashboardController.js b/backend/controllers/dashboardController.js new file mode 100644 index 0000000..bc2a86d --- /dev/null +++ b/backend/controllers/dashboardController.js @@ -0,0 +1,39 @@ +import dashboardService from '../services/dashboardService.js'; + +function getHashedUserId(req) { + return req.headers?.userid; +} + +export default { + async getConfig(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + try { + const config = await dashboardService.getConfig(hashedUserId); + res.json(config); + } catch (error) { + console.error('Dashboard getConfig:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } + }, + + async setConfig(req, res) { + const hashedUserId = getHashedUserId(req); + if (!hashedUserId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + const config = req.body; + if (!config || typeof config !== 'object') { + return res.status(400).json({ error: 'Invalid config' }); + } + try { + const result = await dashboardService.setConfig(hashedUserId, config); + res.json(result); + } catch (error) { + console.error('Dashboard setConfig:', error); + res.status(500).json({ error: error.message || 'Internal server error' }); + } + } +}; diff --git a/backend/models/associations.js b/backend/models/associations.js index 1f417aa..6271662 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -5,6 +5,7 @@ import ChatUser from './chat/user.js'; import Room from './chat/room.js'; import User from './community/user.js'; import UserParam from './community/user_param.js'; +import UserDashboard from './community/user_dashboard.js'; import UserParamType from './type/user_param.js'; import UserRightType from './type/user_right.js'; import UserRight from './community/user_right.js'; @@ -166,6 +167,9 @@ export default function setupAssociations() { User.hasMany(UserParam, { foreignKey: 'userId', as: 'user_params' }); UserParam.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + User.hasOne(UserDashboard, { foreignKey: 'userId', as: 'dashboard' }); + UserDashboard.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + UserParamValue.belongsTo(UserParamType, { foreignKey: 'userParamTypeId', as: 'user_param_value_type' }); UserParamType.hasMany(UserParamValue, { foreignKey: 'userParamTypeId', as: 'user_param_type_value' }); diff --git a/backend/models/community/user_dashboard.js b/backend/models/community/user_dashboard.js new file mode 100644 index 0000000..b362247 --- /dev/null +++ b/backend/models/community/user_dashboard.js @@ -0,0 +1,24 @@ +import { sequelize } from '../../utils/sequelize.js'; +import { DataTypes } from 'sequelize'; +import User from './user.js'; + +const UserDashboard = sequelize.define('user_dashboard', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + primaryKey: true, + references: { model: User, key: 'id' } + }, + config: { + type: DataTypes.JSONB, + allowNull: false, + defaultValue: { widgets: [] } + } +}, { + tableName: 'user_dashboard', + schema: 'community', + underscored: true, + timestamps: false +}); + +export default UserDashboard; diff --git a/backend/models/index.js b/backend/models/index.js index 6bd96f4..7d9845d 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -6,6 +6,7 @@ import UserParamType from './type/user_param.js'; import UserRightType from './type/user_right.js'; import User from './community/user.js'; import UserParam from './community/user_param.js'; +import UserDashboard from './community/user_dashboard.js'; import Login from './logs/login.js'; import UserRight from './community/user_right.js'; import InterestType from './type/interest.js'; @@ -152,6 +153,7 @@ const models = { UserRightType, User, UserParam, + UserDashboard, Login, UserRight, InterestType, diff --git a/backend/routers/dashboardRouter.js b/backend/routers/dashboardRouter.js new file mode 100644 index 0000000..3860722 --- /dev/null +++ b/backend/routers/dashboardRouter.js @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import dashboardController from '../controllers/dashboardController.js'; + +const router = Router(); + +router.get('/config', authenticate, dashboardController.getConfig.bind(dashboardController)); +router.put('/config', authenticate, dashboardController.setConfig.bind(dashboardController)); + +export default router; diff --git a/backend/services/dashboardService.js b/backend/services/dashboardService.js new file mode 100644 index 0000000..940b82c --- /dev/null +++ b/backend/services/dashboardService.js @@ -0,0 +1,37 @@ +import BaseService from './BaseService.js'; +import UserDashboard from '../models/community/user_dashboard.js'; + +class DashboardService extends BaseService { + /** + * @param {string} hashedUserId + * @returns {Promise<{ widgets: Array<{ id: string, title: string, endpoint: string }> }>} + */ + async getConfig(hashedUserId) { + const user = await this.getUserByHashedId(hashedUserId); + const row = await UserDashboard.findOne({ where: { userId: user.id } }); + const config = row?.config ?? { widgets: [] }; + if (!Array.isArray(config.widgets)) config.widgets = []; + return config; + } + + /** + * @param {string} hashedUserId + * @param {{ widgets: Array<{ id: string, title: string, endpoint: string }> }} config + */ + async setConfig(hashedUserId, config) { + const user = await this.getUserByHashedId(hashedUserId); + const widgets = Array.isArray(config?.widgets) ? config.widgets : []; + const sanitized = widgets.map(w => ({ + id: String(w?.id ?? ''), + title: String(w?.title ?? ''), + endpoint: String(w?.endpoint ?? '') + })).filter(w => w.id && (w.title || w.endpoint)); + await UserDashboard.upsert({ + userId: user.id, + config: { widgets: sanitized } + }, { conflictFields: ['userId'] }); + return { widgets: sanitized }; + } +} + +export default new DashboardService(); diff --git a/frontend/src/components/DashboardWidget.vue b/frontend/src/components/DashboardWidget.vue new file mode 100644 index 0000000..096e441 --- /dev/null +++ b/frontend/src/components/DashboardWidget.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/frontend/src/views/home/LoggedInView.vue b/frontend/src/views/home/LoggedInView.vue index c72236a..3018948 100644 --- a/frontend/src/views/home/LoggedInView.vue +++ b/frontend/src/views/home/LoggedInView.vue @@ -1,67 +1,368 @@ \ No newline at end of file + +.dashboard-message { + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; +} + +.dashboard-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; +} + +.dashboard-grid-cell { + min-height: 200px; +} + +.dashboard-grid-cell.drop-target { + outline: 2px dashed #0d6efd; + outline-offset: 4px; + border-radius: 8px; +} + +.dashboard-widget-edit { + min-height: 200px; + padding: 12px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.widget-edit-fields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.widget-edit-input { + padding: 8px 10px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 0.9rem; +} + +.widget-edit-endpoint { + font-family: monospace; + font-size: 0.85rem; +} + +.btn-remove { + align-self: flex-start; + padding: 6px 12px; + border: 1px solid #dc3545; + background: #fff; + color: #dc3545; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.btn-remove:hover { + background: #dc3545; + color: #fff; +} + +.dashboard-empty { + padding: 32px; + text-align: center; + color: #666; + background: #f8f9fa; + border-radius: 8px; + border: 1px dashed #dee2e6; +} + +.actions { + margin-top: 30px; +} +