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:
85
src/App.vue
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
|
||||
Reference in New Issue
Block a user