Built a fckn cdn

This commit is contained in:
Ipmake 2023-12-05 00:00:08 +01:00
parent 543aca02ba
commit 5ce485e924
19 changed files with 1769 additions and 589 deletions

View File

@ -4,6 +4,7 @@ import dotenv from 'dotenv'
import init from './init'
import Errors, { authorize } from './functions'
import fs from 'fs'
import crypto from 'crypto'
dotenv.config()
@ -67,13 +68,15 @@ app.get('/api/auth/verify', async (req, res) => {
app.post("/api/article/create", async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const { title, content, image } = req.body
if(!title || !content || !image) return res.status(400).send(Errors.MISSING_ITEMS)
const { title, content, image, sponsors } = req.body
if (!title || !content || !image) return res.status(400).send(Errors.MISSING_ITEMS)
// sponsors is an array of sponsor IDs
const article = await prisma.articles.create({
data: {
@ -83,6 +86,11 @@ app.post("/api/article/create", async (req, res) => {
ID: user.ID
}
},
sponsors: {
connect: sponsors.map((sponsor: string) => ({
ID: sponsor
}))
}
}
})
@ -95,13 +103,15 @@ app.post("/api/article/create", async (req, res) => {
app.post("/api/article/edit/:id", async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const { title, content, image } = req.body
if(!title || !content || !image || !req.params.id) return res.status(400).send(Errors.MISSING_ITEMS)
const { title, content, image, sponsors } = req.body
if (!title || !content || !image || !req.params.id) return res.status(400).send(Errors.MISSING_ITEMS)
// sponsors is an array of sponsor IDs
const article = await prisma.articles.update({
where: {
@ -109,11 +119,17 @@ app.post("/api/article/edit/:id", async (req, res) => {
},
data: {
title,
sponsors: {
set: sponsors.map((sponsor: string) => ({
ID: sponsor
}))
}
}
})
fs.writeFileSync(`/var/lib/rheinefuerrheine/artikel/${article.ID}.html`, content)
fs.writeFileSync(`/var/lib/rheinefuerrheine/artikel/${article.ID}.banner`, image);
if (image !== "stale") fs.writeFileSync(`/var/lib/rheinefuerrheine/artikel/${article.ID}.banner`, image);
res.send(article)
})
@ -142,7 +158,9 @@ app.get('/api/article/view/:id', async (req, res) => {
createdAt: true,
updatedAt: true,
}
})
}).catch(() => null)
if (!article) return res.status(404).send(Errors.NOT_FOUND)
res.send(article)
})
@ -155,10 +173,10 @@ app.get("/api/article/content/:id", async (req, res) => {
app.get("/api/allArticles", async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const articles = await prisma.articles.findMany({
select: {
@ -218,10 +236,10 @@ app.get('/api/articles/public', async (req, res) => {
app.delete('/api/article/:article', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const article = await prisma.articles.findUnique({
where: {
@ -244,9 +262,9 @@ app.delete('/api/article/:article', async (req, res) => {
}
})
if(!article) return res.status(404).send(Errors.NOT_FOUND)
if (!article) return res.status(404).send(Errors.NOT_FOUND)
if((!user.admin && !user.article_manage) && article?.author.ID !== user.ID) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if ((!user.admin && !user.article_manage) && article?.author.ID !== user.ID) return res.status(401).send(Errors.INVALID_CREDENTIALS)
await prisma.articles.delete({
where: {
@ -255,33 +273,41 @@ app.delete('/api/article/:article', async (req, res) => {
})
fs.rmSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.html`)
if(fs.existsSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)) fs.rmSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)
if (fs.existsSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)) fs.rmSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)
res.send('OK')
})
app.post('/api/article/banner/:article', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
// app.post('/api/article/banner/:article', async (req, res) => {
// const token = req.headers.authorization?.split(' ')[1]
if(!token || !req.body) return res.status(401).send(Errors.INVALID_CREDENTIALS)
// if(!token || !req.body) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
// const user = await authorize(token)
// if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
fs.writeFileSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`, req.body.data);
// fs.writeFileSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`, req.body.data);
res.send('OK')
})
// res.send('OK')
// })
app.get('/api/article/banner/:article', async (req, res) => {
if(!fs.existsSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)) return res.status(404).send(Errors.NOT_FOUND)
if (!fs.existsSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)) return res.status(404).send(Errors.NOT_FOUND)
res.sendFile(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)
const content = fs.readFileSync(`/var/lib/rheinefuerrheine/artikel/${req.params.article}.banner`)
// its a base64 encoded image. The type is appended at the beginning of the string (e.g. data:image/png;base64,....) Extract the type and send it as content type header and the rest as the body
const type = content.toString().split(';')[0].split(':')[1]
res.setHeader('Content-Type', type)
res.send(Buffer.from(content.toString().split(';base64,')[1], 'base64'))
})
app.get('/api/sponsors/getAll', async (req, res) => {
app.get('/api/sponsors/', async (req, res) => {
const sponsors = await prisma.sponsors.findMany({
orderBy: {
addedAt: 'desc'
}
})
res.send(sponsors)
@ -290,19 +316,20 @@ app.get('/api/sponsors/getAll', async (req, res) => {
app.post('/api/sponsors/create', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const { name, description, url, logo, banner } = req.body
if(!name || !description || !url || !logo || !banner) return res.status(400).send(Errors.MISSING_ITEMS)
if (!name || !description || !url || !logo || !banner) return res.status(400).send(Errors.MISSING_ITEMS)
const sponsor = await prisma.sponsors.create({
data: {
name,
description,
url,
url
}
})
@ -315,13 +342,13 @@ app.post('/api/sponsors/create', async (req, res) => {
app.patch('/api/sponsors/edit/:id', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const { name, description, url, logo, banner } = req.body
if(!name || !description || !url || !logo || !banner) return res.status(400).send(Errors.MISSING_ITEMS)
if (!name || !description || !url) return res.status(400).send(Errors.MISSING_ITEMS)
const sponsor = await prisma.sponsors.update({
where: {
@ -334,8 +361,8 @@ app.patch('/api/sponsors/edit/:id', async (req, res) => {
}
})
fs.writeFileSync(`/var/lib/rheinefuerrheine/sponsoren/${sponsor.ID}.logo`, logo)
fs.writeFileSync(`/var/lib/rheinefuerrheine/sponsoren/${sponsor.ID}.banner`, banner)
if (logo) fs.writeFileSync(`/var/lib/rheinefuerrheine/sponsoren/${sponsor.ID}.logo`, logo)
if (banner) fs.writeFileSync(`/var/lib/rheinefuerrheine/sponsoren/${sponsor.ID}.banner`, banner)
res.send(sponsor)
})
@ -343,10 +370,10 @@ app.patch('/api/sponsors/edit/:id', async (req, res) => {
app.delete('/api/sponsors/delete/:id', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
await prisma.sponsors.delete({
where: {
@ -360,15 +387,49 @@ app.delete('/api/sponsors/delete/:id', async (req, res) => {
res.send('OK')
})
app.get('/api/users/all', async (req, res) => {
app.get('/api/sponsors/:id', async (req, res) => {
const sponsor = await prisma.sponsors.findUnique({
where: {
ID: req.params.id
}
})
if (!sponsor) return res.status(404).send(Errors.NOT_FOUND)
res.send(sponsor)
})
app.get('/api/sponsors/logo/:id', async (req, res) => {
if (!fs.existsSync(`/var/lib/rheinefuerrheine/sponsoren/${req.params.id}.logo`)) return res.status(404).send(Errors.NOT_FOUND)
const content = fs.readFileSync(`/var/lib/rheinefuerrheine/sponsoren/${req.params.id}.logo`)
// its a base64 encoded image. The type is appended at the beginning of the string (e.g. data:image/png;base64,....) Extract the type and send it as content type header and the rest as the body
const type = content.toString().split(';')[0].split(':')[1]
res.setHeader('Content-Type', type)
res.send(Buffer.from(content.toString().split(';base64,')[1], 'base64'))
})
app.get('/api/sponsors/banner/:id', async (req, res) => {
if (!fs.existsSync(`/var/lib/rheinefuerrheine/sponsoren/${req.params.id}.banner`)) return res.status(404).send(Errors.NOT_FOUND)
const content = fs.readFileSync(`/var/lib/rheinefuerrheine/sponsoren/${req.params.id}.banner`)
// its a base64 encoded image. The type is appended at the beginning of the string (e.g. data:image/png;base64,....) Extract the type and send it as content type header and the rest as the body
const type = content.toString().split(';')[0].split(':')[1]
res.setHeader('Content-Type', type)
res.send(Buffer.from(content.toString().split(';base64,')[1], 'base64'))
})
app.get('/api/users', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if(!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if(!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if(!user.admin && !user.user_manage) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user.admin && !user.user_manage) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const users = await prisma.users.findMany({
select: {
@ -385,6 +446,139 @@ app.get('/api/users/all', async (req, res) => {
res.send(users)
})
app.get('/api/user/:id', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user.admin && !user.user_manage) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const foundUser = await prisma.users.findUnique({
where: {
ID: req.params.id
},
select: {
ID: true,
username: true,
admin: true,
article_create: true,
article_manage: true,
sponsor_manage: true,
user_manage: true,
}
})
if (!foundUser) return res.status(404).send(Errors.NOT_FOUND)
res.send(foundUser)
})
app.post('/api/users/create', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token || !req.body) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user.admin && !user.user_manage) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const { username, password, admin, article_create, article_manage, sponsor_manage, user_manage } = req.body
if (!username || !password || admin === undefined || article_create === undefined || article_manage === undefined || sponsor_manage === undefined || user_manage === undefined) return res.status(400).send(Errors.MISSING_ITEMS)
const newUser = await prisma.users.create({
data: {
username,
password,
token: crypto.randomBytes(64).toString('hex'),
admin,
article_create,
article_manage,
sponsor_manage,
user_manage,
}
})
res.send(newUser)
})
app.patch('/api/users/edit/:id', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token || !req.body) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user.admin && !user.user_manage) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const { username, password, admin, article_create, article_manage, sponsor_manage, user_manage } = req.body
if (!username || admin === undefined || article_create === undefined || article_manage === undefined || sponsor_manage === undefined || user_manage === undefined) return res.status(400).send(Errors.MISSING_ITEMS)
const newUser = await prisma.users.update({
where: {
ID: req.params.id
},
data: {
username,
admin,
article_create,
article_manage,
sponsor_manage,
user_manage,
...(password && { password })
}
})
res.send(newUser)
})
app.delete('/api/users/delete/:id', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
if (!user.admin && !user.user_manage) return res.status(401).send(Errors.INVALID_CREDENTIALS)
await prisma.users.delete({
where: {
ID: req.params.id
}
})
res.send('OK')
})
app.get('/api/stats', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const user = await authorize(token)
if (!user) return res.status(401).send(Errors.INVALID_CREDENTIALS)
const articles = await prisma.articles.count()
const users = await prisma.users.count()
const sponsors = await prisma.sponsors.count()
const views = await prisma.articles.aggregate({
_sum: {
views: true
}
})
res.send({
articles,
users,
sponsors,
views: views._sum.views
})
})
app.listen(process.env.PORT, () => {
console.log(`Server is running on port ${process.env.PORT}`)
})

View File

@ -13,6 +13,7 @@
"@fontsource-variable/lexend": "^5.0.12",
"@fontsource-variable/overpass": "^5.0.9",
"@mui/icons-material": "^5.14.9",
"@mui/lab": "^5.0.0-alpha.154",
"@mui/material": "^5.14.10",
"@types/node": "^16.18.52",
"@types/react": "^18.2.22",
@ -1948,9 +1949,9 @@
"integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA=="
},
"node_modules/@babel/runtime": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.15.tgz",
"integrity": "sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==",
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz",
"integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -2542,9 +2543,9 @@
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.2.tgz",
"integrity": "sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz",
"integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==",
"dependencies": {
"@floating-ui/dom": "^1.5.1"
},
@ -3383,6 +3384,77 @@
}
}
},
"node_modules/@mui/lab": {
"version": "5.0.0-alpha.154",
"resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.154.tgz",
"integrity": "sha512-Rrhu8eUknjV6hhPMqq52e/p4/c6rvnu/k0AhysuljsHDZcHThYEZNe1mHFLveQ1RIje2VnJSsgmcNfcZKeOOAg==",
"dependencies": {
"@babel/runtime": "^7.23.4",
"@mui/base": "5.0.0-beta.25",
"@mui/system": "^5.14.19",
"@mui/types": "^7.2.10",
"@mui/utils": "^5.14.19",
"clsx": "^2.0.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@mui/material": ">=5.10.11",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/lab/node_modules/@mui/base": {
"version": "5.0.0-beta.25",
"resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.25.tgz",
"integrity": "sha512-Iiv+IcappRRv6IBlknIVmLkXxfp51NEX1+l9f+dIbBuPU4PaRULegr1lCeHKsC45KU5ruxM5xMg4R/de03aJQg==",
"dependencies": {
"@babel/runtime": "^7.23.4",
"@floating-ui/react-dom": "^2.0.4",
"@mui/types": "^7.2.10",
"@mui/utils": "^5.14.19",
"@popperjs/core": "^2.11.8",
"clsx": "^2.0.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/material": {
"version": "5.14.10",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-5.14.10.tgz",
@ -3433,12 +3505,12 @@
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
},
"node_modules/@mui/private-theming": {
"version": "5.14.10",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.10.tgz",
"integrity": "sha512-f67xOj3H06wWDT9xBg7hVL/HSKNF+HG1Kx0Pm23skkbEqD2Ef2Lif64e5nPdmWVv+7cISCYtSuE2aeuzrZe78w==",
"version": "5.14.19",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.14.19.tgz",
"integrity": "sha512-U9w39VpXLGVM8wZlUU/47YGTsBSk60ZQRRxQZtdqPfN1N7OVllQeN4cEKZKR8PjqqR3aYRcSciQ4dc6CttRoXQ==",
"dependencies": {
"@babel/runtime": "^7.22.15",
"@mui/utils": "^5.14.10",
"@babel/runtime": "^7.23.4",
"@mui/utils": "^5.14.19",
"prop-types": "^15.8.1"
},
"engines": {
@ -3446,7 +3518,7 @@
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
@ -3459,11 +3531,11 @@
}
},
"node_modules/@mui/styled-engine": {
"version": "5.14.10",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.10.tgz",
"integrity": "sha512-EJckxmQHrsBvDbFu1trJkvjNw/1R7jfNarnqPSnL+jEQawCkQIqVELWLrlOa611TFtxSJGkdUfCFXeJC203HVg==",
"version": "5.14.19",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.14.19.tgz",
"integrity": "sha512-jtj/Pyn/bS8PM7NXdFNTHWZfE3p+vItO4/HoQbUeAv3u+cnWXcTBGHHY/xdIn446lYGFDczTh1YyX8G4Ts0Rtg==",
"dependencies": {
"@babel/runtime": "^7.22.15",
"@babel/runtime": "^7.23.4",
"@emotion/cache": "^11.11.0",
"csstype": "^3.1.2",
"prop-types": "^15.8.1"
@ -3473,7 +3545,7 @@
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
@ -3490,15 +3562,15 @@
}
},
"node_modules/@mui/system": {
"version": "5.14.10",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.14.10.tgz",
"integrity": "sha512-QQmtTG/R4gjmLiL5ECQ7kRxLKDm8aKKD7seGZfbINtRVJDyFhKChA1a+K2bfqIAaBo1EMDv+6FWNT1Q5cRKjFA==",
"version": "5.14.19",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-5.14.19.tgz",
"integrity": "sha512-4e3Q+2nx+vgEsd0h5ftxlZGB7XtkkPos/zWqCqnxUs1l/T70s0lF2YNrWHHdSQ7LgtBu0eQ0qweZG2pR7KwkAw==",
"dependencies": {
"@babel/runtime": "^7.22.15",
"@mui/private-theming": "^5.14.10",
"@mui/styled-engine": "^5.14.10",
"@mui/types": "^7.2.4",
"@mui/utils": "^5.14.10",
"@babel/runtime": "^7.23.4",
"@mui/private-theming": "^5.14.19",
"@mui/styled-engine": "^5.14.19",
"@mui/types": "^7.2.10",
"@mui/utils": "^5.14.19",
"clsx": "^2.0.0",
"csstype": "^3.1.2",
"prop-types": "^15.8.1"
@ -3508,7 +3580,7 @@
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
@ -3529,11 +3601,11 @@
}
},
"node_modules/@mui/types": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz",
"integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==",
"version": "7.2.10",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.10.tgz",
"integrity": "sha512-wX1vbDC+lzF7FlhT6A3ffRZgEoKWPF8VqRoTu4lZwouFX2t90KyCMsgepMw5DxLak1BSp/KP86CmtZttikb/gQ==",
"peerDependencies": {
"@types/react": "*"
"@types/react": "^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
@ -3542,12 +3614,12 @@
}
},
"node_modules/@mui/utils": {
"version": "5.14.10",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.10.tgz",
"integrity": "sha512-Rn+vYQX7FxkcW0riDX/clNUwKuOJFH45HiULxwmpgnzQoQr3A0lb+QYwaZ+FAkZrR7qLoHKmLQlcItu6LT0y/Q==",
"version": "5.14.19",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.14.19.tgz",
"integrity": "sha512-qAHvTXzk7basbyqPvhgWqN6JbmI2wLB/mf97GkSlz5c76MiKYV6Ffjvw9BjKZQ1YRb8rDX9kgdjRezOcoB91oQ==",
"dependencies": {
"@babel/runtime": "^7.22.15",
"@types/prop-types": "^15.7.5",
"@babel/runtime": "^7.23.4",
"@types/prop-types": "^15.7.11",
"prop-types": "^15.8.1",
"react-is": "^18.2.0"
},
@ -3556,7 +3628,7 @@
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui"
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
@ -4227,9 +4299,9 @@
"integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA=="
},
"node_modules/@types/prop-types": {
"version": "15.7.6",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.6.tgz",
"integrity": "sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg=="
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
},
"node_modules/@types/q": {
"version": "1.5.6",

View File

@ -8,6 +8,7 @@
"@fontsource-variable/lexend": "^5.0.12",
"@fontsource-variable/overpass": "^5.0.9",
"@mui/icons-material": "^5.14.9",
"@mui/lab": "^5.0.0-alpha.154",
"@mui/material": "^5.14.10",
"@types/node": "^16.18.52",
"@types/react": "^18.2.22",

View File

@ -1,6 +1,6 @@
import { Box, Divider } from "@mui/material"
import SidebarElement from "./SidebarElement"
import { Home, Newspaper, Person, Savings } from "@mui/icons-material"
import { ArrowBack, Home, Logout, Newspaper, Person, Savings } from "@mui/icons-material"
function Sidebar() {
@ -38,6 +38,7 @@ function Sidebar() {
<Box sx={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
@ -49,6 +50,11 @@ function Sidebar() {
<SidebarElement Title="Artikel" Icon={Newspaper} Path="/admin/artikel" />
<SidebarElement Title="Sponsoren" Icon={Savings} Path="/admin/sponsoren" />
<SidebarElement Title="Benutzer" Icon={Person} Path="/admin/benutzer" />
<SidebarElement Title="Zur Website" Icon={ArrowBack} Path="/" sx={{
mt: "auto",
}} />
<SidebarElement Title="Logout" Icon={Logout} Path="/admin/logout" />
</Box>
</Box>
)

View File

@ -1,4 +1,4 @@
import { Box, SvgIconTypeMap, Typography } from "@mui/material";
import { Box, SvgIconTypeMap, SxProps, Theme, Typography } from "@mui/material";
import { OverridableComponent } from "@mui/material/OverridableComponent";
import { useLocation, useNavigate } from "react-router-dom";
@ -6,12 +6,14 @@ function SidebarElement({
Title,
Icon,
Path,
sx
}: {
Title: string;
Icon: OverridableComponent<SvgIconTypeMap<{}, "svg">> & {
muiName: string;
};
Path: string;
sx?: SxProps<Theme>;
}) {
const navigate = useNavigate();
const location = useLocation();
@ -38,6 +40,7 @@ function SidebarElement({
gap: "25px",
background: (theme) => theme.palette.primary.main,
},
...sx
}}
onClick={() => navigate(Path)}
>

View File

@ -1,157 +0,0 @@
import { Avatar, Box, Typography } from "@mui/material";
import { useRef, useState } from "react";
function SponsorCard() {
const logoRef = useRef<HTMLInputElement>(null);
const bannerRef = useRef<HTMLInputElement>(null);
const [logo, setLogo] = useState<string>("");
const [banner, setBanner] = useState<string>("https://placehold.co/500x150");
return (
<>
<input
type="file"
ref={logoRef}
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
setLogo(reader.result as string);
};
}}
onAbort={() => {}}
required
/>
<input
type="file"
ref={bannerRef}
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
setBanner(reader.result as string);
};
}}
onAbort={() => {}}
required
/>
<Box
sx={{
width: "500px",
height: "300px",
backgroundColor: "#fff",
// clip the edges using a clip path
clipPath: "polygon(0 0, 100% 0, 100% 100%, 0% 100%)",
}}
>
<img
src={banner}
alt="banner"
style={{
width: "500px",
height: "150px",
objectFit: "cover",
minWidth: "500px",
minHeight: "150px",
cursor: "crosshair",
}}
onClick={() => bannerRef.current?.click()}
/>
<Box
sx={{
mt: "-75px",
width: "500px",
height: "225px",
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "flex-start",
}}
>
<Avatar
variant="square"
src={logo}
sx={{
width: "150px",
height: "150px",
borderRadius: "20px",
ml: "25px",
cursor: "crosshair",
}}
onClick={() => {
logoRef.current?.click();
}}
/>
<Box
sx={{
width: "300px",
height: "175px",
background: "#fff",
mt: "35px",
ml: "25px",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "flex-start",
borderTopLeftRadius: "13px",
}}
>
<Typography
contentEditable
sx={{
fontFamily: "Lexend Variable",
fontSize: "38px",
fontWeight: 800,
fontStyle: "italic",
ml: "10px",
}}
>
Felix Orgel
</Typography>
<Typography
contentEditable
sx={{
fontFamily: "Lexend Variable",
fontSize: "16px",
fontWeight: 200,
fontStyle: "italic",
color: "#828282",
ml: "10px",
}}
>
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
nonumy eirmod tempor invidunt u
</Typography>
</Box>
</Box>
</Box>
</>
);
}
export default SponsorCard;

View File

@ -0,0 +1,270 @@
import { Avatar, Box, Button, TextField } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { getBaseURL } from "../functions";
function SponsorCardEditable({
onSave,
edit,
}: {
onSave: (result: {
name: string,
description: string,
logo: string | null,
banner: string | null,
url: string,
}) => void;
edit?: {
id: string,
name: string,
description: string,
url: string,
};
}) {
const logoRef = useRef<HTMLInputElement>(null);
const bannerRef = useRef<HTMLInputElement>(null);
const [logo, setLogo] = useState<string>("https://placehold.co/150x150");
const [banner, setBanner] = useState<string>("https://placehold.co/500x150");
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [url, setUrl] = useState<string>("");
useEffect(() => {
if (!edit) return;
setName(edit.name);
setDescription(edit.description);
setUrl(edit.url);
setLogo(`${getBaseURL()}/api/sponsors/logo/${edit.id}`);
setBanner(`${getBaseURL()}/api/sponsors/banner/${edit.id}`);
}, [edit]);
const valid = () => {
if (!name) return false;
if (!description) return false;
if (!logo) return false;
if (!banner) return false;
if (!url) return false;
if (logo.startsWith("https://placehold.co/")) return false;
if (banner.startsWith("https://placehold.co/")) return false;
return true;
};
return (
<>
<input
type="file"
ref={logoRef}
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
setLogo(reader.result as string);
};
}}
onAbort={() => {}}
required
/>
<input
type="file"
ref={bannerRef}
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
setBanner(reader.result as string);
};
}}
onAbort={() => {}}
required
/>
<Box
sx={{
width: "500px",
height: "300px",
backgroundColor: "#FFF",
borderRadius: "13px",
border: "1px solid #00000033",
// clip the corners
overflow: "hidden",
}}
>
<img
src={banner}
alt="banner"
style={{
width: "500px",
height: "150px",
objectFit: "cover",
minWidth: "500px",
minHeight: "150px",
cursor: "crosshair",
}}
onClick={() => bannerRef.current?.click()}
/>
<Box
sx={{
mt: "-75px",
width: "500px",
height: "225px",
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "flex-start",
}}
>
<Avatar
variant="square"
src={logo}
sx={{
width: "150px",
height: "150px",
borderRadius: "20px",
ml: "25px",
cursor: "crosshair",
}}
onClick={() => {
logoRef.current?.click();
}}
/>
<Box
sx={{
width: "300px",
height: "175px",
background: "#fff",
mt: "35px",
ml: "25px",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "flex-start",
borderTopLeftRadius: "13px",
}}
>
<TextField
multiline
margin="none"
variant="standard"
fullWidth
value={name}
placeholder="Name"
sx={{
ml: "10px",
width: "280px",
"& .MuiInputBase-root": {
fontFamily: "Lexend Variable",
fontSize: "38px",
fontWeight: 800,
fontStyle: "italic",
},
}}
onChange={(e) => {
if (e.target.value.length >= 12) return;
if (e.target.value.includes("\n")) return;
setName(e.target.value);
}}
/>
<TextField
multiline
margin="none"
variant="standard"
fullWidth
value={description}
placeholder="Beschreibung"
sx={{
height: "90px",
minHeight: "90px",
width: "280px",
"& .MuiInputBase-root": {
display: "inline",
fontFamily: "Lexend Variable",
fontSize: "16px",
fontWeight: 200,
fontStyle: "italic",
color: "#828282",
ml: "10px",
height: "90px",
minHeight: "90px",
textAlign: "left",
verticalAlign: "top",
},
}}
onChange={(e) => {
if (e.target.value.length >= 100) return;
if (e.target.value.includes("\n")) return;
setDescription(e.target.value);
}}
/>
</Box>
</Box>
</Box>
<TextField
margin="none"
variant="standard"
fullWidth
value={url}
placeholder="URL"
sx={{
mt: "20px",
width: "80%",
}}
onChange={(e) => {
if (e.target.value.length >= 128) return;
if (e.target.value.includes("\n")) return;
setUrl(e.target.value);
}}
/>
<Button
variant="contained"
sx={{ width: "80%" }}
disabled={!valid()}
onClick={() => {
onSave({
name,
description,
logo,
banner,
url,
});
}}
>
Speichern
</Button>
</>
);
}
export default SponsorCardEditable;

View File

@ -0,0 +1,53 @@
import { Box, Grid, Avatar } from "@mui/material";
export function SponsorImageSmall({
name,
image,
description,
link,
onRemove,
}: {
name: string;
image: string;
description: string;
link: string;
onRemove?: () => void;
}): JSX.Element {
return (
<Grid item>
<Box
sx={{
justifyContent: "center",
alignItems: "center",
}}
>
<Avatar
src={image}
alt=""
sx={{
height: "75px",
width: "75px",
borderRadius: "20px",
...(onRemove && {
cursor: "crosshair",
"&:hover": {
filter: "brightness(0.5)",
},
}),
...(!onRemove && {
cursor: "pointer",
}),
}}
onClick={
onRemove ||
(() => {
window.open(link, "_blank");
})
}
/>
</Box>
</Grid>
);
}

View File

@ -2,12 +2,14 @@ import { Close } from "@mui/icons-material";
import {
Backdrop,
Box,
CircularProgress,
IconButton,
TextField,
Typography,
} from "@mui/material";
import { useState } from "react";
import SponsorCard from "./SponsorCard";
import { useEffect, useState } from "react";
import SponsorCardEditable from "./SponsorCardEditable";
import axios from "axios";
import { getBaseURL } from "../functions";
function SponsorModal({
id,
@ -16,8 +18,73 @@ function SponsorModal({
id: string | null;
onClose?: () => void;
}) {
const [saving, setSaving] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<Types.Sponsor | null>(null);
useEffect(() => {
setData(null);
if(!id) return;
if (id === "create") return;
setLoading(true);
axios
.get(`${getBaseURL()}/api/sponsors/${id}`)
.then((res) => {
setData(res.data);
setLoading(false);
})
.catch(() => {
onClose?.();
});
}, [id]);
if (!id) return <></>;
if (loading)
return (
<Backdrop
open={true}
sx={{
zIndex: 2000,
flexDirection: "column",
}}
>
<CircularProgress />
<Typography
sx={{
fontFamily: "Lexend Variable",
fontSize: "20px",
fontWeight: "bold",
color: "#fff",
}}
>
Lade...
</Typography>
</Backdrop>
);
return (
<>
{saving && (
<Backdrop
open={true}
sx={{
zIndex: 2000,
flexDirection: "column",
}}
>
<CircularProgress />
<Typography
sx={{
fontFamily: "Lexend Variable",
fontSize: "20px",
fontWeight: "bold",
color: "#fff",
}}
>
Speichere...
</Typography>
</Backdrop>
)}
<Backdrop
open={true}
sx={{
@ -64,16 +131,79 @@ function SponsorModal({
textAlign: "center",
}}
>
Sponsor Erstellen
Sponsor { id === "create" ? "Erstellen" : "Bearbeiten" }
</Typography>
<IconButton onClick={onClose}>
<Close />
</IconButton>
</Box>
<SponsorCard />
<SponsorCardEditable
edit={(id && id !== "create" && {
id: id,
name: data?.name as string,
description: data?.description as string,
url: data?.url as string,
}) || undefined}
onSave={async (result) => {
setSaving(true);
if (id === "create") {
axios
.post(
`${getBaseURL()}/api/sponsors/create`,
{
name: result.name,
description: result.description,
url: result.url,
logo: result.logo,
banner: result.banner,
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem(
"token"
)}`,
},
}
)
.then(() => {
window.location.reload();
});
} else {
result.logo = result.logo?.includes("http")
? null
: result.logo;
result.banner = result.banner?.includes("http")
? null
: result.banner;
axios
.patch(
`${getBaseURL()}/api/sponsors/edit/${id}`,
{
name: result.name,
description: result.description,
url: result.url,
logo: result.logo,
banner: result.banner,
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem(
"token"
)}`,
},
}
)
.then(() => {
window.location.reload();
});
}
}}
/>
</Box>
</Backdrop>
</>
);
}

View File

@ -0,0 +1,382 @@
import { Close } from "@mui/icons-material";
import {
Backdrop,
Box,
Button,
Checkbox,
CircularProgress,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useEffect, useState } from "react";
import axios from "axios";
import { getBaseURL } from "../functions";
import { LoadingButton } from "@mui/lab";
import { sha256 } from "js-sha256";
function UserModal({
id,
onClose,
}: {
id: string | null;
onClose?: () => void;
}) {
const [saving, setSaving] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [username, setUsername] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [permissions, setPermissions] = useState<Types.UserPermissions>({
admin: false,
article_create: false,
article_manage: false,
sponsor_manage: false,
user_manage: false,
});
const valid = () => {
if (!username) return false;
if (!password && id === "create") return false;
return true;
};
useEffect(() => {
setUsername("");
setPassword("");
setPermissions({
admin: false,
article_create: false,
article_manage: false,
sponsor_manage: false,
user_manage: false,
});
if (!id) return;
if (id === "create") return;
setLoading(true);
axios
.get(`${getBaseURL()}/api/user/${id}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
})
.then((res) => {
setUsername(res.data.username);
setPermissions({
admin: res.data.admin,
article_create: res.data.article_create,
article_manage: res.data.article_manage,
sponsor_manage: res.data.sponsor_manage,
user_manage: res.data.user_manage,
});
setLoading(false);
});
}, [id]);
if (!id) return <></>;
if (loading)
return (
<Backdrop
open={true}
sx={{
zIndex: 2000,
flexDirection: "column",
}}
>
<CircularProgress />
<Typography
sx={{
fontFamily: "Lexend Variable",
fontSize: "20px",
fontWeight: "bold",
color: "#fff",
}}
>
Lade...
</Typography>
</Backdrop>
);
return (
<>
<Backdrop
open={true}
sx={{
zIndex: 2000,
}}
>
<Box
sx={{
width: "500px",
height: "auto",
padding: "10px",
backgroundColor: "#fff",
boxShadow: "0px 0px 10px #00000033",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: "10px",
}}
>
<Box
sx={{
width: "100%",
height: "auto",
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box
sx={{
width: "40px",
}}
/>
<Typography
sx={{
fontFamily: "Lexend Variable",
fontSize: "28px",
fontWeight: 700,
textAlign: "center",
}}
>
Benutzer {id === "create" ? "Erstellen" : "Bearbeiten"}
</Typography>
<IconButton onClick={onClose}>
<Close />
</IconButton>
</Box>
<TextField
label="Benutzername"
variant="outlined"
value={username}
onChange={(e) => {
if (e.target.value.length >= 64) return;
if (e.target.value.includes("\n")) return;
setUsername(e.target.value);
}}
sx={{
width: "80%",
}}
/>
<TextField
label="Passwort"
variant="outlined"
value={password}
type="password"
onChange={(e) => {
if (e.target.value.length >= 64) return;
if (e.target.value.includes("\n")) return;
setPassword(e.target.value);
}}
sx={{
width: "80%",
}}
/>
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
alignItems: "flex-start",
width: "80%",
mx: "20px",
}}
>
<Stack
direction="row"
spacing={1}
justifyContent="center"
alignItems="center"
>
<Checkbox
checked={permissions.admin}
onChange={(e) => {
setPermissions({
...permissions,
admin: e.target.checked,
});
}}
/>
Admin
</Stack>
<Stack
direction="row"
spacing={1}
justifyContent="center"
alignItems="center"
>
<Checkbox
disabled={permissions.admin}
checked={permissions.article_create}
onChange={(e) => {
setPermissions({
...permissions,
article_create: e.target.checked,
});
}}
/>
Artikel Erstellen
</Stack>
<Stack
direction="row"
spacing={1}
justifyContent="center"
alignItems="center"
>
<Checkbox
disabled={permissions.admin}
checked={permissions.article_manage}
onChange={(e) => {
setPermissions({
...permissions,
article_manage: e.target.checked,
});
}}
/>
Artikel Verwalten
</Stack>
<Stack
direction="row"
spacing={1}
justifyContent="center"
alignItems="center"
>
<Checkbox
disabled={permissions.admin}
checked={permissions.sponsor_manage}
onChange={(e) => {
setPermissions({
...permissions,
sponsor_manage: e.target.checked,
});
}}
/>
Sponsoren Verwalten
</Stack>
<Stack
direction="row"
spacing={1}
justifyContent="center"
alignItems="center"
>
<Checkbox
disabled={permissions.admin}
checked={permissions.user_manage}
onChange={(e) => {
setPermissions({
...permissions,
user_manage: e.target.checked,
});
}}
/>
Nutzer Verwalten
</Stack>
</Box>
<LoadingButton
variant="contained"
sx={{ width: "80%" }}
disabled={!valid()}
onClick={() => {
setSaving(true);
if (id === "create") {
axios
.post(
`${getBaseURL()}/api/users/create`,
{
username,
password: sha256(`rheine ${password.trim()} rheine`),
admin: permissions.admin,
article_create: permissions.admin
? false
: permissions.article_create,
article_manage: permissions.admin
? false
: permissions.article_manage,
sponsor_manage: permissions.admin
? false
: permissions.sponsor_manage,
user_manage: permissions.admin
? false
: permissions.user_manage,
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem(
"token"
)}`,
},
}
)
.then(() => {
setSaving(false);
onClose?.();
});
} else {
axios
.patch(
`${getBaseURL()}/api/users/edit/${id}`,
{
username,
password: password
? sha256(`rheine ${password.trim()} rheine`)
: undefined,
admin: permissions.admin,
article_create: permissions.admin
? false
: permissions.article_create,
article_manage: permissions.admin
? false
: permissions.article_manage,
sponsor_manage: permissions.admin
? false
: permissions.sponsor_manage,
user_manage: permissions.admin
? false
: permissions.user_manage,
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem(
"token"
)}`,
},
}
)
.then(() => {
setSaving(false);
onClose?.();
});
}
}}
loading={saving}
>
Speichern
</LoadingButton>
</Box>
</Backdrop>
</>
);
}
export default UserModal;

View File

@ -125,3 +125,6 @@ a {
text-decoration: none;
}
input[name="suneditor_image_radio"] {
display: none;
}

View File

@ -6,6 +6,7 @@ import Artikel from "./admin/Artikel";
import Sponsoren from "./admin/Sponsoren";
import Benutzer from "./admin/Benutzer";
import ArticleEditor from "./admin/ArticleEditor";
import Logout from "./admin/Logout";
function AdminFrame() {
return (
@ -34,6 +35,7 @@ function AdminFrame() {
<Route path="/benutzer" element={<Benutzer />} />
<Route path="/editor" element={<ArticleEditor />} />
<Route path="/editor/:id" element={<ArticleEditor />} />
<Route path="/logout" element={<Logout />} />
</Routes>
</Box>
</Box>

View File

@ -1,12 +1,20 @@
/* eslint-disable jsx-a11y/alt-text */
import { useParams } from "react-router-dom";
import TopBar from "../components/TopBar";
import { Box, CircularProgress, Grid, Typography } from "@mui/material";
import {
Box,
CircularProgress,
Grid,
Tooltip,
Typography,
} from "@mui/material";
import { getBaseURL } from "../functions";
import { useEffect, useRef, useState } from "react";
import axios from "axios";
import "./Artikel.css";
import "suneditor/dist/css/suneditor.min.css";
import { SponsorImageSmall } from "../components/SponsorImageSmall";
function Artikel() {
const ref = useRef<HTMLObjectElement>(null);
@ -16,25 +24,10 @@ function Artikel() {
const [loadingContent, setLoadingContent] = useState(true);
const [article, setArticle] = useState<Types.Article | null>(null);
const [banner, setBanner] = useState<string>("");
const loadbanner = () => {
axios
.get(`${getBaseURL()}/api/article/banner/${id}`)
.then((response) => {
setBanner(response.data);
})
.catch((err) => {
console.log(err);
setBanner("https://placehold.co/1920x1080");
});
};
useEffect(() => {
if (!id) return;
loadbanner();
axios.get(`${getBaseURL()}/api/article/content/${id}`).then((response) => {
if (ref.current) {
ref.current.innerHTML = response.data;
@ -64,7 +57,8 @@ function Artikel() {
<TopBar />
{loadingContent && <CircularProgress />}
{!loadingContent && (
<Box sx={{
<Box
sx={{
height: "100vh",
width: "100vw",
position: "fixed",
@ -75,9 +69,10 @@ function Artikel() {
justifyContent: "center",
alignItems: "center",
zIndex: -1,
}}>
}}
>
<img
src={banner}
src={`${getBaseURL()}/api/article/banner/${id}`}
alt=""
style={{
height: "100vh",
@ -143,7 +138,26 @@ function Artikel() {
{article?.title}
</Typography>
<Grid container sx={{}}></Grid>
<Grid
container
spacing={2}
sx={{
pb: "20px",
alignItems: "center",
justifyContent: "flex-start",
}}
>
{article?.sponsors.map((sponsor) => (
<Tooltip title={sponsor.name}>
<SponsorImageSmall
name={sponsor.name}
image={`${getBaseURL()}/api/sponsors/logo/${sponsor.ID}`}
description={sponsor.description}
link={sponsor.url}
/>
</Tooltip>
))}
</Grid>
<object
id="article-content"

View File

@ -17,11 +17,16 @@ import {
Typography,
Grid,
Backdrop,
Menu,
Autocomplete,
TextField,
Avatar,
} from "@mui/material";
import { useNavigate, useParams } from "react-router-dom";
import { AddBox } from "@mui/icons-material";
import { getBaseURL } from "../../functions";
import axios from "axios";
import { SponsorImageSmall } from "../../components/SponsorImageSmall";
function ArticleEditor() {
const navigate = useNavigate();
@ -44,25 +49,30 @@ function ArticleEditor() {
const [banner, setBanner] = useState<string>("");
const [sponsorsAvail, setSponsorsAvail] = useState<Types.Sponsor[]>([]);
const [sponsorsSelected, setSponsorsSelected] = useState<Types.Sponsor[]>([]);
const sponsorsSelectedRef = useRef<Types.Sponsor[]>([]);
sponsorsSelectedRef.current = sponsorsSelected;
const [sponsorMenuEl, setSponsorMenuEl] = useState<null | HTMLElement>(null);
const loadbanner = () => {
axios
.get(`${getBaseURL()}/api/article/banner/${id}`)
.then((response) => {
setBanner(response.data);
})
.catch((err) => {
console.log(err);
setBanner("https://placehold.co/1920x1080");
});
if (!id) setBanner("https://placehold.co/1920x1080");
else setBanner(`${getBaseURL()}/api/article/banner/${id}`);
};
const save = async (content: string) => {
const title = document.getElementById("article-title")?.innerText;
const banner = document.getElementById("articleBanner-content")?.getAttribute("src");
let banner = document
.getElementById("articleBanner-content")
?.getAttribute("src");
if (!title) return false;
if (!banner) return false;
console.log(content);
if (banner.startsWith("http")) banner = "stale";
setSaving(true);
const res = await axios
.post(
@ -71,6 +81,7 @@ function ArticleEditor() {
: `${getBaseURL()}/api/article/edit/${id}`,
{
title: title,
sponsors: sponsorsSelectedRef.current.map((e) => e.ID),
content: content,
image: banner,
},
@ -105,12 +116,12 @@ function ArticleEditor() {
if (!res.data) return;
document.getElementById("article-title")!.innerText = res.data.title;
setSponsorsSelected(res.data.sponsors);
axios.get(`${getBaseURL()}/api/article/content/${id}`).then((res) => {
if (!res.data) return;
editor.current?.setContents(res.data);
setTimeout(() => {
// get all images with a data-size property
const images = document.querySelectorAll("img[data-size]");
@ -125,11 +136,61 @@ function ArticleEditor() {
}, 500);
});
});
axios.get(`${getBaseURL()}/api/sponsors`).then((res) => {
if (!res.data) return;
setSponsorsAvail(res.data);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
return (
<>
<Menu
anchorEl={sponsorMenuEl}
open={Boolean(sponsorMenuEl)}
onClose={() => {
setSponsorMenuEl(null);
}}
>
<Autocomplete
options={sponsorsAvail.filter(e => !sponsorsSelected.map(e => e.ID).includes(e.ID))}
getOptionLabel={(option) => option.name}
renderOption={(props, option) => (
<Box
component="li"
sx={{ "& > img": { mr: 2, flexShrink: 0 } }}
{...props}
onClick={() => {
sponsorsSelected.push(option as Types.Sponsor);
setSponsorsSelected(sponsorsSelected);
setSponsorMenuEl(null);
}}
>
<Avatar
src={`${getBaseURL()}/api/sponsors/logo/${option.ID}`}
sx={{
width: "50px",
height: "50px",
borderRadius: "20px",
}}
/>
{option.name}
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
sx={{
width: "300px",
}}
fullWidth
label="Sponsoren"
/>
)}
/>
</Menu>
{loading && <CircularProgress />}
{!loading && (
<Box
@ -158,7 +219,13 @@ function ArticleEditor() {
style={{
filter: "brightness(0.5)",
height: "100vh",
transform: "translateX(125px)",
width: "100vw",
position: "absolute",
top: "0px",
zIndex: -1,
objectFit: "cover",
minWidth: "100vw",
minHeight: "100vh",
}}
/>
</Box>
@ -170,7 +237,7 @@ function ArticleEditor() {
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
@ -183,10 +250,11 @@ function ArticleEditor() {
required
/>
<Box sx={{
<Box
sx={{
zIndex: 3,
}}>
}}
>
<Backdrop
open={saving}
sx={{
@ -268,7 +336,7 @@ function ArticleEditor() {
mb: "20px",
}}
>
Lorem ipsum whatever
Titel
</Typography>
<Grid
@ -276,20 +344,23 @@ function ArticleEditor() {
spacing={2}
sx={{
pb: "20px",
alignItems: "center",
justifyContent: "flex-start",
}}
>
{sponsorsSelected.map((sponsor) => (
<SponsorImageSmall
name="Felix"
image="/logo.png"
description="Felix ist ein cooler Typ"
link="#"
/>
<SponsorImageSmall
name="Felix"
image="/AdvanTex.jpg"
description="Felix ist ein cooler Typ"
link="https://advantex.de/"
name={sponsor.name}
image={`${getBaseURL()}/api/sponsors/logo/${sponsor.ID}`}
description={sponsor.description}
link={sponsor.url}
onRemove={() => {
setSponsorsSelected(
sponsorsSelected.filter((e) => e.ID !== sponsor.ID)
);
}}
/>
))}
<Grid item>
<AddBox
@ -303,7 +374,9 @@ function ArticleEditor() {
transform: "scale(1.1)",
},
}}
onClick={() => {}}
onClick={(e) => {
setSponsorMenuEl(e.currentTarget as any);
}}
/>
</Grid>
</Grid>
@ -354,28 +427,4 @@ function ArticleEditor() {
export default ArticleEditor;
function SponsorImageSmall({
name,
image,
description,
link,
}: {
name: string;
image: string;
description: string;
link: string;
}): JSX.Element {
return (
<Grid item>
<Box sx={{}}>
<img
src={image}
alt=""
style={{
height: "25px",
}}
/>
</Box>
</Grid>
);
}

View File

@ -1,8 +1,4 @@
import {
ManageAccounts,
PersonAdd,
PersonRemove,
} from "@mui/icons-material";
import { ManageAccounts, PersonAdd, PersonRemove } from "@mui/icons-material";
import {
Box,
Button,
@ -17,17 +13,17 @@ import {
} from "@mui/material";
import axios from "axios";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getBaseURL } from "../../functions";
import UserModal from "../../components/UserModal";
function Benutzer() {
const navigate = useNavigate();
const [modalID, setModelID] = useState<string | null>(null);
const [users, setUsers] = useState<Types.User[]>([]);
useEffect(() => {
axios
.get(`${getBaseURL()}/api/users/all`, {
.get(`${getBaseURL()}/api/users`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
@ -41,6 +37,13 @@ function Benutzer() {
}, []);
return (
<>
<UserModal
id={modalID}
onClose={() => {
setModelID(null);
}}
/>
<Box
sx={{
width: "100%",
@ -84,10 +87,10 @@ function Benutzer() {
gap: "10px",
}}
onClick={() => {
navigate("/admin/sponsor/");
setModelID("create");
}}
>
<PersonAdd /> Neue Benutzer
<PersonAdd /> Neuer Benutzer
</Button>
</Box>
@ -110,7 +113,7 @@ function Benutzer() {
Name
</TableCell>
<TableCell align="right">Berechtigungen</TableCell>
<TableCell align="right">Optionen</TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -129,8 +132,7 @@ function Benutzer() {
.map((e) => e.name)
.join(", ")}
</TableCell>
<TableCell align="right" width="200px"></TableCell>
<TableCell align="right" width="60px">
<TableCell align="right">
<Box
sx={{
display: "flex",
@ -149,6 +151,9 @@ function Benutzer() {
backgroundColor: "#fff",
borderRadius: "5px",
}}
onClick={() => {
setModelID(user.ID);
}}
>
<ManageAccounts />
</IconButton>
@ -165,6 +170,21 @@ function Benutzer() {
background: "red",
},
}}
onClick={() => {
axios
.delete(`${getBaseURL()}/api/users/delete/${user.ID}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem(
"token"
)}`,
},
})
.then(() => {
setUsers(
users.filter((e) => e.ID !== user.ID)
);
});
}}
>
<PersonRemove />
</IconButton>
@ -175,6 +195,7 @@ function Benutzer() {
</TableBody>
</Table>
</Box>
</>
);
}

View File

@ -1,8 +1,34 @@
import { Visibility, Newspaper, Person, Savings } from "@mui/icons-material";
import { Box, Grid, SvgIconTypeMap, Typography } from "@mui/material";
import { OverridableComponent } from "@mui/material/OverridableComponent";
import axios from "axios";
import { useEffect, useState } from "react";
import { getBaseURL } from "../../functions";
function Dashboard() {
const [stats, setStats] = useState<{
users: number;
articles: number;
sponsors: number;
views: number;
}>({
users: 0,
articles: 0,
sponsors: 0,
views: 0,
});
useEffect(() => {
axios.get(`${getBaseURL()}/api/stats`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
}).then((response) => {
console.log(response.data);
setStats(response.data);
});
}, []);
return (
<Box
sx={{
@ -19,10 +45,10 @@ function Dashboard() {
}}
>
<Grid container spacing={1}>
<StatDisplay Title="Benutzer" Icon={Person} Value="0" />
<StatDisplay Title="Artikel" Icon={Newspaper} Value="0" />
<StatDisplay Title="Sponsoren" Icon={Savings} Value="0" />
<StatDisplay Title="Klicks" Icon={Visibility} Value="0" />
<StatDisplay Title="Benutzer" Icon={Person} Value={stats.users.toFixed(0)} />
<StatDisplay Title="Artikel" Icon={Newspaper} Value={stats.articles.toFixed(0)} />
<StatDisplay Title="Sponsoren" Icon={Savings} Value={stats.sponsors.toFixed(0)} />
<StatDisplay Title="Views" Icon={Visibility} Value={stats.views.toFixed(0)} />
</Grid>
</Box>
);
@ -49,7 +75,8 @@ function StatDisplay({
flexDirection: "column",
padding: "10px",
background: (theme) => `linear-gradient(45deg, ${theme.palette.secondary.light}, ${theme.palette.secondary.main})`,
background: (theme) =>
`linear-gradient(45deg, ${theme.palette.secondary.light}, ${theme.palette.secondary.main})`,
width: "100%",
}}
@ -74,14 +101,16 @@ function StatDisplay({
{Title}
</Typography>
<Box sx={{
<Box
sx={{
width: "50px",
height: "50px",
padding: "5px",
backgroundColor: "#fff",
borderRadius: "5px",
}}>
}}
>
<Icon
sx={{
fontSize: "40px",
@ -90,17 +119,21 @@ function StatDisplay({
/>
</Box>
</Box>
<Box sx={{
<Box
sx={{
display: "flex",
flexDirection: "row",
justifyContent: "flex-start",
alignItems: "flex-start",
}}>
<Typography sx={{
}}
>
<Typography
sx={{
fontFamily: "Lexend Variable",
fontSize: "40px",
color: "#000000FF",
}}>
}}
>
{Value}
</Typography>
</Box>

View File

@ -0,0 +1,31 @@
import { Backdrop, CircularProgress, Typography } from "@mui/material"
import { useEffect } from "react"
function Logout() {
useEffect(() => {
localStorage.removeItem("token")
setTimeout(() => {
window.location.href = "/login"
}, 1000)
}, [])
return (
<Backdrop open={true} sx={{
zIndex: 1000,
flexDirection: "column",
display: "flex",
}}>
<CircularProgress />
<Typography sx={{
fontFamily: "Lexend Variable",
fontSize: "20px",
fontWeight: "bold",
color: "#fff",
}}>
Logge aus...
</Typography>
</Backdrop>
)
}
export default Logout

View File

@ -15,21 +15,51 @@ import {
TableRow,
Typography,
Tooltip,
Avatar,
Backdrop,
CircularProgress,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import SponsorModal from "../../components/SponsorModal";
import { useState } from "react";
import { useEffect, useState } from "react";
import axios from "axios";
import { getBaseURL } from "../../functions";
import moment from "moment";
function Sponsoren() {
const navigate = useNavigate();
const [modalID, setModelID] = useState<string | null>(null);
const [sponsors, setSponsors] = useState<Types.Sponsor[]>([]);
const [loaded, setLoaded] = useState<boolean>(false);
useEffect(() => {
setLoaded(false);
axios.get(`${getBaseURL()}/api/sponsors/`).then((res) => {
setSponsors(res.data);
setLoaded(true);
});
}, []);
return (
<>
<SponsorModal id={modalID} onClose={() => {
<Backdrop
open={!loaded}
sx={{
zIndex: 2000,
color: "#fff",
}}
>
<CircularProgress
sx={{
color: "white",
}}
/>
</Backdrop>
<SponsorModal
id={modalID}
onClose={() => {
setModelID(null);
}} />
}}
/>
<Box
sx={{
width: "100%",
@ -95,20 +125,33 @@ function Sponsoren() {
>
<TableHead>
<TableRow>
<TableCell align="left" width="700px">
<TableCell align="left" width="50px">
{" "}
</TableCell>
<TableCell align="left" width="100%">
Name
</TableCell>
<TableCell align="right">Ansprechpartner</TableCell>
<TableCell align="right">Datum</TableCell>
<TableCell align="right">Optionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell align="left">RF Computer GMBH</TableCell>
<TableCell align="right">R. Fink</TableCell>
{sponsors.map((sponsor) => (
<TableRow key={sponsor.ID}>
<TableCell align="left" width="50px" padding="none">
<Avatar
src={`${getBaseURL()}/api/sponsors/logo/${sponsor.ID}`}
alt="logo"
style={{
width: "50px",
height: "50px",
borderRadius: "20px",
}}
/>
</TableCell>
<TableCell align="left">{sponsor.name}</TableCell>
<TableCell align="right" width="200px">
7 Nov. 2023
{moment(sponsor.addedAt).locale("de").format("ll")}
</TableCell>
<TableCell align="right" width="60px">
<Box
@ -130,6 +173,9 @@ function Sponsoren() {
backgroundColor: "#fff",
borderRadius: "5px",
}}
onClick={() => {
setModelID(sponsor.ID);
}}
>
<ManageAccounts />
</IconButton>
@ -148,6 +194,26 @@ function Sponsoren() {
background: "red",
},
}}
onClick={() => {
axios
.delete(
`${getBaseURL()}/api/sponsors/delete/${
sponsor.ID
}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem(
"token"
)}`,
},
}
)
.then(() => {
setSponsors(
sponsors.filter((s) => s.ID !== sponsor.ID)
);
});
}}
>
<DomainDisabledRounded />
</IconButton>
@ -155,6 +221,7 @@ function Sponsoren() {
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>

View File

@ -7,22 +7,28 @@ declare namespace Types {
ID: string;
username: string;
}
sponsors: {
ID: string;
name: string;
url: string;
description: string;
}[];
sponsors: Sponsor[];
updatedAt: Date;
createdAt: Date;
}
interface User {
interface User extends UserPermissions {
ID: string;
username: string;
}
interface UserPermissions {
admin: boolean;
article_create: boolean;
article_manage: boolean;
sponsor_manage: boolean;
user_manage: boolean;
}
interface Sponsor {
ID: string;
name: string;
url: string;
description: string;
addedAt: Date;
}
}