Compare commits

...

2 Commits

18 changed files with 261 additions and 187 deletions

BIN
public/Logo-200x60.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,4 @@
<svg width="420" height="50" xmlns="http://www.w3.org/2000/svg">
<rect width="420" height="50" fill="#dc3545"/>
<text x="210" y="30" text-anchor="middle" fill="white" font-family="Arial" font-size="16" font-weight="bold">Footer 1x4</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,4 @@
<svg width="780" height="60" xmlns="http://www.w3.org/2000/svg">
<rect width="780" height="60" fill="#ffc107"/>
<text x="390" y="35" text-anchor="middle" fill="black" font-family="Arial" font-size="18" font-weight="bold">Footer 2x2</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,4 @@
<svg width="420" height="50" xmlns="http://www.w3.org/2000/svg">
<rect width="420" height="50" fill="#007bff"/>
<text x="210" y="30" text-anchor="middle" fill="white" font-family="Arial" font-size="16" font-weight="bold">Header 1x4</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,4 @@
<svg width="780" height="60" xmlns="http://www.w3.org/2000/svg">
<rect width="780" height="60" fill="#28a745"/>
<text x="390" y="35" text-anchor="middle" fill="white" font-family="Arial" font-size="18" font-weight="bold">Header 2x2</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -4,7 +4,7 @@ import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<header>
<!-- <header >
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
@@ -15,8 +15,9 @@ import HelloWorld from './components/HelloWorld.vue'
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
</header> -->
<RouterLink to="/">Home</RouterLink>
<RouterView />
</template>

BIN
src/assets/footer-1x4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
src/assets/footer-2x2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
src/assets/header-1x4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src/assets/header-2x2.png Normal file

Binary file not shown.

View File

