feat: add photo booth functionality with shooting, uploading, and printing steps
- Implemented PrintStepView for displaying countdown and photo results. - Created SelectSourceView for choosing between shooting new photos or uploading existing ones. - Developed ShootingView for capturing photos using the device camera. - Added UploadView for selecting and previewing uploaded photos. - Configured TypeScript settings with tsconfig files for app and node environments. - Set up Vite configuration with PWA support for the application.
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# .
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7453
package-lock.json
generated
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "photobooth",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.26",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node24": "^24.0.3",
|
||||||
|
"@types/node": "^24.10.4",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.3.0",
|
||||||
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
|
"vue-tsc": "^3.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
5
public/frame-classic.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="300" fill="#ffffff" stroke="#dee2e6" stroke-width="2" rx="8"/>
|
||||||
|
<rect x="10" y="10" width="180" height="40" fill="#f8f9fa" rx="4"/>
|
||||||
|
<text x="100" y="35" font-family="Arial" font-size="14" fill="#666" text-anchor="middle">Classic Frame</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
11
public/frame-colorful.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorful" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4facfe"/>
|
||||||
|
<stop offset="100%" style="stop-color:#00f2fe"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="200" height="300" fill="url(#colorful)" rx="8"/>
|
||||||
|
<rect x="10" y="10" width="180" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
|
||||||
|
<text x="100" y="35" font-family="Arial" font-size="14" fill="white" text-anchor="middle">Colorful Frame</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 554 B |
5
public/frame-minimal.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="300" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2" rx="8"/>
|
||||||
|
<rect x="10" y="10" width="180" height="40" fill="#ffffff" stroke="#dee2e6" stroke-width="1" rx="4"/>
|
||||||
|
<text x="100" y="35" font-family="Arial" font-size="14" fill="#666" text-anchor="middle">Minimal Frame</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 379 B |
11
public/frame-modern.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="modern" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea"/>
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="200" height="300" fill="url(#modern)" rx="8"/>
|
||||||
|
<rect x="10" y="10" width="180" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
|
||||||
|
<text x="100" y="35" font-family="Arial" font-size="14" fill="white" text-anchor="middle">Modern Frame</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 548 B |
11
public/frame-vintage.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="200" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="vintage" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#f093fb"/>
|
||||||
|
<stop offset="100%" style="stop-color:#f5576c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="200" height="300" fill="url(#vintage)" rx="8"/>
|
||||||
|
<rect x="10" y="10" width="180" height="40" fill="rgba(255,255,255,0.2)" rx="4"/>
|
||||||
|
<text x="100" y="35" font-family="Arial" font-size="14" fill="white" text-anchor="middle">Vintage Frame</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 551 B |
4
public/icon-192x192.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#667eea" rx="24"/>
|
||||||
|
<text x="96" y="120" font-family="Arial" font-size="72" fill="white" text-anchor="middle">📷</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 234 B |
7
public/placeholder-photo1.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="360" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="360" height="480" fill="#f8f9fa" rx="8"/>
|
||||||
|
<circle cx="180" cy="160" r="60" fill="#dee2e6"/>
|
||||||
|
<rect x="120" y="240" width="120" height="20" fill="#dee2e6" rx="4"/>
|
||||||
|
<rect x="140" y="270" width="80" height="16" fill="#dee2e6" rx="4"/>
|
||||||
|
<text x="180" y="400" font-family="Arial" font-size="16" fill="#666" text-anchor="middle">ตัวอย่างรูปถ่าย</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 469 B |
13
public/placeholder-photo2.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="360" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea"/>
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="360" height="480" fill="url(#grad1)" rx="8"/>
|
||||||
|
<circle cx="180" cy="160" r="60" fill="rgba(255,255,255,0.3)"/>
|
||||||
|
<rect x="120" y="240" width="120" height="20" fill="rgba(255,255,255,0.3)" rx="4"/>
|
||||||
|
<rect x="140" y="270" width="80" height="16" fill="rgba(255,255,255,0.3)" rx="4"/>
|
||||||
|
<text x="180" y="400" font-family="Arial" font-size="16" fill="white" text-anchor="middle">รูปถ่ายตัวอย่าง 2</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 735 B |
7
public/placeholder-photo3.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="360" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="360" height="480" fill="#ff6b6b" rx="8"/>
|
||||||
|
<circle cx="180" cy="160" r="60" fill="rgba(255,255,255,0.8)"/>
|
||||||
|
<rect x="120" y="240" width="120" height="20" fill="rgba(255,255,255,0.8)" rx="4"/>
|
||||||
|
<rect x="140" y="270" width="80" height="16" fill="rgba(255,255,255,0.8)" rx="4"/>
|
||||||
|
<text x="180" y="400" font-family="Arial" font-size="16" fill="white" text-anchor="middle">รูปถ่ายตัวอย่าง 3</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 514 B |
7
public/placeholder-photo4.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="360" height="480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="360" height="480" fill="#4ecdc4" rx="8"/>
|
||||||
|
<circle cx="180" cy="160" r="60" fill="rgba(255,255,255,0.8)"/>
|
||||||
|
<rect x="120" y="240" width="120" height="20" fill="rgba(255,255,255,0.8)" rx="4"/>
|
||||||
|
<rect x="140" y="270" width="80" height="16" fill="rgba(255,255,255,0.8)" rx="4"/>
|
||||||
|
<text x="180" y="400" font-family="Arial" font-size="16" fill="white" text-anchor="middle">รูปถ่ายตัวอย่าง 4</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 514 B |
85
src/App.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
import HelloWorld from './components/HelloWorld.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
<HelloWorld msg="You did it!" />
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<RouterLink to="/">Home</RouterLink>
|
||||||
|
<RouterLink to="/about">About</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
header {
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.router-link-exact-active {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.router-link-exact-active:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:first-of-type {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
padding-right: calc(var(--section-gap) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin: 0 2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .wrapper {
|
||||||
|
display: flex;
|
||||||
|
place-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
text-align: left;
|
||||||
|
margin-left: -1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition:
|
||||||
|
color 0.5s,
|
||||||
|
background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
35
src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
msg: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="greetings">
|
||||||
|
<h1 class="green">{{ msg }}</h1>
|
||||||
|
<h3>
|
||||||
|
You’ve successfully created a project with
|
||||||
|
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||||
|
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
position: relative;
|
||||||
|
top: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greetings h1,
|
||||||
|
.greetings h3 {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.greetings h1,
|
||||||
|
.greetings h3 {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
src/components/TheWelcome.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import WelcomeItem from './WelcomeItem.vue'
|
||||||
|
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||||
|
import ToolingIcon from './icons/IconTooling.vue'
|
||||||
|
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||||
|
import CommunityIcon from './icons/IconCommunity.vue'
|
||||||
|
import SupportIcon from './icons/IconSupport.vue'
|
||||||
|
|
||||||
|
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<DocumentationIcon />
|
||||||
|
</template>
|
||||||
|
<template #heading>Documentation</template>
|
||||||
|
|
||||||
|
Vue’s
|
||||||
|
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||||
|
provides you with all information you need to get started.
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<ToolingIcon />
|
||||||
|
</template>
|
||||||
|
<template #heading>Tooling</template>
|
||||||
|
|
||||||
|
This project is served and bundled with
|
||||||
|
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||||
|
recommended IDE setup is
|
||||||
|
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||||
|
+
|
||||||
|
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||||
|
>Vue - Official</a
|
||||||
|
>. If you need to test your components and web pages, check out
|
||||||
|
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||||
|
and
|
||||||
|
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||||
|
/
|
||||||
|
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
More instructions are available in
|
||||||
|
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||||
|
>.
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<EcosystemIcon />
|
||||||
|
</template>
|
||||||
|
<template #heading>Ecosystem</template>
|
||||||
|
|
||||||
|
Get official tools and libraries for your project:
|
||||||
|
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||||
|
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||||
|
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||||
|
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||||
|
you need more resources, we suggest paying
|
||||||
|
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||||
|
a visit.
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<CommunityIcon />
|
||||||
|
</template>
|
||||||
|
<template #heading>Community</template>
|
||||||
|
|
||||||
|
Got stuck? Ask your question on
|
||||||
|
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||||
|
(our official Discord server), or
|
||||||
|
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||||
|
>StackOverflow</a
|
||||||
|
>. You should also follow the official
|
||||||
|
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||||
|
Bluesky account or the
|
||||||
|
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||||
|
X account for latest news in the Vue world.
|
||||||
|
</WelcomeItem>
|
||||||
|
|
||||||
|
<WelcomeItem>
|
||||||
|
<template #icon>
|
||||||
|
<SupportIcon />
|
||||||
|
</template>
|
||||||
|
<template #heading>Support Vue</template>
|
||||||
|
|
||||||
|
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||||
|
us by
|
||||||
|
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||||
|
</WelcomeItem>
|
||||||
|
</template>
|
||||||
87
src/components/WelcomeItem.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="item">
|
||||||
|
<i>
|
||||||
|
<slot name="icon"></slot>
|
||||||
|
</i>
|
||||||
|
<div class="details">
|
||||||
|
<h3>
|
||||||
|
<slot name="heading"></slot>
|
||||||
|
</h3>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
place-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: var(--color-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.item {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
top: calc(50% - 25px);
|
||||||
|
left: -26px;
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:before {
|
||||||
|
content: ' ';
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: calc(50% + 25px);
|
||||||
|
height: calc(50% - 25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:after {
|
||||||
|
content: ' ';
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(50% + 25px);
|
||||||
|
height: calc(50% - 25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:first-of-type:before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:last-of-type:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
src/components/icons/IconCommunity.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
src/components/icons/IconDocumentation.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
src/components/icons/IconEcosystem.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
src/components/icons/IconSupport.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
19
src/components/icons/IconTooling.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
class="iconify iconify--mdi"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
11
src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
45
src/router/index.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from '../views/HomeView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/frame',
|
||||||
|
name: 'frame',
|
||||||
|
component: () => import('../views/FrameView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/selectsource',
|
||||||
|
name: 'selectsource',
|
||||||
|
component: () => import('../views/SelectSourceView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/shooting',
|
||||||
|
name: 'shooting',
|
||||||
|
component: () => import('../views/ShootingView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/upload',
|
||||||
|
name: 'upload',
|
||||||
|
component: () => import('../views/UploadView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/printstep',
|
||||||
|
name: 'printstep',
|
||||||
|
component: () => import('../views/PrintStepView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pickup',
|
||||||
|
name: 'pickup',
|
||||||
|
component: () => import('../views/PickupView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
15
src/views/AboutView.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div class="about">
|
||||||
|
<h1>This is an about page</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.about {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
src/views/FrameView.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const selectedLayout = ref<'1x4' | '2x2'>('1x4')
|
||||||
|
const selectedFrame = ref<number>(0)
|
||||||
|
|
||||||
|
const layouts = [
|
||||||
|
{ id: '1x4', name: '1x4 แนวตั้ง', cols: 1, rows: 4 },
|
||||||
|
{ id: '2x2', name: '2x2 ตาราง', cols: 2, rows: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const frames = [
|
||||||
|
{ id: 0, name: 'กรอบคลาสสิก', preview: '/frame-classic.svg' },
|
||||||
|
{ id: 1, name: 'กรอบโมเดิร์น', preview: '/frame-modern.svg' },
|
||||||
|
{ id: 2, name: 'กรอบวินเทจ', preview: '/frame-vintage.svg' },
|
||||||
|
{ id: 3, name: 'กรอบสีสดใส', preview: '/frame-colorful.svg' },
|
||||||
|
{ id: 4, name: 'กรอบมินิมอล', preview: '/frame-minimal.svg' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectLayout = (layoutId: string) => {
|
||||||
|
selectedLayout.value = layoutId as '1x4' | '2x2'
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFrame = (frameId: number) => {
|
||||||
|
selectedFrame.value = frameId
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceedToSource = () => {
|
||||||
|
// เก็บข้อมูล layout และ frame ที่เลือกไว้ใน localStorage หรือ state management
|
||||||
|
localStorage.setItem('photobooth-layout', selectedLayout.value)
|
||||||
|
localStorage.setItem('photobooth-frame', selectedFrame.value.toString())
|
||||||
|
|
||||||
|
router.push('/selectsource')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="frame-container">
|
||||||
|
<header class="header">
|
||||||
|
<button @click="goBack" class="back-button">← กลับ</button>
|
||||||
|
<h1>เลือกกรอบรูป</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="layout-section">
|
||||||
|
<h2>เลือกรูปแบบ</h2>
|
||||||
|
<div class="layout-options">
|
||||||
|
<div
|
||||||
|
v-for="layout in layouts"
|
||||||
|
:key="layout.id"
|
||||||
|
:class="['layout-option', { selected: selectedLayout === layout.id }]"
|
||||||
|
@click="selectLayout(layout.id)"
|
||||||
|
>
|
||||||
|
<div class="layout-preview">
|
||||||
|
<div
|
||||||
|
class="layout-grid"
|
||||||
|
:style="{ gridTemplateColumns: `repeat(${layout.cols}, 1fr)`, gridTemplateRows: `repeat(${layout.rows}, 1fr)` }"
|
||||||
|
>
|
||||||
|
<div v-for="i in layout.cols * layout.rows" :key="i" class="grid-cell"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>{{ layout.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="frame-section">
|
||||||
|
<h2>เลือกกรอบ</h2>
|
||||||
|
<div class="frame-options">
|
||||||
|
<div
|
||||||
|
v-for="frame in frames"
|
||||||
|
:key="frame.id"
|
||||||
|
:class="['frame-option', { selected: selectedFrame === frame.id }]"
|
||||||
|
@click="selectFrame(frame.id)"
|
||||||
|
>
|
||||||
|
<div class="frame-preview">
|
||||||
|
<img :src="frame.preview" :alt="frame.name" />
|
||||||
|
</div>
|
||||||
|
<span>{{ frame.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="action-section">
|
||||||
|
<button @click="proceedToSource" class="next-button">
|
||||||
|
ถัดไป
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.frame-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-section, .frame-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-options, .frame-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-option, .frame-option {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-option.selected, .frame-option.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,123,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-preview {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
max-width: 120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
background: #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-button {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(40,167,69,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-button:hover {
|
||||||
|
background: #218838;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(40,167,69,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.layout-options, .frame-options {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
129
src/views/HomeView.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const startPhotobooth = () => {
|
||||||
|
router.push('/frame')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ตัวอย่างรูปถ่าย (จะถูกแทนที่ด้วยรูปจริงในอนาคต)
|
||||||
|
const examplePhotos = ref([
|
||||||
|
'/placeholder-photo1.svg',
|
||||||
|
'/placeholder-photo2.svg',
|
||||||
|
'/placeholder-photo3.svg',
|
||||||
|
'/placeholder-photo4.svg'
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home-container">
|
||||||
|
<header class="hero-section">
|
||||||
|
<h1 class="title">DekThai Photobooth</h1>
|
||||||
|
<p class="subtitle">สร้างรูปถ่ายสุดพิเศษกับกรอบสวยงาม</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="examples-section">
|
||||||
|
<h2>ตัวอย่างผลงาน</h2>
|
||||||
|
<div class="photo-examples">
|
||||||
|
<div class="example-strip">
|
||||||
|
<img v-for="photo in examplePhotos" :key="photo" :src="photo" alt="Example photo" class="example-photo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta-section">
|
||||||
|
<button @click="startPhotobooth" class="start-button">
|
||||||
|
เริ่มสร้างรูปถ่าย
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples-section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-examples {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-photo {
|
||||||
|
width: 120px;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(255,107,107,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button:hover {
|
||||||
|
background: #ff5252;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(255,107,107,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-photo {
|
||||||
|
width: 80px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
403
src/views/PickupView.vue
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const photos = ref<string[]>([])
|
||||||
|
const layout = ref<'1x4' | '2x2'>('1x4')
|
||||||
|
const frame = ref<number>(0)
|
||||||
|
const shareUrl = ref('')
|
||||||
|
const qrCodeUrl = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// โหลดข้อมูลจาก localStorage
|
||||||
|
const savedPhotos = localStorage.getItem('photobooth-photos')
|
||||||
|
const savedLayout = localStorage.getItem('photobooth-layout')
|
||||||
|
const savedFrame = localStorage.getItem('photobooth-frame')
|
||||||
|
|
||||||
|
if (savedPhotos) {
|
||||||
|
photos.value = JSON.parse(savedPhotos)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedLayout) {
|
||||||
|
layout.value = savedLayout as '1x4' | '2x2'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedFrame) {
|
||||||
|
frame.value = parseInt(savedFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// สร้าง URL สำหรับแชร์ (ในโปรเจกต์จริงควรอัพโหลดขึ้น cloud)
|
||||||
|
generateShareUrl()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getGridStyle = computed(() => {
|
||||||
|
if (layout.value === '1x4') {
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
|
gridTemplateRows: 'repeat(4, 1fr)',
|
||||||
|
gap: '8px'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gridTemplateRows: 'repeat(2, 1fr)',
|
||||||
|
gap: '8px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateShareUrl = () => {
|
||||||
|
// ในโปรเจกต์จริง ควรอัพโหลดรูปขึ้น cloud และสร้าง URL
|
||||||
|
// ตัวอย่างนี้ใช้ data URL ชั่วคราว
|
||||||
|
const photoData = {
|
||||||
|
photos: photos.value,
|
||||||
|
layout: layout.value,
|
||||||
|
frame: frame.value,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// เข้ารหัสข้อมูลเป็น base64 และสร้าง URL
|
||||||
|
const encodedData = btoa(JSON.stringify(photoData))
|
||||||
|
shareUrl.value = `${window.location.origin}/share/${encodedData}`
|
||||||
|
|
||||||
|
// สร้าง QR code URL (ใช้ service ภายนอก)
|
||||||
|
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(shareUrl.value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadImage = () => {
|
||||||
|
// ในโปรเจกต์จริงควรสร้าง canvas และ download เป็นไฟล์ภาพ
|
||||||
|
// ตัวอย่างนี้ download เป็น JSON ชั่วคราว
|
||||||
|
const data = {
|
||||||
|
photos: photos.value,
|
||||||
|
layout: layout.value,
|
||||||
|
frame: frame.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `photobooth-${Date.now()}.json`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareImage = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'รูปถ่ายจาก DekThai Photobooth',
|
||||||
|
text: 'ดูรูปถ่ายสุดพิเศษที่ฉันสร้าง!',
|
||||||
|
url: shareUrl.value
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error sharing:', error)
|
||||||
|
copyToClipboard()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copyToClipboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl.value).then(() => {
|
||||||
|
alert('คัดลอกลิงก์แล้ว! สามารถแชร์ให้เพื่อนได้')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOver = () => {
|
||||||
|
// เคลียร์ข้อมูลและกลับไปหน้าแรก
|
||||||
|
localStorage.removeItem('photobooth-photos')
|
||||||
|
localStorage.removeItem('photobooth-layout')
|
||||||
|
localStorage.removeItem('photobooth-frame')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pickup-container">
|
||||||
|
<header class="header">
|
||||||
|
<h1>รูปถ่ายเสร็จแล้ว!</h1>
|
||||||
|
<p>ดาวน์โหลดหรือแชร์รูปถ่ายของคุณ</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="result-section">
|
||||||
|
<div class="photo-result">
|
||||||
|
<div class="photo-frame" :class="`frame-${frame}`">
|
||||||
|
<div class="photo-grid" :style="getGridStyle">
|
||||||
|
<div
|
||||||
|
v-for="(photo, index) in photos"
|
||||||
|
:key="index"
|
||||||
|
class="photo-cell"
|
||||||
|
>
|
||||||
|
<img :src="photo" :alt="`Photo ${index + 1}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="actions-section">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button @click="downloadImage" class="download-button">
|
||||||
|
<span class="button-icon">📥</span>
|
||||||
|
ดาวน์โหลด
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button @click="shareImage" class="share-button">
|
||||||
|
<span class="button-icon">📤</span>
|
||||||
|
แชร์
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="qr-section">
|
||||||
|
<h3>สแกน QR Code เพื่อดูรูป</h3>
|
||||||
|
<div class="qr-container">
|
||||||
|
<img :src="qrCodeUrl" alt="QR Code" class="qr-code" />
|
||||||
|
<p class="qr-text">{{ shareUrl }}</p>
|
||||||
|
<button @click="copyToClipboard" class="copy-link-button">
|
||||||
|
คัดลอกลิงก์
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="footer-section">
|
||||||
|
<button @click="startOver" class="start-over-button">
|
||||||
|
สร้างรูปใหม่
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pickup-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-result {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-cell img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button, .share-button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,123,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-button {
|
||||||
|
background: #28a745;
|
||||||
|
box-shadow: 0 4px 15px rgba(40,167,69,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,123,255,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-button:hover {
|
||||||
|
background: #218838;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(40,167,69,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section h3 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-button {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-button:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-over-button {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(255,107,107,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-over-button:hover {
|
||||||
|
background: #ff5252;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(255,107,107,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frame styles */
|
||||||
|
.frame-0 {
|
||||||
|
/* Classic frame - default white */
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-1 {
|
||||||
|
/* Modern frame */
|
||||||
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-2 {
|
||||||
|
/* Vintage frame */
|
||||||
|
background: linear-gradient(45deg, #f093fb, #f5576c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-3 {
|
||||||
|
/* Colorful frame */
|
||||||
|
background: linear-gradient(45deg, #4facfe, #00f2fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-4 {
|
||||||
|
/* Minimal frame */
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button, .share-button {
|
||||||
|
width: 200px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
300
src/views/PrintStepView.vue
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const countdown = ref(3)
|
||||||
|
const showResult = ref(false)
|
||||||
|
const photos = ref<string[]>([])
|
||||||
|
const layout = ref<'1x4' | '2x2'>('1x4')
|
||||||
|
const frame = ref<number>(0)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// โหลดข้อมูลจาก localStorage
|
||||||
|
const savedPhotos = localStorage.getItem('photobooth-photos')
|
||||||
|
const savedLayout = localStorage.getItem('photobooth-layout')
|
||||||
|
const savedFrame = localStorage.getItem('photobooth-frame')
|
||||||
|
|
||||||
|
if (savedPhotos) {
|
||||||
|
photos.value = JSON.parse(savedPhotos)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedLayout) {
|
||||||
|
layout.value = savedLayout as '1x4' | '2x2'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedFrame) {
|
||||||
|
frame.value = parseInt(savedFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// เริ่มนับถอยหลัง
|
||||||
|
startCountdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
const startCountdown = () => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
clearInterval(timer)
|
||||||
|
showResult.value = true
|
||||||
|
|
||||||
|
// หลังจากแสดงผล 3 วินาที ให้ไปหน้า pickup
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/pickup')
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGridStyle = () => {
|
||||||
|
if (layout.value === '1x4') {
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr',
|
||||||
|
gridTemplateRows: 'repeat(4, 1fr)',
|
||||||
|
gap: '8px'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||||
|
gridTemplateRows: 'repeat(2, 1fr)',
|
||||||
|
gap: '8px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="print-container">
|
||||||
|
<div v-if="!showResult" class="countdown-section">
|
||||||
|
<div class="countdown-display">
|
||||||
|
<div class="countdown-number">{{ countdown }}</div>
|
||||||
|
<div class="processing-text">กำลังประมวลผล...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: `${((4 - countdown) / 4) * 100}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="result-section">
|
||||||
|
<div class="result-header">
|
||||||
|
<h1>เสร็จแล้ว!</h1>
|
||||||
|
<p>กำลังเตรียมไฟล์สำหรับดาวน์โหลด...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="photo-result">
|
||||||
|
<div class="photo-frame" :class="`frame-${frame}`">
|
||||||
|
<div class="photo-grid" :style="getGridStyle()">
|
||||||
|
<div
|
||||||
|
v-for="(photo, index) in photos"
|
||||||
|
:key="index"
|
||||||
|
class="photo-cell"
|
||||||
|
>
|
||||||
|
<img :src="photo" :alt="`Photo ${index + 1}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>กำลังสร้างไฟล์...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.print-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #000;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-section {
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeIn 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-display {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-number {
|
||||||
|
font-size: 8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
animation: scalePulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 300px;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #4ecdc4);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
text-align: center;
|
||||||
|
animation: slideUp 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-result {
|
||||||
|
margin: 2rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(255,255,255,0.1);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-cell {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-cell img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.3);
|
||||||
|
border-top: 3px solid #ff6b6b;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator span {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scalePulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frame styles */
|
||||||
|
.frame-0 {
|
||||||
|
/* Classic frame - default white */
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-1 {
|
||||||
|
/* Modern frame */
|
||||||
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-2 {
|
||||||
|
/* Vintage frame */
|
||||||
|
background: linear-gradient(45deg, #f093fb, #f5576c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-3 {
|
||||||
|
/* Colorful frame */
|
||||||
|
background: linear-gradient(45deg, #4facfe, #00f2fe);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-4 {
|
||||||
|
/* Minimal frame */
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.countdown-number {
|
||||||
|
font-size: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
src/views/SelectSourceView.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const goToShooting = () => {
|
||||||
|
router.push('/shooting')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToUpload = () => {
|
||||||
|
router.push('/upload')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/frame')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="source-container">
|
||||||
|
<header class="header">
|
||||||
|
<button @click="goBack" class="back-button">← กลับ</button>
|
||||||
|
<h1>เลือกแหล่งภาพ</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="options-section">
|
||||||
|
<div class="option-card" @click="goToShooting">
|
||||||
|
<div class="option-icon">
|
||||||
|
📷
|
||||||
|
</div>
|
||||||
|
<h3>ถ่ายภาพใหม่</h3>
|
||||||
|
<p>ถ่ายภาพ 4 รูปจากกล้องหรือเว็บแคม</p>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-card" @click="goToUpload">
|
||||||
|
<div class="option-icon">
|
||||||
|
🖼️
|
||||||
|
</div>
|
||||||
|
<h3>อัพโหลดภาพ</h3>
|
||||||
|
<p>เลือกภาพ 4 รูปจากคลังภาพในอุปกรณ์</p>
|
||||||
|
<div class="arrow">→</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.source-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.option-card {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
370
src/views/ShootingView.vue
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const videoRef = ref<HTMLVideoElement>()
|
||||||
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
|
const photos = ref<string[]>([])
|
||||||
|
const currentPhotoIndex = ref(0)
|
||||||
|
const isCapturing = ref(false)
|
||||||
|
const stream = ref<MediaStream | null>(null)
|
||||||
|
|
||||||
|
const totalPhotos = 4
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await startCamera()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCamera()
|
||||||
|
})
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
facingMode: 'user',
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.value = await navigator.mediaDevices.getUserMedia(constraints)
|
||||||
|
if (videoRef.value) {
|
||||||
|
videoRef.value.srcObject = stream.value
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing camera:', error)
|
||||||
|
alert('ไม่สามารถเข้าถึงกล้องได้ กรุณาตรวจสอบสิทธิ์การใช้งานกล้อง')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopCamera = () => {
|
||||||
|
if (stream.value) {
|
||||||
|
stream.value.getTracks().forEach(track => track.stop())
|
||||||
|
stream.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const capturePhoto = () => {
|
||||||
|
if (!videoRef.value || !canvasRef.value || isCapturing.value) return
|
||||||
|
|
||||||
|
isCapturing.value = true
|
||||||
|
|
||||||
|
const video = videoRef.value
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!context) return
|
||||||
|
|
||||||
|
// ตั้งค่าขนาด canvas ให้ตรงกับ video
|
||||||
|
canvas.width = video.videoWidth
|
||||||
|
canvas.height = video.videoHeight
|
||||||
|
|
||||||
|
// วาดภาพจาก video ไปยัง canvas
|
||||||
|
context.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// แปลงเป็น base64
|
||||||
|
const photoDataUrl = canvas.toDataURL('image/jpeg', 0.9)
|
||||||
|
photos.value.push(photoDataUrl)
|
||||||
|
currentPhotoIndex.value++
|
||||||
|
|
||||||
|
// ถ่ายครบ 4 รูปแล้ว ไปหน้าถัดไป
|
||||||
|
if (currentPhotoIndex.value >= totalPhotos) {
|
||||||
|
proceedToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isCapturing.value = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceedToNext = () => {
|
||||||
|
// เก็บรูปภาพไว้ใน localStorage
|
||||||
|
localStorage.setItem('photobooth-photos', JSON.stringify(photos.value))
|
||||||
|
stopCamera()
|
||||||
|
router.push('/printstep')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
stopCamera()
|
||||||
|
router.push('/selectsource')
|
||||||
|
}
|
||||||
|
|
||||||
|
const retakePhoto = () => {
|
||||||
|
photos.value = []
|
||||||
|
currentPhotoIndex.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shooting-container">
|
||||||
|
<header class="header">
|
||||||
|
<button @click="goBack" class="back-button">← กลับ</button>
|
||||||
|
<h1>ถ่ายภาพ</h1>
|
||||||
|
<span class="photo-counter">{{ currentPhotoIndex }}/{{ totalPhotos }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="camera-section">
|
||||||
|
<div class="camera-container">
|
||||||
|
<video
|
||||||
|
ref="videoRef"
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
class="camera-video"
|
||||||
|
></video>
|
||||||
|
<canvas ref="canvasRef" class="hidden-canvas"></canvas>
|
||||||
|
|
||||||
|
<div class="camera-overlay">
|
||||||
|
<div class="capture-guide">
|
||||||
|
<div class="guide-frame"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button
|
||||||
|
@click="capturePhoto"
|
||||||
|
:disabled="isCapturing"
|
||||||
|
class="capture-button"
|
||||||
|
:class="{ capturing: isCapturing }"
|
||||||
|
>
|
||||||
|
<span v-if="!isCapturing">📷 ถ่ายภาพ</span>
|
||||||
|
<span v-else>กำลังถ่าย...</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="currentPhotoIndex > 0 && currentPhotoIndex < totalPhotos"
|
||||||
|
@click="retakePhoto"
|
||||||
|
class="retake-button"
|
||||||
|
>
|
||||||
|
ถ่ายใหม่ทั้งหมด
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="preview-section" v-if="photos.length > 0">
|
||||||
|
<h3>ภาพที่ถ่ายแล้ว</h3>
|
||||||
|
<div class="photo-preview">
|
||||||
|
<div
|
||||||
|
v-for="(photo, index) in photos"
|
||||||
|
:key="index"
|
||||||
|
class="preview-item"
|
||||||
|
>
|
||||||
|
<img :src="photo" :alt="`Photo ${index + 1}`" />
|
||||||
|
<span class="photo-number">{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shooting-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #000;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-counter {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-canvas {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-guide {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-frame {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
border: 3px solid rgba(255,255,255,0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-button {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-button:hover:not(:disabled) {
|
||||||
|
background: #ff5252;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capturing {
|
||||||
|
animation: pulse 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retake-button {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retake-button:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-number {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
color: white;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.camera-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
326
src/views/UploadView.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const uploadedPhotos = ref<string[]>([])
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const totalPhotos = 4
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const files = target.files
|
||||||
|
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
// แปลงไฟล์เป็น base64
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
if (uploadedPhotos.value.length >= totalPhotos) return
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string
|
||||||
|
if (result) {
|
||||||
|
uploadedPhotos.value.push(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
// เคลียร์ input
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePhoto = (index: number) => {
|
||||||
|
uploadedPhotos.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceedToNext = () => {
|
||||||
|
if (uploadedPhotos.value.length !== totalPhotos) {
|
||||||
|
alert(`กรุณาอัพโหลดภาพให้ครบ ${totalPhotos} รูป`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// เก็บรูปภาพไว้ใน localStorage
|
||||||
|
localStorage.setItem('photobooth-photos', JSON.stringify(uploadedPhotos.value))
|
||||||
|
router.push('/printstep')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/selectsource')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="upload-container">
|
||||||
|
<header class="header">
|
||||||
|
<button @click="goBack" class="back-button">← กลับ</button>
|
||||||
|
<h1>อัพโหลดภาพ</h1>
|
||||||
|
<span class="photo-counter">{{ uploadedPhotos.length }}/{{ totalPhotos }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="upload-section">
|
||||||
|
<div class="upload-area" @click="triggerFileInput">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<h3>คลิกเพื่อเลือกภาพ</h3>
|
||||||
|
<p>หรือลากและวางไฟล์ภาพลงที่นี่</p>
|
||||||
|
<p class="file-types">รองรับ: JPG, PNG, GIF</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="handleFileSelect"
|
||||||
|
class="hidden-input"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="preview-section" v-if="uploadedPhotos.length > 0">
|
||||||
|
<h3>ภาพที่เลือก</h3>
|
||||||
|
<div class="photo-grid">
|
||||||
|
<div
|
||||||
|
v-for="(photo, index) in uploadedPhotos"
|
||||||
|
:key="index"
|
||||||
|
class="photo-item"
|
||||||
|
>
|
||||||
|
<img :src="photo" :alt="`Uploaded photo ${index + 1}`" />
|
||||||
|
<button @click="removePhoto(index)" class="remove-button">✕</button>
|
||||||
|
<span class="photo-number">{{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Placeholder สำหรับรูปที่ยังไม่ได้เลือก -->
|
||||||
|
<div
|
||||||
|
v-for="i in totalPhotos - uploadedPhotos.length"
|
||||||
|
:key="`placeholder-${i}`"
|
||||||
|
class="photo-placeholder"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
>
|
||||||
|
<div class="placeholder-icon">+</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="action-section" v-if="uploadedPhotos.length === totalPhotos">
|
||||||
|
<button @click="proceedToNext" class="next-button">
|
||||||
|
ถัดไป
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-counter {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
background: white;
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #333;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-types {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item, .photo-placeholder {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(220, 53, 69, 0.9);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
background: rgba(220, 53, 69, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-number {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-placeholder {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px dashed #dee2e6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-placeholder:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-button {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(40,167,69,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-button:hover {
|
||||||
|
background: #218838;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(40,167,69,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.photo-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
42
vite.config.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
manifest: {
|
||||||
|
name: 'DekThai Photobooth',
|
||||||
|
short_name: 'Photobooth',
|
||||||
|
description: 'Create beautiful photo strips with frames',
|
||||||
|
theme_color: '#ffffff',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icon-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icon-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||