diff --git a/Readme.md b/README.md similarity index 97% rename from Readme.md rename to README.md index 8fd1d61..9c6bdf8 100644 --- a/Readme.md +++ b/README.md @@ -84,4 +84,4 @@ Glitch can use `.env` file, [documentation](https://help.glitch.com/hc/en-us/art ## License -[MIT License](./LICENSE) +[MIT License](./LICENSE), excluding all themes diff --git a/assets/img/back-to-top.png b/assets/img/back-to-top.png new file mode 100644 index 0000000..2aec0d6 Binary files /dev/null and b/assets/img/back-to-top.png differ diff --git a/assets/style.css b/assets/style.css index da69e7a..0fdae66 100644 --- a/assets/style.css +++ b/assets/style.css @@ -34,6 +34,69 @@ h2, h3, h4, h5 { margin-bottom: .6em; } +.back-to-top { + position: fixed; + z-index: 2; + right: -108px; + bottom: 0; + width: 108px; + height: 150px; + background: url('./img/back-to-top.png?v=1') no-repeat 0 0; /* artwork from https://www.pixiv.net/artworks/83996495 */ + background-size: 108px 450px; + opacity: 0.6; + transition: opacity 0.3s, right 0.8s; + cursor: pointer; +} +.back-to-top:hover { + background-position: 0 -150px; + opacity: 1; +} +.back-to-top.load { + right: 0; +} +.back-to-top.ani-leave { + background-position: 0 -150px; + animation: ani-leave 390ms ease-in-out; + animation-fill-mode: forwards; +} +.back-to-top.leaved { + pointer-events: none; + background: none; + transition: none; +} +.back-to-top.ending { + pointer-events: none; +} +.back-to-top.ending::after { + opacity: 1; + transition-delay: 0.35s; +} +.back-to-top::after { + content: ''; + position: fixed; + z-index: 2; + right: 0; + bottom: 0; + width: 108px; + height: 150px; + background: url('./img/back-to-top.png?v=1') no-repeat 0 0; + background-size: 108px 450px; + background-position: 0 -300px; + transition: opacity 0.3s; + opacity: 0; + pointer-events: none; +} + +@keyframes ani-leave { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(108px); + } +} + + @media screen and (max-width: 900px) { iframe { display: none; diff --git a/index.js b/index.js index 8a08654..5931d81 100644 --- a/index.js +++ b/index.js @@ -3,9 +3,12 @@ const config = require("config-yml"); const express = require("express"); const compression = require("compression"); +const { z } = require("zod"); const db = require("./db"); -const themify = require("./utils/themify"); +const { themeList, getCountImage } = require("./utils/themify"); +const { ZodValid } = require("./utils/zod"); +const { randomArray } = require("./utils"); const app = express(); @@ -15,56 +18,65 @@ app.set("view engine", "pug"); app.get('/', (req, res) => { const site = config.app.site || `${req.protocol}://${req.get('host')}` - res.render('index', { site }) + res.render('index', { + site, + themeList, + }) }); // get the image -app.get(["/@:name", "/get/@:name"], async (req, res) => { - const { name } = req.params; - const { theme = "moebooru", padding = 7, pixelated = '1', darkmode = 'auto' } = req.query; - const isPixelated = pixelated === '1'; +app.get(["/@:name", "/get/@:name"], + ZodValid({ + params: z.object({ + name: z.string().max(32), + }), + query: z.object({ + theme: z.string().default("moebooru"), + padding: z.coerce.number().min(0).max(32).default(7), + offset: z.coerce.number().min(-500).max(500).default(0), + scale: z.coerce.number().min(0.1).max(2).default(1), + pixelated: z.enum(["0", "1"]).default("1"), + darkmode: z.enum(["0", "1", "auto"]).default("auto") + }) + }), + async (req, res) => { + const { name } = req.params; + let { theme = "moebooru", ...rest } = req.query; - if (name.length > 32) { - res.status(400).send("name too long"); - return; + // This helps with GitHub's image cache + res.set({ + "content-type": "image/svg+xml", + "cache-control": "max-age=0, no-cache, no-store, must-revalidate", + }); + + const data = await getCountByName(name); + + if (name === "demo") { + res.set("cache-control", "max-age=31536000"); + } + + if (theme === "random") { + theme = randomArray(Object.keys(themeList)); + } + + // Send the generated SVG as the result + const renderSvg = getCountImage({ + count: data.num, + theme, + ...rest + }); + + res.send(renderSvg); + + console.log( + data, + `theme: ${theme}`, + `ip: ${req.headers['x-forwarded-for'] || req.connection.remoteAddress}`, + `ref: ${req.get("Referrer") || null}`, + `ua: ${req.get("User-Agent") || null}` + ); } - - if (padding > 32) { - res.status(400).send("padding too long"); - return; - } - - // This helps with GitHub's image cache - res.set({ - "content-type": "image/svg+xml", - "cache-control": "max-age=0, no-cache, no-store, must-revalidate", - }); - - const data = await getCountByName(name); - - if (name === "demo") { - res.set("cache-control", "max-age=31536000"); - } - - // Send the generated SVG as the result - const renderSvg = themify.getCountImage({ - count: data.num, - theme, - padding, - darkmode, - pixelated: isPixelated - }); - - res.send(renderSvg); - - console.log( - data, - `theme: ${theme}`, - `ip: ${req.headers['x-forwarded-for'] || req.connection.remoteAddress}`, - `ref: ${req.get("Referrer") || null}`, - `ua: ${req.get("User-Agent") || null}` - ); -}); +); // JSON record app.get("/record/@:name", async (req, res) => { diff --git a/package.json b/package.json index dc32288..2b6ec71 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "image-size": "^0.8.3", "mime-types": "^2.1.27", "mongoose": "^5.9.28", - "pug": "^3.0.0" + "pug": "^3.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": "16.x" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d0a5e2..809153c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: pug: specifier: ^3.0.0 version: 3.0.3 + zod: + specifier: ^3.23.8 + version: 3.23.8 packages: @@ -1665,3 +1668,7 @@ packages: y18n: 3.2.2 yargs-parser: 2.4.1 dev: false + + /zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + dev: false diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 0000000..a06a32b --- /dev/null +++ b/utils/index.js @@ -0,0 +1,5 @@ +module.exports = { + randomArray: (arr) => { + return arr[Math.floor(Math.random() * arr.length)] + }, +} diff --git a/utils/themify.js b/utils/themify.js index 589205b..94047db 100644 --- a/utils/themify.js +++ b/utils/themify.js @@ -14,10 +14,10 @@ fs.readdirSync(themePath).forEach(theme => { const imgList = fs.readdirSync(path.resolve(themePath, theme)) imgList.forEach(img => { const imgPath = path.resolve(themePath, theme, img) - const name = path.parse(img).name + const num = path.parse(img).name const { width, height } = sizeOf(imgPath) - themeList[theme][name] = { + themeList[theme][num] = { width, height, data: convertToDatauri(imgPath) @@ -32,8 +32,12 @@ function convertToDatauri(path) { return `data:${mime};base64,${base64}` } -function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = true, darkmode = 'auto' }) { +function getCountImage(params) { + let { count, theme = 'moebooru', padding = 7, offset = 0, scale = 1, pixelated = '1', darkmode = 'auto' } = params + if (!(theme in themeList)) theme = 'moebooru' + padding = parseInt(padding, 10) + offset = parseInt(offset, 10) // This is not the greatest way for generating an SVG but it'll do for now const countArray = count.toString().padStart(padding, '0').split('') @@ -42,7 +46,9 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru let x = 0, y = 0 const defs = uniqueChar.reduce((ret, cur) => { - const { width, height, data } = themeList[theme][cur] + let { width, height, data } = themeList[theme][cur] + width *= scale + height *= scale if (height > y) y = height @@ -53,19 +59,23 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru }, '') const parts = countArray.reduce((ret, cur) => { - const { width } = themeList[theme][cur] + let { width } = themeList[theme][cur] + width *= scale const image = `${ret} ` - x += width + x += width + offset return image }, '') + // Fix the last image offset + x -= offset + const style = ` svg { - ${pixelated ? 'image-rendering: pixelated;' : ''} + ${pixelated === '1' ? 'image-rendering: pixelated;' : ''} ${darkmode === '1' ? 'filter: brightness(.6);' : ''} } ${darkmode === 'auto' ? `@media (prefers-color-scheme: dark) { svg { filter: brightness(.6); } }` : ''} @@ -85,5 +95,6 @@ function getCountImage({ count, theme = 'moebooru', padding = 7, pixelated = tru } module.exports = { + themeList, getCountImage } diff --git a/utils/zod.js b/utils/zod.js new file mode 100644 index 0000000..f5bcc89 --- /dev/null +++ b/utils/zod.js @@ -0,0 +1,50 @@ +function parseError(error) { + const err = JSON.parse(error)[0]; + + return { + code: 400, + message: `The field \`${err.path[0]}\` is invalid. ${err.message}`, + } +} + +module.exports = { + ZodValid: ({ headers, params, query, body }) => { + const handler = (req, res, next) => { + if (headers) { + const result = headers.safeParse(req.headers); + if (!result.success) { + res.status(400).send(parseError(result.error)); + return; + } + } + + if (params) { + const result = params.safeParse(req.params); + if (!result.success) { + res.status(400).send(parseError(result.error)); + return; + } + } + + if (query) { + const result = query.safeParse(req.query); + if (!result.success) { + res.status(400).send(parseError(result.error)); + return; + } + } + + if (body) { + const result = body.safeParse(req.body); + if (!result.success) { + res.status(400).send(parseError(result.error)); + return; + } + } + + next(); + } + + return handler + } +} \ No newline at end of file diff --git a/views/index.pug b/views/index.pug index 8f60810..9b85f3f 100644 --- a/views/index.pug +++ b/views/index.pug @@ -35,7 +35,7 @@ html h3 How to use p Set a unique id for your counter, replace code :name - | in the url, that's all. + | in the url, That's it! h5 SVG address code #{site}/@:name @@ -47,24 +47,15 @@ html code ![:name](#{site}/@:name) h5 e.g. - Moe Count! + Moe Counter! - details#themes + details#themes(style='margin-top: 2em;') summary#more_theme(onclick='_evt_push("click", "normal", "more_theme")') h3(style='display: inline-block; margin: 0; cursor: pointer;') More theme✨ p(style='margin: 0;') Just use the query parameters theme, like this: #{site}/@:name?theme=moebooru - h5 asoul - A-SOUL - h5 moebooru - Moebooru - h5 moebooru-h - Moebooru-Hentai - h5 rule34 - Rule34 - h5 gelbooru - Gelbooru - h5 gelbooru-h - Gelbooru-Hentai + each theme in Object.keys(themeList) + h5 #{theme} + #{theme} h3 Credits ul @@ -74,14 +65,13 @@ html a(href='https://space.bilibili.com/703007996', target='_blank', title='A-SOUL_Official') A-SOUL li a(href='https://github.com/moebooru/moebooru', target='_blank', rel='nofollow') moebooru - li - a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') rule34.xxx - | NSFW li a(href='javascript:alert("!!! NSFW LINK !!!\\nPlease enter the url manually")') gelbooru.com | NSFW li a(href='https://icons8.com/icon/80355/star', target='_blank', rel='nofollow') Icons8 + span + i And all booru site... h3 Tool .tool @@ -89,37 +79,58 @@ html thead tr th Param + th Description th Value tbody tr td code name + td Unique counter name td input#name(type='text', placeholder=':name') tr td code theme + td Select a counter image theme, default is + code moebooru td select#theme - option(value='asoul') asoul - option(value='moebooru') moebooru - option(value='moebooru-h') moebooru-h - option(value='rule34') rule34 - option(value='gelbooru') gelbooru - option(value='gelbooru-h') gelbooru-h - tr - td - code pixelated - td - input#pixelated(type='checkbox', checked, style='margin: .5rem .75rem;') + option(value="random", selected) * random + each theme in Object.keys(themeList) + tr td code padding + td Set the minimum length, between 1-32, default is + code 7 td input#padding(type='number', value='7', min='1', max='32', step='1', oninput='this.value = this.value.replace(/[^0-9]/g, "")') + tr + td + code offset + td Set the offset pixel value, between -500-500, default is + code 0 + td + input#offset(type='number', value='0', min='-500', max='500', step='1', oninput='this.value = this.value.replace(/[^0-9|\-]/g, "")') + tr + td + code scale + td Set the image scale, between 0.1-2, default is + code 1 + td + input#scale(type='number', value='1', min='0.1', max='2', step='0.1', oninput='this.value = this.value.replace(/[^0-9|\.]/g, "")') + tr + td + code pixelated + td Enable pixelated mode, Enum 0/1, default is + code 1 + td + input#pixelated(type='checkbox', checked, style='margin: .5rem .75rem;') tr td code darkmode + td Enable dark mode, Enum 0/1/auto, default is + code auto td select#darkmode(name="darkmode") option(value="auto", selected) auto @@ -132,7 +143,13 @@ html code#code(style='visibility: hidden; display: inline-block; margin-bottom: 1em;') img#result(style='display: block;') - script. + p(style='margin-top: 2em;') + a(href='https://github.com/journey-ad/Moe-Counter', target='_blank', onclick='_evt_push("click", "normal", "go_github")') source code + + div.back-to-top + + script. + (function () { var btn = document.getElementById('get'), img = document.getElementById('result'), code = document.getElementById('code') @@ -140,15 +157,13 @@ html btn.addEventListener('click', throttle(function() { var $name = document.getElementById('name'), $theme = document.getElementById('theme'), - $pixelated = document.getElementById('pixelated'), $padding = document.getElementById('padding'), + $offset = document.getElementById('offset'), + $scale = document.getElementById('scale'), + $pixelated = document.getElementById('pixelated'), $darkmode = document.getElementById('darkmode') var name = $name.value ? $name.value.trim() : '' - var theme = $theme.value || 'moebooru' - var pixelated = $pixelated.checked ? '1' : '0' - var padding = $padding.value || '7' - var darkmode = $darkmode.value || 'auto' if(!name) { alert('Please input counter name.') @@ -157,7 +172,19 @@ html party.confetti(this, { count: party.variation.range(20, 40) }); - img.src = `#{site}/@${name}?theme=${theme}&pixelated=${pixelated}&padding=${padding}&darkmode=${darkmode}` + var params = { + name: $name.value ? $name.value.trim() : '', + theme: $theme.value || 'moebooru', + padding: $padding.value || '7', + offset: $offset.value || '0', + scale: $scale.value || '1', + pixelated: $pixelated.checked ? '1' : '0', + darkmode: $darkmode.value || 'auto', + } + + var query = new URLSearchParams(params).toString() + + img.src = `#{site}/@${name}?${query}` code.textContent = img.src code.style.visibility = 'visible' @@ -220,6 +247,50 @@ html } } } + })(); - p(style='margin-top: 2em;') - a(href='https://github.com/journey-ad/Moe-Counter', target='_blank', onclick='_evt_push("click", "normal", "go_github")') source code + script. + (function () { + var isShow = false, lock = false; + var btn = document.querySelector('.back-to-top'); + + window.addEventListener('scroll', function () { + if (lock) return; + + if (document.body.scrollTop >= 1000) { + if (!isShow) btn.classList.add('load'); + isShow = true; + } else { + if (isShow) { + btn.classList.remove('load'); + isShow = false; + } + } + }); + + btn.addEventListener('click', function () { + lock = true; + btn.classList.add('ani-leave'); + + window.scrollTo({ top: 0, behavior: 'smooth' }); + + setTimeout(function () { + btn.classList.remove('ani-leave'); + btn.classList.add('leaved'); + }, 390); + + setTimeout(function () { + btn.classList.add('ending'); + }, 120); + + setTimeout(function () { + btn.classList.remove('load'); + }, 1500); + + setTimeout(function () { + lock = false; + isShow = false; + btn.classList.remove('leaved', 'ending'); + }, 2000); + }); + })();