frontend/app/components/StickyNoteWindow.vue

422 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="sticky-note-window"
:style="{ top: `${windowData.y}px`, left: `${windowData.x}px`, width: `${windowData.width}px`, height: `${windowData.height}px`, zIndex: windowData.zIndex }"
@mousedown.stop="bringToFront"
>
<div class="main-content">
<div class="sidebar">
<div
v-for="(note, index) in notes"
:key="note.id"
class="note-tab"
:class="{ active: note.id === activeNoteId }"
:style="{ top: `${index * 50 + 30}px`, backgroundColor: note.color }"
@click="switchNote(note.id)"
>
<div class="tab-shape">
<!-- 全部改用 SVG -->
<svg v-if="note.shape === 'circle'" viewBox="0 0 24 24" width="24" height="24">
<path fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a3.5 3.5 0 0 0-2.5-3.36v6.72A3.5 3.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06c4.01-1 7-4.65 7-9.29s-2.99-8.29-7-9.25z"/>
</svg>
<svg v-else-if="note.shape === 'square'" viewBox="0 0 24 24" class="icon-shape">
<rect x="4" y="4" width="16" height="16" fill="currentColor" />
</svg>
<svg v-else-if="note.shape === 'triangle'" viewBox="0 0 24 24" class="icon-shape">
<polygon points="12,4 20,20 4,20" fill="currentColor" />
</svg>
<svg v-else-if="note.shape === 'heart'" viewBox="0 0 24 24" class="icon-shape">
<path
fill="currentColor"
d="M12 21s-6.7-4.1-9.2-7.1C-0.6 10.3 1.6 5 6.2 5c2 0 3.6 1 4.8 2.6C12.3 6 13.8 5 15.8 5c4.6 0 6.8 5.3 3.9 8.9C18.7 16.9 12 21 12 21z"
/>
</svg>
</div>
<div class="tab-name">{{ $t(note.nameKey) }}</div>
</div>
</div>
<div class="content">
<!-- Carousel for the first note -->
<div
v-if="activeNote && activeNote.type === 'carousel'"
class="carousel"
@mouseenter="pauseCarousel"
@mouseleave="resumeCarousel"
>
<div
class="carousel-inner"
:style="{ backgroundImage: `url(${activeNote.images[currentImageIndex].src})`, cursor: activeNote.images[currentImageIndex].link ? 'pointer' : 'default' }"
@click="openCarouselLink"
></div>
<button @click.stop="prevImage" class="carousel-nav prev">&#10094;</button>
<button @click.stop="nextImage" class="carousel-nav next">&#10095;</button>
<div class="carousel-dots">
<span
v-for="(_, index) in activeNote.images"
:key="index"
class="dot"
:class="{ active: currentImageIndex === index }"
@click.stop="goToImage(index)"
></span>
</div>
</div>
<!-- Card Carousel for the second note -->
<!-- 第二個 note: 翻轉卡片 -->
<div v-else-if="activeNote && activeNote.type === 'card-carousel'" class="card-carousel-container">
<div v-for="(card, index) in activeNote.cards" :key="index" class="flip-card">
<div class="flip-card-inner">
<!-- 正面:圖片 + 文字 -->
<div class="flip-card-front">
<img :src="card.image" alt="Card Image" class="card-image" />
<div class="overlay">
<div class="overlay-title">{{ card.title }}</div>
<div class="overlay-meta">{{ card.description }}</div>
</div>
</div>
<!-- 背面:只有文字 -->
<div class="flip-card-back">
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
<button class="more-btn" @click.stop="openCardLink(card.link)">更多</button>
</div>
</div>
</div>
</div>
<!-- Textarea for other notes -->
<textarea v-else-if="activeNote && activeNote.type === 'text'" v-model="activeNote.content" placeholder="..." readonly></textarea>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, defineProps, defineEmits, onUnmounted, computed } from 'vue';
const props = defineProps({
windowData: Object,
});
const emit = defineEmits(['minimize', 'bring-to-front']);
const defaultNotesStructure = [
{
id: '1',
nameKey: 'sticky_note_ideas',
type: 'carousel',
images: [
{ src: 'https://picsum.photos/seed/slide1/400/300', link: 'https://www.google.com' },
{ src: 'https://picsum.photos/seed/slide2/400/300', link: 'https://www.bing.com' },
{ src: 'https://picsum.photos/seed/slide3/400/300', link: null },
],
carouselInterval: 3000,
shape: 'circle',
color: '#ffadad'
},
{
id: '2',
nameKey: 'sticky_note_todo',
type: 'card-carousel',
cards: [
{ image: 'https://picsum.photos/seed/card1/200/150', title: 'Card 1', description: 'Description for card 1', link: 'https://www.example.com/card1' },
{ image: 'https://picsum.photos/seed/card2/200/150', title: 'Card 2', description: 'Description for card 2', link: 'https://www.example.com/card2' },
{ image: 'https://picsum.photos/seed/card3/200/150', title: 'Card 3', description: 'Description for card 3', link: 'https://www.example.com/card3' },
{ image: 'https://picsum.photos/seed/card4/200/150', title: 'Card 4', description: 'Description for card 4', link: 'https://www.example.com/card4' },
{ image: 'https://picsum.photos/seed/card5/200/150', title: 'Card 5', description: 'Description for card 5', link: 'https://www.example.com/card5' },
],
shape: 'heart',
color: '#ffd6a5'
},
{ id: '3', nameKey: 'sticky_note_misc', type: 'text', content: '', shape: 'square', color: '#caffbf' },
];
const notes = ref(JSON.parse(JSON.stringify(defaultNotesStructure)));
const activeNoteId = ref('1');
const currentImageIndex = ref(0);
let carouselTimer = null;
const activeNote = computed(() => notes.value.find(n => n.id === activeNoteId.value));
const switchNote = (noteId) => {
activeNoteId.value = noteId;
currentImageIndex.value = 0;
if (activeNote.value && activeNote.value.type === 'carousel') {
resumeCarousel();
} else {
pauseCarousel();
}
};
const nextImage = () => {
if (activeNote.value && activeNote.value.type === 'carousel') {
currentImageIndex.value = (currentImageIndex.value + 1) % activeNote.value.images.length;
}
};
const prevImage = () => {
if (activeNote.value && activeNote.value.type === 'carousel') {
currentImageIndex.value = (currentImageIndex.value - 1 + activeNote.value.images.length) % activeNote.value.images.length;
}
};
const goToImage = (index) => {
currentImageIndex.value = index;
resumeCarousel();
};
const openCarouselLink = () => {
if (activeNote.value && activeNote.value.type === 'carousel') {
const currentImage = activeNote.value.images[currentImageIndex.value];
if (currentImage && currentImage.link) {
window.open(currentImage.link, '_blank');
}
}
};
const openCardLink = (link) => { if (link) window.open(link, '_blank'); };
const pauseCarousel = () => {
if (carouselTimer) {
clearInterval(carouselTimer);
carouselTimer = null;
}
};
const resumeCarousel = () => {
pauseCarousel();
if (activeNote.value && activeNote.value.type === 'carousel' && activeNote.value.carouselInterval) {
carouselTimer = setInterval(nextImage, activeNote.value.carouselInterval);
}
};
const bringToFront = () => emit('bring-to-front');
onMounted(() => {
const savedContent = JSON.parse(localStorage.getItem('stickyNotesContent') || '{}');
notes.value = defaultNotesStructure.map(note => {
if (note.type === 'text' && savedContent[note.id]) {
return { ...note, content: savedContent[note.id] };
}
return note;
});
activeNoteId.value = '1';
if (activeNote.value && activeNote.value.type === 'carousel') {
resumeCarousel();
}
});
watch(activeNoteId, () => {
if (activeNote.value && activeNote.value.type === 'carousel') resumeCarousel();
else pauseCarousel();
});
onUnmounted(() => { pauseCarousel(); });
</script>
<style scoped>
.sticky-note-window {
position: absolute;
background-color: #ffffff;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
border-radius: 12px;
display: flex;
min-width: 750px;
min-height: 350px;
overflow: visible;
}
.main-content { display: flex; flex-grow: 1; width: 100%; }
.sidebar { position: relative; width: 40px; flex-shrink: 0; z-index: 1; }
.note-tab {
position: absolute;
left: -30px;
width: 120px;
height: 40px;
border-radius: 8px 0 0 8px;
display: flex;
align-items: center;
padding: 0 10px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: -2px 2px 5px rgba(0,0,0,0.05);
}
.note-tab:not(.active):hover { left: -25px; }
.note-tab.active { left: -20px; width: 130px; font-weight: bold; z-index: 2; }
.tab-shape { width: 16px; height: 16px; margin-right: 8px; flex-shrink: 0; }
.tab-shape.circle { border-radius: 50%; background-color: currentColor; }
.tab-shape.square { background-color: currentColor; }
.tab-shape.triangle {
width: 0; height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 16px solid currentColor;
}
.icon-heart { width: 16px; height: 16px; display: inline-block; }
.tab-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Gaegu', cursive;
font-size: 18px;
}
.content {
flex-grow: 1;
padding: 20px;
z-index: 1;
overflow: hidden;
}
.content textarea {
width: 100%; height: 100%; padding: 15px;
box-sizing: border-box;
border: none;
border-radius: 8px;
font-family: 'Gaegu', cursive;
font-size: 18px;
line-height: 1.5;
color: #333;
resize: none;
outline: none;
background-color: #FFFFE0;
pointer-events: none;
}
/* Carousel */
.carousel {
position: relative; width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center;
overflow: hidden; border-radius: 8px;
background-color: #f0f0f0;
}
.carousel-inner {
width: 100%; height: 100%;
background-size: cover; background-position: center;
transition: background-image 0.5s ease-in-out;
}
.carousel-nav {
position: absolute; top: 50%; transform: translateY(-50%);
background-color: rgba(0, 0, 0, 0.3); color: white;
border: none; border-radius: 50%;
width: 40px; height: 40px; font-size: 20px;
cursor: pointer; z-index: 2;
}
.carousel-nav.prev { left: 10px; }
.carousel-nav.next { right: 10px; }
.carousel-dots {
position: absolute; bottom: 10px; left: 50%;
transform: translateX(-50%);
display: flex; z-index: 2;
}
.dot {
width: 10px; height: 10px; border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
margin: 0 5px; cursor: pointer;
}
.dot.active { background-color: white; }
/* Card Carousel */
/* --- 只影響第二個 note卡片容器 (保持原尺寸 + 可水平滑動) --- */
.card-carousel-container {
display: flex;
gap: 20px;
padding: 20px;
height: 100%;
align-items: center;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 8px;
overflow-x: auto; /* 可左右移動 */
overflow-y: hidden; /* 避免翻轉時出現縱向捲軸造成抖動 */
-webkit-overflow-scrolling: touch;
}
/* 固定卡片佔位:與你原本相同寬高 */
.flip-card {
flex: 0 0 200px; /* 固定寬度,避免翻面時擠壓隊列 */
width: 200px;
height: 300px;
perspective: 1000px;
transform: translateZ(0); /* 防抖 */
contain: layout paint size; /* 隔離翻轉帶來的重排 */
}
/* 內層:只做 3D 翻轉,尺寸完全跟外層一致 */
.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.6s;
transform-origin: center center; /* 原地翻 */
will-change: transform;
}
/* 滑鼠上去才翻面(原地) */
.flip-card:hover .flip-card-inner {
transform: rotateY(180deg);
}
/* 兩面都「完全覆蓋」容器,確保尺寸 1:1 */
.flip-card-front,
.flip-card-back {
position: absolute;
inset: 0; /* 等同 top/left/right/bottom:0 */
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 8px;
overflow: hidden;
box-sizing: border-box; /* 讓 padding 不改變盒子大小 */
}
/* 正面:滿版圖片 + 底部漸層文字,尺寸不動 */
.flip-card-front img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.flip-card-front .overlay {
position: absolute;
left: 0; right: 0; bottom: 0;
padding: 10px;
background: linear-gradient(to top, rgba(0,0,0,0.6), transparent);
color: #fff;
font-size: 14px;
}
.overlay-title { font-size: 16px; font-weight: bold; }
.overlay-meta { font-size: 12px; margin-top: 4px; }
/* 背面:只有文字,圖片完全不見;仍與正面 1:1 疊合 */
.flip-card-back {
transform: rotateY(180deg);
background: linear-gradient(135deg, #f78ca0, #f9748f, #fd868c, #fe9a8b);
color: #fff;
/* 不用 margin改用內距避免看起來「變大」 */
padding: 15px;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
/* 取消預設外距,避免翻面時高度視覺變化 */
.flip-card-back h3,
.flip-card-back p {
margin: 0 0 12px 0;
}
.flip-card-back .more-btn {
align-self: center;
background: #fff;
color: #ff4d6d;
font-weight: bold;
padding: 6px 12px;
border-radius: 16px;
border: none;
cursor: pointer;
font-size: 14px;
}
</style>