change categories to tags in frontend

This commit is contained in:
Zutatensuppe 2021-05-22 15:48:13 +02:00
parent 92ed17efa5
commit 5be099c61c
16 changed files with 196 additions and 112 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,9 +4,9 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>🧩 jigsaw.hyottoko.club</title> <title>🧩 jigsaw.hyottoko.club</title>
<script type="module" crossorigin src="/assets/index.0aa9cc2a.js"></script> <script type="module" crossorigin src="/assets/index.3b46c827.js"></script>
<link rel="modulepreload" href="/assets/vendor.1ad14f11.js"> <link rel="modulepreload" href="/assets/vendor.b622ee49.js">
<link rel="stylesheet" href="/assets/index.dc049e4e.css"> <link rel="stylesheet" href="/assets/index.f09d7623.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -1258,7 +1258,7 @@ async function getExifOrientation(imagePath) {
}); });
}); });
} }
const getCategories = (db, imageId) => { const getTags = (db, imageId) => {
const query = ` const query = `
select * from categories c select * from categories c
inner join image_x_category ixc on c.id = ixc.category_id inner join image_x_category ixc on c.id = ixc.category_id
@ -1273,11 +1273,11 @@ const imageFromDb = (db, imageId) => {
file: `${UPLOAD_DIR}/${i.filename}`, file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
categories: getCategories(db, i.id), tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
}; };
}; };
const allImagesFromDb = (db, categorySlug, sort) => { const allImagesFromDb = (db, tagSlugs, sort) => {
const sortMap = { const sortMap = {
alpha_asc: [{ filename: 1 }], alpha_asc: [{ filename: 1 }],
alpha_desc: [{ filename: -1 }], alpha_desc: [{ filename: -1 }],
@ -1286,16 +1286,18 @@ const allImagesFromDb = (db, categorySlug, sort) => {
}; };
// TODO: .... clean up // TODO: .... clean up
const wheresRaw = {}; const wheresRaw = {};
if (categorySlug !== '') { if (tagSlugs.length > 0) {
const c = db.get('categories', { slug: categorySlug }); const c = db.getMany('categories', { slug: { '$in': tagSlugs } });
if (!c) { if (!c) {
return []; return [];
} }
const where = db._buildWhere({
'category_id': { '$in': c.map(x => x.id) }
});
const ids = db._getMany(` const ids = db._getMany(`
select i.id from image_x_category ixc select i.id from image_x_category ixc
inner join images i on i.id = ixc.image_id inner join images i on i.id = ixc.image_id ${where.sql};
where ixc.category_id = ?; `, where.values).map(img => img.id);
`, [c.id]).map(img => img.id);
if (ids.length === 0) { if (ids.length === 0) {
return []; return [];
} }
@ -1308,11 +1310,11 @@ where ixc.category_id = ?;
file: `${UPLOAD_DIR}/${i.filename}`, file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
categories: getCategories(db, i.id), tags: getTags(db, i.id),
created: i.created * 1000, created: i.created * 1000,
})); }));
}; };
const allImagesFromDisk = (category, sort) => { const allImagesFromDisk = (tags, sort) => {
let images = fs.readdirSync(UPLOAD_DIR) let images = fs.readdirSync(UPLOAD_DIR)
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/)) .filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({ .map(f => ({
@ -1321,7 +1323,7 @@ const allImagesFromDisk = (category, sort) => {
file: `${UPLOAD_DIR}/${f}`, file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`, url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
title: f.replace(/\.[a-z]+$/, ''), title: f.replace(/\.[a-z]+$/, ''),
categories: [], tags: [],
created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(), created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
})); }));
switch (sort) { switch (sort) {
@ -1901,9 +1903,10 @@ app.get('/api/conf', (req, res) => {
}); });
app.get('/api/newgame-data', (req, res) => { app.get('/api/newgame-data', (req, res) => {
const q = req.query; const q = req.query;
const tagSlugs = q.tags ? q.tags.split(',') : [];
res.send({ res.send({
images: Images.allImagesFromDb(db, q.category, q.sort), images: Images.allImagesFromDb(db, tagSlugs, q.sort),
categories: db.getMany('categories', {}, [{ title: 1 }]), tags: db.getMany('categories', {}, [{ title: 1 }]),
}); });
}); });
app.get('/api/index-data', (req, res) => { app.get('/api/index-data', (req, res) => {
@ -1925,6 +1928,18 @@ app.get('/api/index-data', (req, res) => {
gamesFinished: games.filter(g => !!g.finished), gamesFinished: games.filter(g => !!g.finished),
}); });
}); });
const setImageTags = (db, imageId, tags) => {
tags.forEach((tag) => {
const slug = Util.slug(tag);
const id = db.upsert('categories', { slug, title: tag }, { slug }, 'id');
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
});
}
});
};
app.post('/api/save-image', bodyParser.json(), (req, res) => { app.post('/api/save-image', bodyParser.json(), (req, res) => {
const data = req.body; const data = req.body;
db.update('images', { db.update('images', {
@ -1933,16 +1948,8 @@ app.post('/api/save-image', bodyParser.json(), (req, res) => {
id: data.id, id: data.id,
}); });
db.delete('image_x_category', { image_id: data.id }); db.delete('image_x_category', { image_id: data.id });
if (data.category) { if (data.tags) {
const title = data.category; setImageTags(db, data.id, data.tags);
const slug = Util.slug(title);
const id = db.upsert('categories', { slug, title }, { slug }, 'id');
if (id) {
db.insert('image_x_category', {
image_id: data.id,
category_id: id,
});
}
} }
res.send({ ok: true }); res.send({ ok: true });
}); });
@ -1965,16 +1972,8 @@ app.post('/api/upload', (req, res) => {
title: req.body.title || '', title: req.body.title || '',
created: Time.timestamp(), created: Time.timestamp(),
}); });
if (req.body.category) { if (req.body.tags) {
const title = req.body.category; setImageTags(db, imageId, req.body.tags);
const slug = Util.slug(title);
const id = db.upsert('categories', { slug, title }, { slug }, 'id');
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
});
}
} }
res.send(Images.imageFromDb(db, imageId)); res.send(Images.imageFromDb(db, imageId));
}); });

View file

@ -8,7 +8,7 @@ export type EncodedPlayer = Array<any>
export type EncodedPiece = Array<any> export type EncodedPiece = Array<any>
export type EncodedPieceShape = number export type EncodedPieceShape = number
export interface Category { export interface Tag {
id: number id: number
slug: string slug: string
title: string title: string
@ -34,7 +34,7 @@ export interface Image {
file: string file: string
url: string url: string
title: string title: string
categories: Array<Category> tags: Array<Tag>
created: number created: number
} }

View file

@ -20,9 +20,11 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<!-- TODO: autocomplete category --> <!-- TODO: autocomplete tags -->
<td><label>Category</label></td> <td><label>Tags</label></td>
<td><input type="text" v-model="category" placeholder="Plants" /></td> <td>
<tags-input v-model="tags" />
</td>
</tr> </tr>
</table> </table>
</div> </div>
@ -36,14 +38,16 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType } from 'vue' import { defineComponent, PropType } from 'vue'
import { Image } from '../../common/GameCommon' import { Image, Tag } from '../../common/GameCommon'
import ResponsiveImage from './ResponsiveImage.vue' import ResponsiveImage from './ResponsiveImage.vue'
import TagsInput from './TagsInput.vue'
export default defineComponent({ export default defineComponent({
name: 'edit-image-dialog', name: 'edit-image-dialog',
components: { components: {
ResponsiveImage, ResponsiveImage,
TagsInput,
}, },
props: { props: {
image: { image: {
@ -58,19 +62,19 @@ export default defineComponent({
data () { data () {
return { return {
title: '', title: '',
category: '', tags: [] as string[],
} }
}, },
created () { created () {
this.title = this.image.title this.title = this.image.title
this.category = this.image.categories.length > 0 ? this.image.categories[0].title : '' this.tags = this.image.tags.map((t: Tag) => t.title)
}, },
methods: { methods: {
saveImage () { saveImage () {
this.$emit('saveClick', { this.$emit('saveClick', {
id: this.image.id, id: this.image.id,
title: this.title, title: this.title,
category: this.category, tags: this.tags,
}) })
}, },
}, },

View file

@ -16,12 +16,8 @@ gallery", if possible!
<div v-else> <div v-else>
<label class="upload"> <label class="upload">
<input type="file" style="display: none" @change="preview" accept="image/*" /> <input type="file" style="display: none" @change="preview" accept="image/*" />
<span class="btn">{{label || 'Upload File'}}</span> <span class="btn">Upload File</span>
</label> </label>
<!-- TODO: drop area for image -->
<!-- <upload class="upload" @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" /> -->
</div> </div>
</div> </div>
@ -37,9 +33,11 @@ gallery", if possible!
</td> </td>
</tr> </tr>
<tr> <tr>
<!-- TODO: autocomplete category --> <!-- TODO: autocomplete tags -->
<td><label>Category</label></td> <td><label>Tags</label></td>
<td><input type="text" v-model="category" placeholder="Plants" /></td> <td>
<tags-input v-model="tags" />
</td>
</tr> </tr>
</table> </table>
</div> </div>
@ -55,14 +53,14 @@ gallery", if possible!
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import Upload from './Upload.vue'
import ResponsiveImage from './ResponsiveImage.vue' import ResponsiveImage from './ResponsiveImage.vue'
import TagsInput from './TagsInput.vue'
export default defineComponent({ export default defineComponent({
name: 'new-image-dialog', name: 'new-image-dialog',
components: { components: {
Upload,
ResponsiveImage, ResponsiveImage,
TagsInput,
}, },
emits: { emits: {
bgclick: null, bgclick: null,
@ -74,7 +72,7 @@ export default defineComponent({
previewUrl: '', previewUrl: '',
file: null as File|null, file: null as File|null,
title: '', title: '',
category: '', tags: [] as string[],
} }
}, },
computed: { computed: {
@ -103,14 +101,14 @@ export default defineComponent({
this.$emit('postToGalleryClick', { this.$emit('postToGalleryClick', {
file: this.file, file: this.file,
title: this.title, title: this.title,
category: this.category, tags: this.tags,
}) })
}, },
setupGameClick () { setupGameClick () {
this.$emit('setupGameClick', { this.$emit('setupGameClick', {
file: this.file, file: this.file,
title: this.title, title: this.title,
category: this.category, tags: this.tags,
}) })
}, },
}, },

View file

@ -0,0 +1,60 @@
<template>
<div>
<input class="input" type="text" v-model="input" placeholder="Plants, People" @keydown.enter="add" @keyup="onKeyUp" />
<span v-for="(tag,idx) in values" :key="idx" class="bit" @click="rm(tag)">{{tag}} </span>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export default defineComponent({
name: 'tags-input',
props: {
modelValue: {
type: Array as PropType<Array<string>>,
required: true,
},
},
emits: {
'update:modelValue': null,
},
data () {
return {
input: '',
values: [] as Array<string>,
}
},
created () {
this.values = this.modelValue
},
methods: {
onKeyUp (ev: KeyboardEvent) {
if (ev.key === ',') {
this.add()
ev.stopPropagation()
return false
}
},
add () {
const newval = this.input.replace(/,/g, '').trim()
if (!newval) {
return
}
if (!this.values.includes(newval)) {
this.values.push(newval)
}
this.input = ''
this.$emit('update:modelValue', this.values)
},
rm (val: string) {
this.values = this.values.filter(v => v !== val)
this.$emit('update:modelValue', this.values)
},
}
})
</script>
<style scoped>
.input {
margin-bottom: .5em;
}
</style>

View file

@ -6,6 +6,7 @@
--link-color: #808db0; --link-color: #808db0;
--link-hover-color: #c5cfeb; --link-hover-color: #c5cfeb;
--highlight-color: #dd7e7e; --highlight-color: #dd7e7e;
--positive-color: #64a756;
--input-bg-color: #262523; --input-bg-color: #262523;
--bg-color: rgba(0,0,0,.7); --bg-color: rgba(0,0,0,.7);
} }
@ -212,6 +213,18 @@ kbd {
color: var(--main-darker-color); color: var(--main-darker-color);
} }
.bit {
background: rgb(59, 55, 55);
border-radius: .5em;
padding: .25em .5em;
display: inline-block;
margin: 0 .25em .25em 0;
cursor: pointer;
}
.bit.on {
color: var(--positive-color);
}
.upload-image-teaser { .upload-image-teaser {
text-align: center; text-align: center;
} }

View file

@ -13,12 +13,13 @@ in jigsawpuzzles.io
</div> </div>
<div> <div>
<label v-if="categories.length > 0"> <label v-if="tags.length > 0">
Category: Tags:
<select v-model="filters.category" @change="filtersChanged"> <span class="bit" v-for="(t,idx) in tags" :key="idx" @click="toggleTag(t)" :class="{on: filters.tags.includes(t.slug)}">{{t.title}}</span>
<!-- <select v-model="filters.tags" @change="filtersChanged">
<option value="">All</option> <option value="">All</option>
<option v-for="(c, idx) in categories" :key="idx" :value="c.slug">{{c.title}}</option> <option v-for="(c, idx) in tags" :key="idx" :value="c.slug">{{c.title}}</option>
</select> </select> -->
</label> </label>
<label> <label>
Sort by: Sort by:
@ -44,7 +45,7 @@ import ImageLibrary from './../components/ImageLibrary.vue'
import NewImageDialog from './../components/NewImageDialog.vue' import NewImageDialog from './../components/NewImageDialog.vue'
import EditImageDialog from './../components/EditImageDialog.vue' import EditImageDialog from './../components/EditImageDialog.vue'
import NewGameDialog from './../components/NewGameDialog.vue' import NewGameDialog from './../components/NewGameDialog.vue'
import { GameSettings, Image } from '../../common/GameCommon' import { GameSettings, Image, Tag } from '../../common/GameCommon'
import Util from '../../common/Util' import Util from '../../common/Util'
export default defineComponent({ export default defineComponent({
@ -58,10 +59,10 @@ export default defineComponent({
return { return {
filters: { filters: {
sort: 'date_desc', sort: 'date_desc',
category: '', tags: [] as string[],
}, },
images: [], images: [],
categories: [], tags: [],
image: { image: {
id: 0, id: 0,
@ -69,7 +70,7 @@ export default defineComponent({
file: '', file: '',
url: '', url: '',
title: '', title: '',
categories: [], tags: [],
created: 0, created: 0,
} as Image, } as Image,
@ -80,11 +81,19 @@ export default defineComponent({
await this.loadImages() await this.loadImages()
}, },
methods: { methods: {
toggleTag (t: Tag) {
if (this.filters.tags.includes(t.slug)) {
this.filters.tags = this.filters.tags.filter(slug => slug !== t.slug)
} else {
this.filters.tags.push(t.slug)
}
this.filtersChanged()
},
async loadImages () { async loadImages () {
const res = await fetch(`/api/newgame-data${Util.asQueryArgs(this.filters)}`) const res = await fetch(`/api/newgame-data${Util.asQueryArgs(this.filters)}`)
const json = await res.json() const json = await res.json()
this.images = json.images this.images = json.images
this.categories = json.categories this.tags = json.tags
}, },
async filtersChanged () { async filtersChanged () {
await this.loadImages() await this.loadImages()
@ -101,7 +110,7 @@ export default defineComponent({
const formData = new FormData(); const formData = new FormData();
formData.append('file', data.file, data.file.name); formData.append('file', data.file, data.file.name);
formData.append('title', data.title) formData.append('title', data.title)
formData.append('category', data.category) formData.append('tags', data.tags)
const res = await fetch('/api/upload', { const res = await fetch('/api/upload', {
method: 'post', method: 'post',
@ -119,7 +128,7 @@ export default defineComponent({
body: JSON.stringify({ body: JSON.stringify({
id: data.id, id: data.id,
title: data.title, title: data.title,
category: data.category, tags: data.tags,
}), }),
}) })
return await res.json() return await res.json()

View file

@ -47,7 +47,7 @@ async function getExifOrientation(imagePath: string) {
}) })
} }
const getCategories = (db: Db, imageId: number) => { const getTags = (db: Db, imageId: number) => {
const query = ` const query = `
select * from categories c select * from categories c
inner join image_x_category ixc on c.id = ixc.category_id inner join image_x_category ixc on c.id = ixc.category_id
@ -63,12 +63,12 @@ const imageFromDb = (db: Db, imageId: number) => {
file: `${UPLOAD_DIR}/${i.filename}`, file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
categories: getCategories(db, i.id) as any[], tags: getTags(db, i.id) as any[],
created: i.created * 1000, created: i.created * 1000,
} }
} }
const allImagesFromDb = (db: Db, categorySlug: string, sort: string) => { const allImagesFromDb = (db: Db, tagSlugs: string[], sort: string) => {
const sortMap = { const sortMap = {
alpha_asc: [{filename: 1}], alpha_asc: [{filename: 1}],
alpha_desc: [{filename: -1}], alpha_desc: [{filename: -1}],
@ -78,16 +78,18 @@ const allImagesFromDb = (db: Db, categorySlug: string, sort: string) => {
// TODO: .... clean up // TODO: .... clean up
const wheresRaw: Record<string, any> = {} const wheresRaw: Record<string, any> = {}
if (categorySlug !== '') { if (tagSlugs.length > 0) {
const c = db.get('categories', {slug: categorySlug}) const c = db.getMany('categories', {slug: {'$in': tagSlugs}})
if (!c) { if (!c) {
return [] return []
} }
const where = db._buildWhere({
'category_id': {'$in': c.map(x => x.id)}
})
const ids = db._getMany(` const ids = db._getMany(`
select i.id from image_x_category ixc select i.id from image_x_category ixc
inner join images i on i.id = ixc.image_id inner join images i on i.id = ixc.image_id ${where.sql};
where ixc.category_id = ?; `, where.values).map(img => img.id)
`, [c.id]).map(img => img.id)
if (ids.length === 0) { if (ids.length === 0) {
return [] return []
} }
@ -101,12 +103,12 @@ where ixc.category_id = ?;
file: `${UPLOAD_DIR}/${i.filename}`, file: `${UPLOAD_DIR}/${i.filename}`,
url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`, url: `${UPLOAD_URL}/${encodeURIComponent(i.filename)}`,
title: i.title, title: i.title,
categories: getCategories(db, i.id) as any[], tags: getTags(db, i.id) as any[],
created: i.created * 1000, created: i.created * 1000,
})) }))
} }
const allImagesFromDisk = (category: string, sort: string) => { const allImagesFromDisk = (tags: string[], sort: string) => {
let images = fs.readdirSync(UPLOAD_DIR) let images = fs.readdirSync(UPLOAD_DIR)
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/)) .filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
.map(f => ({ .map(f => ({
@ -115,7 +117,7 @@ const allImagesFromDisk = (category: string, sort: string) => {
file: `${UPLOAD_DIR}/${f}`, file: `${UPLOAD_DIR}/${f}`,
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`, url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
title: f.replace(/\.[a-z]+$/, ''), title: f.replace(/\.[a-z]+$/, ''),
categories: [] as any[], tags: [] as any[],
created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(), created: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
})) }))

View file

@ -63,9 +63,10 @@ app.get('/api/conf', (req, res) => {
app.get('/api/newgame-data', (req, res) => { app.get('/api/newgame-data', (req, res) => {
const q = req.query as any const q = req.query as any
const tagSlugs: string[] = q.tags ? q.tags.split(',') : []
res.send({ res.send({
images: Images.allImagesFromDb(db, q.category, q.sort), images: Images.allImagesFromDb(db, tagSlugs, q.sort),
categories: db.getMany('categories', {}, [{ title: 1 }]), tags: db.getMany('categories', {}, [{ title: 1 }]),
}) })
}) })
@ -93,8 +94,22 @@ app.get('/api/index-data', (req, res) => {
interface SaveImageRequestData { interface SaveImageRequestData {
id: number id: number
title: string title: string
category: string tags: string[]
} }
const setImageTags = (db: Db, imageId: number, tags: string[]) => {
tags.forEach((tag: string) => {
const slug = Util.slug(tag)
const id = db.upsert('categories', { slug, title: tag }, { slug }, 'id')
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
})
}
})
}
app.post('/api/save-image', bodyParser.json(), (req, res) => { app.post('/api/save-image', bodyParser.json(), (req, res) => {
const data = req.body as SaveImageRequestData const data = req.body as SaveImageRequestData
db.update('images', { db.update('images', {
@ -105,16 +120,8 @@ app.post('/api/save-image', bodyParser.json(), (req, res) => {
db.delete('image_x_category', { image_id: data.id }) db.delete('image_x_category', { image_id: data.id })
if (data.category) { if (data.tags) {
const title = data.category setImageTags(db, data.id, data.tags)
const slug = Util.slug(title)
const id = db.upsert('categories', { slug, title }, { slug }, 'id')
if (id) {
db.insert('image_x_category', {
image_id: data.id,
category_id: id,
})
}
} }
res.send({ ok: true }) res.send({ ok: true })
@ -140,16 +147,8 @@ app.post('/api/upload', (req, res) => {
created: Time.timestamp(), created: Time.timestamp(),
}) })
if (req.body.category) { if (req.body.tags) {
const title = req.body.category setImageTags(db, imageId as number, req.body.tags)
const slug = Util.slug(title)
const id = db.upsert('categories', { slug, title }, { slug }, 'id')
if (id) {
db.insert('image_x_category', {
image_id: imageId,
category_id: id,
})
}
} }
res.send(Images.imageFromDb(db, imageId as number)) res.send(Images.imageFromDb(db, imageId as number))