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.
This commit is contained in:
mrkad@rpi
2026-01-17 11:41:46 +07:00
commit e90c06230b
44 changed files with 10189 additions and 0 deletions

85
src/App.vue Normal file
View 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
View 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
View 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
View 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;
}
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View 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>
Vues
<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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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>