@@ -69,7 +69,7 @@ const goBack = () => {
</div>
</section>
<section class="frame-section">
<section class="frame-section" style="display: none;">
<h2>เลอกกรอบ</h2>
<div class="frame-options">
<div

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, nextTick } 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('')
const cachedImageUrl = ref('')
const isGeneratingImage = ref(false)
const hasError = ref(false)
onMounted(() => {
// โหลดข้อมูลจาก localStorage
@@ -27,8 +28,8 @@ onMounted(() => {
frame.value = parseInt(savedFrame)
}
// สร้าง URL สำหรับแชร์ (ในโปรเจกต์จริงควรอัพโหลดขึ้น cloud)
generateShareUrl()
// สร้าง PNG และบันทึกใน localStorage (ถ้ายังไม่มี)
generateAndCacheImage()
})
const getGridStyle = computed(() => {
@@ -38,7 +39,7 @@ const getGridStyle = computed(() => {
gridTemplateColumns: '1fr',
gridTemplateRows: 'repeat(4, 1fr)',
gap: '8px',
aspectRatio: '1/2' // ทำให้แต่ละภาพยาวขึ้น (1:2 aspect ratio รวม)
aspectRatio: '1/1' // ทำให้แต่ละภาพยาวขึ้น (1:2 aspect ratio รวม)
}
} else {
return {
@@ -51,104 +52,158 @@ const getGridStyle = computed(() => {
}
})
const generateShareUrl = () => {
// ในโปรเจกต์จริง ควรอัพโหลดรูปขึ้น cloud และสร้าง URL
// ตัวอย่างนี้ใช้ data URL ชั่วคราว
const photoData = {
photos: photos.value,
layout: layout.value,
frame: frame.value,
timestamp: Date.now()
}
const generateAndCacheImage = async () => {
try {
hasError.value = false
if (photos.value.length === 0) return
// เข้ารหัสข้อมูลเป็น base64 และสร้าง URL
const encodedData = btoa(JSON.stringify(photoData))
shareUrl.value = `${window.location.origin}/share/${encodedData}`
isGeneratingImage.value = true
// สร้าง QR code URL (ใช้ service ภายนอก)
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(shareUrl.value)}`
}
// สร้าง key สำหรับ localStorage
const storageKey = `photobooth-${layout.value}-${frame.value}-${photos.value.length}`
const downloadImage = async () => {
if (photos.value.length === 0) return
// ตรวจสอบว่ามี PNG ที่สร้างไว้แล้วหรือไม่
const cachedImage = localStorage.getItem(storageKey)
if (cachedImage) {
cachedImageUrl.value = cachedImage
await nextTick()
return // มีแล้ว ไม่ต้องสร้างใหม่
}
// สร้าง canvas สำหรับรวมรูป
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
// สร้าง canvas สำหรับรวมรูป
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) return
if (!context) {
throw new Error('Could not get canvas context')
}
// ขนาดรูปแต่ละภาพ (720x960 = 3:4)
const photoWidth = 720
const photoHeight = 960
// ขนาดรูปแต่ละภาพ (360x480 = 3:4) - ลดขนาดลงเพื่อป้องกันปัญหา canvas ใหญ่เกินไป
const photoWidth = 360
const photoHeight = 480
const gap = 20 // ระยะห่างระหว่างรูป
let canvasWidth, canvasHeight
let headerHeight = 0, footerHeight = 0
let sidePadding = 0
if (layout.value === '1x4') {
// 1x4: 1 คอลัมน์ 4 แถว
canvasWidth = photoWidth
canvasHeight = (photoHeight * 4) + (gap * 3)
// 1x4: 1 คอลัมน์ 4 แถว + header/footer
headerHeight = 50
footerHeight = 50
sidePadding = 30 // padding ซ้ายขวาเพื่อให้ total width = 420
canvasWidth = 420
canvasHeight = headerHeight + (photoHeight * 4) + (gap * 3) + footerHeight
} else {
// 2x2: 2 คอลัมน์ 2 แถว
canvasWidth = (photoWidth * 2) + gap
canvasHeight = (photoHeight * 2) + gap
// 2x2: 2 คอลัมน์ 2 แถว + header/footer
headerHeight = 60
footerHeight = 60
sidePadding = 20 // padding ซ้ายขวาเพื่อให้ total width = 780
canvasWidth = 780
canvasHeight = headerHeight + (photoHeight * 2) + gap + footerHeight
}
console.log('Canvas dimensions:', canvasWidth, 'x', canvasHeight)
canvas.width = canvasWidth
canvas.height = canvasHeight
// วาด background ตาม frame
drawFrameBackground(context, canvasWidth, canvasHeight, frame.value)
// วาดรูปภาพ
// วาด header image
const headerImagePath = layout.value === '1x4' ? '/assets/header-1x4.png' : '/assets/header-2x2.png'
await drawHeaderFooterImage(context, headerImagePath, 0, 0, canvasWidth, headerHeight)
// วาด footer image
const footerImagePath = layout.value === '1x4' ? '/assets/footer-1x4.png' : '/assets/footer-2x2.png'
await drawHeaderFooterImage(context, footerImagePath, 0, canvasHeight - footerHeight, canvasWidth, footerHeight)
// วาดรูปภาพ (มี padding ซ้ายขวา)
for (let i = 0; i < photos.value.length; i++) {
const photoSrc = photos.value[i]
if (!photoSrc) continue
const img = new Image()
img.src = photos.value[i]
img.src = photoSrc
await new Promise((resolve) => {
img.onload = () => {
let x, y
if (layout.value === '1x4') {
// 1x4 layout
x = 0
y = i * (photoHeight + gap)
// 1x4 layout - วาดรูปตรงกลาง (มี padding ซ้ายขวา)
x = sidePadding
y = headerHeight + i * (photoHeight + gap)
} else {
// 2x2 layout
// 2x2 layout - วาดรูปตรงกลาง (มี padding ซ้ายขวา)
const col = i % 2
const row = Math.floor(i / 2)
x = col * (photoWidth + gap)
y = row * (photoHeight + gap)
x = sidePadding + col * (photoWidth + gap)
y = headerHeight + row * (photoHeight + gap)
}
// วาดรูปภาพ
context.drawImage(img, x, y, photoWidth, photoHeight)
resolve(void 0)
}
img.onerror = () => {
console.error(`Failed to load image ${i}`)
resolve(void 0)
}
})
}
// สร้าง PNG และ download
canvas.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `photobooth-${Date.now()}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
// สร้าง PNG data URL และบันทึกใน localStorage
const dataUrl = canvas.toDataURL('image/png')
try {
localStorage.setItem(storageKey, dataUrl)
cachedImageUrl.value = dataUrl
// ให้ Vue reactive update ก่อน
await nextTick()
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
console.error('localStorage quota exceeded for cached image')
alert('ไม่สามารถบันทึกภาพได้เนื่องจากพื้นที่เก็บข้อมูลไม่เพียงพอ')
} else {
console.error('Error saving cached image:', error)
throw error
}
}, 'image/png')
}
} catch (error) {
console.error('Error in generateAndCacheImage:', error)
hasError.value = true
} finally {
isGeneratingImage.value = false
}
}
const downloadImage = async () => {
if (photos.value.length === 0) return
// ตรวจสอบว่า cachedImageUrl มีหรือไม่ ถ้าไม่มีให้สร้างก่อน
if (!cachedImageUrl.value) {
await generateAndCacheImage()
}
// Download จาก cachedImageUrl
downloadFromDataUrl(cachedImageUrl.value, `photobooth-${Date.now()}.png`)
}
const downloadFromDataUrl = (dataUrl: string, filename: string) => {
const a = document.createElement('a')
a.href = dataUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const drawFrameBackground = (context: CanvasRenderingContext2D, width: number, height: number, frameType: number) => {
// วาด background ตาม frame type
switch (frameType) {
case 0: // Classic - white
context.fillStyle = '#ffffff'
context.fillStyle = '#000000'
break
case 1: // Modern - gradient
const gradient1 = context.createLinearGradient(0, 0, width, height)
@@ -172,32 +227,35 @@ const drawFrameBackground = (context: CanvasRenderingContext2D, width: number, h
context.fillStyle = '#f8f9fa'
break
default:
context.fillStyle = '#ffffff'
context.fillStyle = '#000000'
}
context.fillRect(0, 0, width, height)
}
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()
const drawHeaderFooterImage = async (context: CanvasRenderingContext2D, imagePath: string, x: number, y: number, width: number, height: number) => {
return new Promise<void>((resolve) => {
const img = new Image()
img.onload = () => {
context.drawImage(img, x, y, width, height)
resolve()
}
} else {
copyToClipboard()
}
}
const copyToClipboard = () => {
navigator.clipboard.writeText(shareUrl.value).then(() => {
alert('คัดลอกลิงก์แล้ว! สามารถแชร์ให้เพื่อนได้')
img.onerror = () => {
console.warn(`Failed to load header/footer image: ${imagePath}, drawing placeholder`)
// วาด placeholder color แทน
if (imagePath.includes('header-1x4')) {
context.fillStyle = '#007bff' // น้ำเงินสำหรับ 1x4 header
} else if (imagePath.includes('footer-1x4')) {
context.fillStyle = '#dc3545' // แดงสำหรับ 1x4 footer
} else if (imagePath.includes('header-2x2')) {
context.fillStyle = '#28a745' // เขียวสำหรับ 2x2 header
} else if (imagePath.includes('footer-2x2')) {
context.fillStyle = '#ffc107' // เหลืองสำหรับ 2x2 footer
}
context.fillRect(x, y, width, height)
resolve()
}
img.src = imagePath
})
}
@@ -220,14 +278,14 @@ const startOver = () => {
<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 v-if="isGeneratingImage && !cachedImageUrl" class="loading-indicator">
<div class="spinner"></div>
<p>กำลงสรางภาพ...</p>
</div>
<img v-else-if="cachedImageUrl" :src="cachedImageUrl" alt="Combined Photo" class="combined-image" />
<div v-else-if="hasError" class="error-message">
<p>ไมสามารถสรางภาพได</p>
</div>
</div>
</div>
@@ -239,22 +297,6 @@ const startOver = () => {
<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>
@@ -328,6 +370,46 @@ const startOver = () => {
display: block;
}
.combined-image {
width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #dc3545;
text-align: center;
}
.actions-section {
margin-bottom: 2rem;
}
@@ -338,7 +420,7 @@ const startOver = () => {
justify-content: center;
}
.download-button, .share-button {
.download-button {
background: #007bff;
color: white;
border: none;
@@ -354,77 +436,16 @@ const startOver = () => {
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;
}
@@ -449,10 +470,6 @@ const startOver = () => {
}
/* Frame styles */
.frame-0 {
/* Classic frame - default white */
}
.frame-1 {
/* Modern frame */
background: linear-gradient(45deg, #667eea, #764ba2);

View File

@@ -81,9 +81,9 @@ const captureSinglePhoto = () => {
cropY = (videoHeight - cropHeight) / 2
}
// ตั้งค่าขนาด canvas เป็น 3:4 (720x960)
const finalWidth = 720
const finalHeight = 960
// ตั้งค่าขนาด canvas เป็น 3:4 (360x480) - ลดขนาดเพื่อป้องกัน localStorage quota exceeded
const finalWidth = 360
const finalHeight = 480
canvas.width = finalWidth
canvas.height = finalHeight
@@ -95,8 +95,8 @@ const captureSinglePhoto = () => {
0, 0, finalWidth, finalHeight // ตำแหน่งและขนาดใน canvas
)
// แปลงเป็น base64
const photoDataUrl = canvas.toDataURL('image/jpeg', 0.9)
// แปลงเป็น base64 ด้วย quality 0.7 เพื่อลดขนาด
const photoDataUrl = canvas.toDataURL('image/jpeg', 0.7)
photos.value.push(photoDataUrl)
currentPhotoIndex.value++
@@ -145,10 +145,30 @@ const startAutoCapture = () => {
}
const proceedToNext = () => {
// เก็บรูปภาพไว้ใน localStorage
localStorage.setItem('photobooth-photos', JSON.stringify(photos.value))
stopCamera()
router.push('/printstep')
try {
// เคลียร์ cached images ที่เก่าออกก่อนเพื่อให้มีพื้นที่
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && key.startsWith('photobooth-1x4-') || key.startsWith('photobooth-2x2-')) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
// เก็บรูปภาพไว้ใน localStorage
localStorage.setItem('photobooth-photos', JSON.stringify(photos.value))
stopCamera()
router.push('/printstep')
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
alert('รูปภาพมีขนาดใหญ่เกินไป กรุณาลองใหม่อีกครั้งหรือใช้รูปภาพขนาดเล็กลง')
console.error('localStorage quota exceeded:', error)
} else {
console.error('Error saving photos:', error)
alert('เกิดข้อผิดพลาดในการบันทึกภาพ')
}
}
}
const goBack = () => {

View File

@@ -41,9 +41,9 @@ const cropImageTo34 = (imageSrc: string): Promise<string> => {
cropY = (imgHeight - cropHeight) / 2
}
// ตั้งค่าขนาด canvas เป็น 3:4 (720x960)
const finalWidth = 720
const finalHeight = 960
// ตั้งค่าขนาด canvas เป็น 3:4 (360x480) - ลดขนาดเพื่อป้องกัน localStorage quota exceeded
const finalWidth = 360
const finalHeight = 480
canvas.width = finalWidth
canvas.height = finalHeight
@@ -55,8 +55,8 @@ const cropImageTo34 = (imageSrc: string): Promise<string> => {
0, 0, finalWidth, finalHeight // ตำแหน่งและขนาดใน canvas
)
// แปลงเป็น base64
const croppedDataUrl = canvas.toDataURL('image/jpeg', 0.9)
// แปลงเป็น base64 ด้วย quality 0.7 เพื่อลดขนาด
const croppedDataUrl = canvas.toDataURL('image/jpeg', 0.7)
resolve(croppedDataUrl)
}
img.src = imageSrc
@@ -103,9 +103,29 @@ const proceedToNext = () => {
return
}
// เก็บรูปภาพไว้ใน localStorage
localStorage.setItem('photobooth-photos', JSON.stringify(uploadedPhotos.value))
router.push('/printstep')
try {
// เคลียร์ cached images ที่เก่าออกก่อนเพื่อให้มีพื้นที่
const keysToRemove = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && (key.startsWith('photobooth-1x4-') || key.startsWith('photobooth-2x2-'))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
// เก็บรูปภาพไว้ใน localStorage
localStorage.setItem('photobooth-photos', JSON.stringify(uploadedPhotos.value))
router.push('/printstep')
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
alert('รูปภาพมีขนาดใหญ่เกินไป กรุณาเลือกภาพขนาดเล็กลงหรือจำนวนน้อยลง')
console.error('localStorage quota exceeded:', error)
} else {
console.error('Error saving photos:', error)
alert('เกิดข้อผิดพลาดในการบันทึกภาพ')
}
}
}
const goBack = () => {