add layers for new image and new game
This commit is contained in:
parent
e9b209edf1
commit
bdd061dd1a
18 changed files with 551 additions and 99 deletions
|
|
@ -1 +0,0 @@
|
||||||
:root{--main-color:#c1b19f;--link-color:#808db0;--link-hover-color:#c5cfeb;--highlight-color:#dd7e7e;--input-bg-color:#262523;--bg-color:rgba(0,0,0,.7)}body,html{margin:0;background:#2b2b2b;color:var(--main-color);height:100%}*{font-family:monospace;font-size:15px}h1,h2,h3,h4{font-size:20px}a{color:var(--link-color);text-decoration:none}a:hover{color:var(--link-hover-color)}td,th{vertical-align:top}.btn{display:inline-block;background:var(--input-bg-color);color:var(--link-color);border:solid 1px #000;padding:5px 10px;box-shadow:1px 1px 2px rgba(0,0,0,.5),0 0 1px rgba(150,150,150,.4) inset;border-radius:4px;user-select:none}.btn:hover{background:#2f2e2c;color:var(--link-hover-color);border:solid 1px #111;box-shadow:0 0 1px rgba(150,150,150,.4) inset;cursor:pointer}.btn:disabled{background:#2f2e2c;color:#8c4747!important;border:solid 1px #111;box-shadow:0 0 1px rgba(150,150,150,.4) inset;cursor:not-allowed}input{background:#333230;border-radius:4px;color:var(--main-color);padding:6px 10px;border:solid 1px #000;box-shadow:0 0 3px rgba(0,0,0,.3) inset}input:focus{border:solid 1px #686767;background:var(--input-bg-color)}.scores{position:absolute;right:0;top:0;background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7)}.timer{position:absolute;left:0;top:0;background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7)}.menu{position:absolute;top:0;left:50%;transform:translateX(-50%);background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7);z-index:2}.closed{display:none}.overlay{position:absolute;top:0;left:0;right:0;bottom:0;z-index:10;background:var(--bg-color)}.overlay.transparent{background:0 0}.overlay-content{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--bg-color);padding:5px;border:solid 1px #000;box-shadow:0 0 10px 0 rgba(0,0,0,.7);z-index:1}.connection-lost .overlay-content{padding:20px;text-align:center}.preview{position:absolute;top:20px;left:20px;bottom:20px;right:20px}.preview .img{height:100%;width:100%;position:absolute;background-repeat:no-repeat;background-position:center;background-size:contain}.menu .opener{display:inline-block;margin-right:10px;color:var(--link-color)}.menu .opener:last-child{margin-right:0}.menu .opener:hover{color:var(--link-hover-color);cursor:pointer}canvas.loaded{cursor:none}kbd{background-color:#eee;border-radius:3px;border:1px solid #b4b4b4;box-shadow:0 1px 1px rgba(0,0,0,.2),0 2px 0 0 rgba(255,255,255,.7) inset;color:#333;display:inline-block;font-size:.85em;font-weight:700;line-height:1;padding:2px 4px;white-space:nowrap}.nav{list-style:none;padding:0}.nav li{display:inline-block;margin-right:1em}.image-list{overflow:scroll}.image-list-inner{white-space:nowrap}.imageteaser{width:150px;height:100px;display:inline-block;margin:5px;background-size:contain;background-position:center;background-repeat:no-repeat;background-color:#222;cursor:pointer}.game-teaser-wrap{display:inline-block;width:20%;padding:5px;box-sizing:border-box}.game-teaser{display:block;background-repeat:no-repeat;background-position:center;background-size:contain;position:relative;padding-top:56.25%;width:100%;background-color:#222}.game-info{position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%}.game-info-text{position:absolute;top:0;background:var(--bg-color);padding:5px}.game-replay{position:absolute;top:0;right:0}html.view-game{overflow:hidden}html.view-game body{overflow:hidden}html.view-replay{overflow:hidden}html.view-replay body{overflow:hidden}html.view-replay canvas{cursor:grab}
|
|
||||||
1
build/public/assets/index.6c4f6859.css
Normal file
1
build/public/assets/index.6c4f6859.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
build/public/assets/index.b49dadca.js
Normal file
1
build/public/assets/index.b49dadca.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
build/public/assets/vendor.8616a479.js
Normal file
6
build/public/assets/vendor.8616a479.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -3,9 +3,9 @@
|
||||||
<head>
|
<head>
|
||||||
|
|
||||||
<title>🧩 jigsaw.hyottoko.club</title>
|
<title>🧩 jigsaw.hyottoko.club</title>
|
||||||
<script type="module" crossorigin src="/assets/index.85898c1b.js"></script>
|
<script type="module" crossorigin src="/assets/index.b49dadca.js"></script>
|
||||||
<link rel="modulepreload" href="/assets/vendor.00b608ff.js">
|
<link rel="modulepreload" href="/assets/vendor.8616a479.js">
|
||||||
<link rel="stylesheet" href="/assets/index.421011a7.css">
|
<link rel="stylesheet" href="/assets/index.6c4f6859.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,17 @@ const hash = (str) => {
|
||||||
}
|
}
|
||||||
return hash;
|
return hash;
|
||||||
};
|
};
|
||||||
|
function asQueryArgs(data) {
|
||||||
|
const q = [];
|
||||||
|
for (let k in data) {
|
||||||
|
const pair = [k, data[k]].map(encodeURIComponent);
|
||||||
|
q.push(pair.join('='));
|
||||||
|
}
|
||||||
|
if (q.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `?${q.join('&')}`;
|
||||||
|
}
|
||||||
var Util = {
|
var Util = {
|
||||||
hash,
|
hash,
|
||||||
uniqId,
|
uniqId,
|
||||||
|
|
@ -193,6 +204,7 @@ var Util = {
|
||||||
encodeGame,
|
encodeGame,
|
||||||
decodeGame,
|
decodeGame,
|
||||||
coordByTileIdx,
|
coordByTileIdx,
|
||||||
|
asQueryArgs,
|
||||||
};
|
};
|
||||||
|
|
||||||
const log$4 = logger('WebSocketServer.js');
|
const log$4 = logger('WebSocketServer.js');
|
||||||
|
|
@ -1236,18 +1248,40 @@ async function getExifOrientation(imagePath) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const allImages = () => {
|
const allImages = (sort) => {
|
||||||
const 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 => ({
|
||||||
filename: f,
|
filename: f,
|
||||||
file: `${UPLOAD_DIR}/${f}`,
|
file: `${UPLOAD_DIR}/${f}`,
|
||||||
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
|
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
|
||||||
}))
|
title: '',
|
||||||
.sort((a, b) => {
|
category: '',
|
||||||
return fs.statSync(b.file).mtime.getTime() -
|
ts: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
|
||||||
fs.statSync(a.file).mtime.getTime();
|
}));
|
||||||
});
|
switch (sort) {
|
||||||
|
case 'alpha_asc':
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.file > b.file ? 1 : -1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'alpha_desc':
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.file < b.file ? 1 : -1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'date_asc':
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.ts > b.ts ? 1 : -1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'date_desc':
|
||||||
|
default:
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.ts < b.ts ? 1 : -1;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
return images;
|
return images;
|
||||||
};
|
};
|
||||||
async function getDimensions(imagePath) {
|
async function getDimensions(imagePath) {
|
||||||
|
|
@ -1650,8 +1684,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;
|
||||||
res.send({
|
res.send({
|
||||||
images: Images.allImages(),
|
images: Images.allImages(q.sort),
|
||||||
|
categories: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.get('/api/index-data', (req, res) => {
|
app.get('/api/index-data', (req, res) => {
|
||||||
|
|
@ -1695,11 +1731,12 @@ app.post('/upload', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.post('/newgame', bodyParser.json(), async (req, res) => {
|
app.post('/newgame', bodyParser.json(), async (req, res) => {
|
||||||
log.log(req.body.tiles, req.body.image);
|
const gameSettings = req.body;
|
||||||
|
log.log(gameSettings);
|
||||||
const gameId = Util.uniqId();
|
const gameId = Util.uniqId();
|
||||||
if (!Game.exists(gameId)) {
|
if (!Game.exists(gameId)) {
|
||||||
const ts = Time.timestamp();
|
const ts = Time.timestamp();
|
||||||
await Game.createGame(gameId, req.body.tiles, req.body.image, ts, req.body.scoreMode);
|
await Game.createGame(gameId, gameSettings.tiles, gameSettings.image, ts, gameSettings.scoreMode);
|
||||||
}
|
}
|
||||||
res.send({ id: gameId });
|
res.send({ id: gameId });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export type EncodedPlayer = Array<any>
|
||||||
export type EncodedPiece = Array<any>
|
export type EncodedPiece = Array<any>
|
||||||
export type EncodedPieceShape = number
|
export type EncodedPieceShape = number
|
||||||
|
|
||||||
|
// TODO: maybe something other than string in the future
|
||||||
|
export type Category = string
|
||||||
|
|
||||||
interface GameRng {
|
interface GameRng {
|
||||||
obj: Rng
|
obj: Rng
|
||||||
type?: string
|
type?: string
|
||||||
|
|
@ -22,6 +25,19 @@ interface Game {
|
||||||
rng: GameRng
|
rng: GameRng
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Image {
|
||||||
|
file: string
|
||||||
|
url: string
|
||||||
|
category: Category
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSettings {
|
||||||
|
tiles: number
|
||||||
|
image: Image
|
||||||
|
scoreMode: ScoreMode
|
||||||
|
}
|
||||||
|
|
||||||
export interface Puzzle {
|
export interface Puzzle {
|
||||||
tiles: Array<EncodedPiece>
|
tiles: Array<EncodedPiece>
|
||||||
data: PuzzleData
|
data: PuzzleData
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,18 @@ const hash = (str: string): number => {
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asQueryArgs(data: any) {
|
||||||
|
const q = []
|
||||||
|
for (let k in data) {
|
||||||
|
const pair = [k, data[k]].map(encodeURIComponent)
|
||||||
|
q.push(pair.join('='))
|
||||||
|
}
|
||||||
|
if (q.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `?${q.join('&')}`
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
hash,
|
hash,
|
||||||
uniqId,
|
uniqId,
|
||||||
|
|
@ -165,4 +177,6 @@ export default {
|
||||||
decodeGame,
|
decodeGame,
|
||||||
|
|
||||||
coordByTileIdx,
|
coordByTileIdx,
|
||||||
|
|
||||||
|
asQueryArgs,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
src/frontend/components/ImageLibrary.vue
Normal file
32
src/frontend/components/ImageLibrary.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<image-teaser v-for="(i,idx) in images" :image="i" @click="imageClicked(i)" :key="idx" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { Image } from '../../common/GameCommon'
|
||||||
|
|
||||||
|
import ImageTeaser from './ImageTeaser.vue'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'image-library',
|
||||||
|
components: {
|
||||||
|
ImageTeaser,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
images: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
imageClicked: null,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
imageClicked (image: Image) {
|
||||||
|
this.$emit('imageClicked', image)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -1,92 +1,86 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="overlay new-game-dialog" @click="$emit('bgclick')">
|
||||||
<h1>New game</h1>
|
<div class="overlay-content" @click.stop="">
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td><label>Pieces: </label></td>
|
|
||||||
<td><input type="text" v-model="tiles" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><label>Scoring: </label></td>
|
|
||||||
<td>
|
|
||||||
<label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label>
|
|
||||||
<br />
|
|
||||||
<label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><label>Image: </label></td>
|
|
||||||
<td>
|
|
||||||
<span v-if="image">
|
|
||||||
<img :src="image.url" style="width: 150px;" />
|
|
||||||
or
|
|
||||||
<upload @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
<upload @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
|
|
||||||
(or select from below)
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">
|
|
||||||
<button class="btn" :disabled="!canStartNewGame" @click="onNewGameClick">Start new game</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h1>Image lib</h1>
|
<div class="area-image">
|
||||||
<div>
|
<div class="has-image">
|
||||||
<image-teaser v-for="(i,idx) in images" :image="i" @click="image = i" :key="idx" />
|
<responsive-image :src="image.url" :title="image.title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area-settings">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><label>Pieces</label></td>
|
||||||
|
<td><input type="text" v-model="tiles" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label>Scoring: </label></td>
|
||||||
|
<td>
|
||||||
|
<label><input type="radio" v-model="scoreMode" value="1" /> Any (Score when pieces are connected to each other or on final location)</label>
|
||||||
|
<br />
|
||||||
|
<label><input type="radio" v-model="scoreMode" value="0" /> Final (Score when pieces are put to their final location)</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area-buttons">
|
||||||
|
<button class="btn" :disabled="!canStartNewGame" @click="onNewGameClick">
|
||||||
|
🧩 Generate Puzzle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent } from 'vue'
|
||||||
|
|
||||||
import { ScoreMode } from './../../common/GameCommon'
|
import { GameSettings, ScoreMode } from './../../common/GameCommon'
|
||||||
import Upload from './../components/Upload.vue'
|
import ResponsiveImage from './ResponsiveImage.vue'
|
||||||
import ImageTeaser from './../components/ImageTeaser.vue'
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'new-game-dialog',
|
name: 'new-game-dialog',
|
||||||
components: {
|
components: {
|
||||||
Upload,
|
ResponsiveImage,
|
||||||
ImageTeaser,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
images: Array,
|
image: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: {
|
emits: {
|
||||||
newGame: null,
|
newGame: null,
|
||||||
|
bgclick: null,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tiles: 1000,
|
tiles: 1000,
|
||||||
image: '',
|
|
||||||
scoreMode: ScoreMode.ANY,
|
scoreMode: ScoreMode.ANY,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// TODO: ts type UploadedImage
|
onNewGameClick () {
|
||||||
mediaImgUploaded(data: any) {
|
|
||||||
this.image = data.image
|
|
||||||
},
|
|
||||||
canStartNewGame () {
|
|
||||||
if (!this.tilesInt || !this.image || ![0, 1].includes(this.scoreModeInt)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
onNewGameClick() {
|
|
||||||
this.$emit('newGame', {
|
this.$emit('newGame', {
|
||||||
tiles: this.tilesInt,
|
tiles: this.tilesInt,
|
||||||
image: this.image,
|
image: this.image,
|
||||||
scoreMode: this.scoreModeInt,
|
scoreMode: this.scoreModeInt,
|
||||||
})
|
} as GameSettings)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
canStartNewGame () {
|
||||||
|
if (
|
||||||
|
!this.tilesInt
|
||||||
|
|| !this.image
|
||||||
|
|| !this.image.url
|
||||||
|
|| ![0, 1].includes(this.scoreModeInt)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
scoreModeInt (): number {
|
scoreModeInt (): number {
|
||||||
return parseInt(`${this.scoreMode}`, 10)
|
return parseInt(`${this.scoreMode}`, 10)
|
||||||
},
|
},
|
||||||
|
|
@ -96,3 +90,46 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: scoped vs .new-game-dialog
|
||||||
|
<style>
|
||||||
|
.new-game-dialog .overlay-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 450px;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"image settings"
|
||||||
|
"image buttons";
|
||||||
|
height: 90%;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
.new-game-dialog .area-image {
|
||||||
|
grid-area: image;
|
||||||
|
}
|
||||||
|
.new-game-dialog .area-settings {
|
||||||
|
grid-area: settings;
|
||||||
|
}
|
||||||
|
.new-game-dialog .area-settings table input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-game-dialog .area-buttons {
|
||||||
|
align-self: end;
|
||||||
|
grid-area: buttons;
|
||||||
|
}
|
||||||
|
.new-game-dialog .area-buttons button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.new-game-dialog .has-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.new-game-dialog .has-image .remove {
|
||||||
|
position: absolute;
|
||||||
|
top: .5em;
|
||||||
|
left: .5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
163
src/frontend/components/NewImageDialog.vue
Normal file
163
src/frontend/components/NewImageDialog.vue
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"Upload image" clicked: what it looks like when the image was uploaded.
|
||||||
|
Probably needs a (x) in the upper right corner of the image to allow the
|
||||||
|
user to remove the image if a wrong one was selected. The image should
|
||||||
|
be uploaded to the actual gallery only when the user presses "post to
|
||||||
|
gallery", if possible!
|
||||||
|
<template>
|
||||||
|
<div class="overlay new-image-dialog" @click="$emit('bgclick')">
|
||||||
|
<div class="overlay-content" @click.stop="">
|
||||||
|
|
||||||
|
<div class="area-image" :class="{'has-image': !!image.url, 'no-image': !image.url}">
|
||||||
|
<!-- TODO: ... -->
|
||||||
|
<div v-if="image.url" class="has-image">
|
||||||
|
<span class="remove btn" @click="image.url=''">X</span>
|
||||||
|
<responsive-image :src="image.url" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- TODO: drop area for image -->
|
||||||
|
<upload class="upload" @uploaded="mediaImgUploaded($event)" accept="image/*" label="Upload an image" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area-settings">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><label>Title</label></td>
|
||||||
|
<td><input type="text" v-model="image.title" placeholder="Flower by @artist" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">
|
||||||
|
<div class="hint">Feel free to leave a credit to the artist/photographer in the title :)</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<!-- TODO: autocomplete category -->
|
||||||
|
<td><label>Category</label></td>
|
||||||
|
<td><input type="text" v-model="image.category" placeholder="Plants" /></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="area-buttons">
|
||||||
|
<!-- <button class="btn" :disabled="!canPostToGallery" @click="postToGallery">🖼️ Post to gallery</button> -->
|
||||||
|
<button class="btn" :disabled="!canSetupGameClick" @click="setupGameClick">🧩 Post to gallery <br /> + set up game</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
|
||||||
|
import Upload from './Upload.vue'
|
||||||
|
import ResponsiveImage from './ResponsiveImage.vue'
|
||||||
|
import { Image } from '../../common/GameCommon'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'new-image-dialog',
|
||||||
|
components: {
|
||||||
|
Upload,
|
||||||
|
ResponsiveImage,
|
||||||
|
},
|
||||||
|
emits: {
|
||||||
|
bgclick: null,
|
||||||
|
setupGameClick: null,
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
image: {
|
||||||
|
file: '',
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
category: '',
|
||||||
|
} as Image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canPostToGallery () {
|
||||||
|
return !!this.image.url
|
||||||
|
},
|
||||||
|
canSetupGameClick () {
|
||||||
|
return !!this.image.url
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
mediaImgUploaded(data: any) {
|
||||||
|
this.image.file = data.image.file
|
||||||
|
this.image.url = data.image.url
|
||||||
|
},
|
||||||
|
postToGallery () {
|
||||||
|
this.$emit('postToGallery', this.image)
|
||||||
|
},
|
||||||
|
setupGameClick () {
|
||||||
|
this.$emit('setupGameClick', this.image)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
// TODO: scoped vs .new-image-dialog
|
||||||
|
<style>
|
||||||
|
.new-image-dialog .overlay-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 450px;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"image settings"
|
||||||
|
"image buttons";
|
||||||
|
height: 90%;
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-image-dialog .area-image {
|
||||||
|
grid-area: image;
|
||||||
|
}
|
||||||
|
.new-image-dialog .area-image.no-image {
|
||||||
|
align-content: center;
|
||||||
|
display: grid;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px;
|
||||||
|
border: dashed 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.new-image-dialog .area-image .has-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.new-image-dialog .area-image .has-image .remove {
|
||||||
|
position: absolute;
|
||||||
|
top: .5em;
|
||||||
|
left: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-image-dialog .area-settings {
|
||||||
|
grid-area: settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-image-dialog .area-settings table input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-image-dialog .area-buttons {
|
||||||
|
align-self: end;
|
||||||
|
grid-area: buttons;
|
||||||
|
}
|
||||||
|
.new-image-dialog .area-buttons button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.new-image-dialog .upload {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.new-image-dialog .upload .btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%,-50%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
src/frontend/components/ResponsiveImage.vue
Normal file
37
src/frontend/components/ResponsiveImage.vue
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<template>
|
||||||
|
<div :style="style" :title="title"></div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'responsive-image',
|
||||||
|
props: {
|
||||||
|
src: String,
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: '100%',
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: String,
|
||||||
|
default: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
style() {
|
||||||
|
return {
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'text-bottom',
|
||||||
|
backgroundImage: `url('${this.src}')`,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: this.width,
|
||||||
|
height: this.height,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--main-color: #c1b19f;
|
--main-color: #c1b19f;
|
||||||
|
--main-darker-color: #4f4e4c;
|
||||||
--link-color: #808db0;
|
--link-color: #808db0;
|
||||||
--link-hover-color: #c5cfeb;
|
--link-hover-color: #c5cfeb;
|
||||||
--highlight-color: #dd7e7e;
|
--highlight-color: #dd7e7e;
|
||||||
|
|
@ -48,6 +49,11 @@ td, th {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-big {
|
||||||
|
font-size: 1.5em;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: #2f2e2c;
|
background: #2f2e2c;
|
||||||
color: var(--link-hover-color);
|
color: var(--link-hover-color);
|
||||||
|
|
@ -202,6 +208,22 @@ kbd {
|
||||||
|
|
||||||
/* pre-game stuff */
|
/* pre-game stuff */
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--main-darker-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-image-teaser {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-image-teaser .btn {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
table label {
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,97 @@
|
||||||
|
"New Game" page: Upload button big, centered, at the top of the page, as
|
||||||
|
visible as possible. Upload button has a warning that the image will
|
||||||
|
be added to public gallery, just so noone uploads anything naughty on
|
||||||
|
accident. The page can show all the images by default, or one of the categories
|
||||||
|
of images. Instead of categories, you can make the system tag-based, like
|
||||||
|
in jigsawpuzzles.io
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<new-game-dialog :images="images" @newGame="onNewGame" />
|
<div class="upload-image-teaser">
|
||||||
|
<div class="btn btn-big" @click="dialog='new-image'">Upload your image</div>
|
||||||
|
<div class="hint">(The image you upload will be added to the public gallery.)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label v-if="categories.length > 0">
|
||||||
|
Category:
|
||||||
|
<select v-model="filters.category" @change="filtersChanged">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option v-for="(c, idx) in categories" :key="idx" :value="c">{{c}}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sort by:
|
||||||
|
<select v-model="filters.sort" @change="filtersChanged">
|
||||||
|
<option value="date_desc">Newest first</option>
|
||||||
|
<option value="date_asc">Oldest first</option>
|
||||||
|
<option value="alpha_asc">A-Z</option>
|
||||||
|
<option value="alpha_desc">Z-A</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<image-library :images="images" :categories="categories" @imageClicked="imageClicked" />
|
||||||
|
<new-image-dialog v-if="dialog==='new-image'" @bgclick="dialog=''" @setupGameClick="setupGameClick" />
|
||||||
|
<new-game-dialog v-if="image && dialog==='new-game'" @bgclick="dialog=''" @newGame="onNewGame" :image="image" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent } from 'vue'
|
||||||
|
|
||||||
// TODO: maybe move dialog back, now that this is a view on its own
|
import ImageLibrary from './../components/ImageLibrary.vue'
|
||||||
|
import NewImageDialog from './../components/NewImageDialog.vue'
|
||||||
import NewGameDialog from './../components/NewGameDialog.vue'
|
import NewGameDialog from './../components/NewGameDialog.vue'
|
||||||
|
import { GameSettings, Image } from '../../common/GameCommon'
|
||||||
|
import Util from '../../common/Util'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
ImageLibrary,
|
||||||
|
NewImageDialog,
|
||||||
NewGameDialog,
|
NewGameDialog,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
filters: {
|
||||||
|
sort: 'date_desc',
|
||||||
|
category: '',
|
||||||
|
},
|
||||||
images: [],
|
images: [],
|
||||||
|
categories: [],
|
||||||
|
|
||||||
|
image: {
|
||||||
|
url: '',
|
||||||
|
file: '',
|
||||||
|
title: '',
|
||||||
|
category: '',
|
||||||
|
} as Image,
|
||||||
|
|
||||||
|
dialog: '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async created() {
|
async created() {
|
||||||
const res = await fetch('/api/newgame-data')
|
await this.loadImages()
|
||||||
const json = await res.json()
|
|
||||||
this.images = json.images
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// TODO: ts GameSettings type
|
async loadImages () {
|
||||||
async onNewGame(gameSettings: any) {
|
const res = await fetch(`/api/newgame-data${Util.asQueryArgs(this.filters)}`)
|
||||||
|
const json = await res.json()
|
||||||
|
this.images = json.images
|
||||||
|
this.categories = json.categories
|
||||||
|
},
|
||||||
|
async filtersChanged () {
|
||||||
|
await this.loadImages()
|
||||||
|
},
|
||||||
|
imageClicked (image: Image) {
|
||||||
|
this.image = image
|
||||||
|
this.dialog = 'new-game'
|
||||||
|
},
|
||||||
|
setupGameClick (image: Image) {
|
||||||
|
this.image = image
|
||||||
|
this.dialog = 'new-game'
|
||||||
|
},
|
||||||
|
async onNewGame(gameSettings: GameSettings) {
|
||||||
const res = await fetch('/newgame', {
|
const res = await fetch('/newgame', {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -39,7 +104,7 @@ export default defineComponent({
|
||||||
const game = await res.json()
|
const game = await res.json()
|
||||||
this.$router.push({ name: 'game', params: { id: game.id } })
|
this.$router.push({ name: 'game', params: { id: game.id } })
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -46,18 +46,44 @@ async function getExifOrientation(imagePath: string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const allImages = () => {
|
const allImages = (sort: string) => {
|
||||||
const 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 => ({
|
||||||
filename: f,
|
filename: f,
|
||||||
file: `${UPLOAD_DIR}/${f}`,
|
file: `${UPLOAD_DIR}/${f}`,
|
||||||
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
|
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
|
||||||
|
title: '',
|
||||||
|
category: '',
|
||||||
|
ts: fs.statSync(`${UPLOAD_DIR}/${f}`).mtime.getTime(),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
|
||||||
return fs.statSync(b.file).mtime.getTime() -
|
switch (sort) {
|
||||||
fs.statSync(a.file).mtime.getTime()
|
case 'alpha_asc':
|
||||||
})
|
images = images.sort((a, b) => {
|
||||||
|
return a.file > b.file ? 1 : -1
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'alpha_desc':
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.file < b.file ? 1 : -1
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'date_asc':
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.ts > b.ts ? 1 : -1
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'date_desc':
|
||||||
|
default:
|
||||||
|
images = images.sort((a, b) => {
|
||||||
|
return a.ts < b.ts ? 1 : -1
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
return images
|
return images
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import GameSockets from './GameSockets'
|
||||||
import Time from './../common/Time'
|
import Time from './../common/Time'
|
||||||
import Images from './Images'
|
import Images from './Images'
|
||||||
import { UPLOAD_DIR, UPLOAD_URL, PUBLIC_DIR } from './Dirs'
|
import { UPLOAD_DIR, UPLOAD_URL, PUBLIC_DIR } from './Dirs'
|
||||||
import GameCommon, { ScoreMode } from '../common/GameCommon'
|
import { GameSettings, ScoreMode } from '../common/GameCommon'
|
||||||
import GameStorage from './GameStorage'
|
import GameStorage from './GameStorage'
|
||||||
|
|
||||||
let configFile = ''
|
let configFile = ''
|
||||||
|
|
@ -52,8 +52,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
|
||||||
res.send({
|
res.send({
|
||||||
images: Images.allImages(),
|
images: Images.allImages(q.sort),
|
||||||
|
categories: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -102,16 +104,17 @@ app.post('/upload', (req, res) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/newgame', bodyParser.json(), async (req, res) => {
|
app.post('/newgame', bodyParser.json(), async (req, res) => {
|
||||||
log.log(req.body.tiles, req.body.image)
|
const gameSettings = req.body as GameSettings
|
||||||
|
log.log(gameSettings)
|
||||||
const gameId = Util.uniqId()
|
const gameId = Util.uniqId()
|
||||||
if (!Game.exists(gameId)) {
|
if (!Game.exists(gameId)) {
|
||||||
const ts = Time.timestamp()
|
const ts = Time.timestamp()
|
||||||
await Game.createGame(
|
await Game.createGame(
|
||||||
gameId,
|
gameId,
|
||||||
req.body.tiles,
|
gameSettings.tiles,
|
||||||
req.body.image,
|
gameSettings.image,
|
||||||
ts,
|
ts,
|
||||||
req.body.scoreMode
|
gameSettings.scoreMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
res.send({ id: gameId })
|
res.send({ id: gameId })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue