539 lines
19 KiB
TypeScript
539 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState } from "react";
|
||
import { JournalPost } from "./types";
|
||
import {
|
||
Heart,
|
||
MessageSquare,
|
||
Tag,
|
||
Play,
|
||
Pause,
|
||
Maximize2,
|
||
Terminal,
|
||
Volume2,
|
||
CornerDownRight,
|
||
Check,
|
||
Sparkles
|
||
} from "lucide-react";
|
||
|
||
interface JournalCardProps {
|
||
post: JournalPost;
|
||
onOpenDetail: (post: JournalPost) => void;
|
||
onTagClick: (tag: string) => void;
|
||
}
|
||
|
||
export const JournalCard: React.FC<JournalCardProps> = ({
|
||
post,
|
||
onOpenDetail,
|
||
onTagClick,
|
||
}) => {
|
||
const [likes, setLikes] = useState(post.likes);
|
||
const [hasLiked, setHasLiked] = useState(false);
|
||
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
||
const [stampFeedback, setStampFeedback] = useState(false);
|
||
|
||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||
const audioContextRef = React.useRef<AudioContext | null>(null);
|
||
const synthTimerRef = React.useRef<any>(null);
|
||
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause();
|
||
}
|
||
if (synthTimerRef.current) {
|
||
clearInterval(synthTimerRef.current);
|
||
}
|
||
if (audioContextRef.current) {
|
||
audioContextRef.current.close().catch(() => {});
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const handleLike = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!hasLiked) {
|
||
setLikes((prev) => prev + 1);
|
||
setHasLiked(true);
|
||
setStampFeedback(true);
|
||
setTimeout(() => setStampFeedback(false), 1200);
|
||
} else {
|
||
setLikes((prev) => prev - 1);
|
||
setHasLiked(false);
|
||
}
|
||
};
|
||
|
||
const toggleAudio = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (isPlayingAudio) {
|
||
stopAudio();
|
||
} else {
|
||
startAudio();
|
||
}
|
||
};
|
||
|
||
const stopAudio = () => {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause();
|
||
}
|
||
if (synthTimerRef.current) {
|
||
clearInterval(synthTimerRef.current);
|
||
synthTimerRef.current = null;
|
||
}
|
||
if (audioContextRef.current) {
|
||
audioContextRef.current.close().catch(() => {});
|
||
audioContextRef.current = null;
|
||
}
|
||
setIsPlayingAudio(false);
|
||
};
|
||
|
||
const startAudio = () => {
|
||
if (post.audioUrl) {
|
||
if (!audioRef.current) {
|
||
audioRef.current = new Audio(post.audioUrl);
|
||
audioRef.current.onended = () => setIsPlayingAudio(false);
|
||
audioRef.current.onpause = () => setIsPlayingAudio(false);
|
||
}
|
||
audioRef.current.play()
|
||
.then(() => setIsPlayingAudio(true))
|
||
.catch(() => playSynthFallback());
|
||
} else {
|
||
playSynthFallback();
|
||
}
|
||
};
|
||
|
||
const playSynthFallback = () => {
|
||
try {
|
||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
||
if (!AudioCtx) {
|
||
setIsPlayingAudio(true);
|
||
return;
|
||
}
|
||
const ctx = new AudioCtx();
|
||
audioContextRef.current = ctx;
|
||
setIsPlayingAudio(true);
|
||
|
||
const notes = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25];
|
||
let noteIdx = 0;
|
||
|
||
const playNextNote = () => {
|
||
if (!audioContextRef.current || ctx.state === "closed") return;
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.type = "sine";
|
||
osc.frequency.setValueAtTime(notes[noteIdx % notes.length], ctx.currentTime);
|
||
gain.gain.setValueAtTime(0.06, ctx.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5);
|
||
osc.connect(gain);
|
||
gain.connect(ctx.destination);
|
||
osc.start();
|
||
osc.stop(ctx.currentTime + 0.5);
|
||
noteIdx++;
|
||
};
|
||
|
||
playNextNote();
|
||
synthTimerRef.current = setInterval(playNextNote, 450);
|
||
} catch (e) {
|
||
setIsPlayingAudio(true);
|
||
}
|
||
};
|
||
|
||
// Render format based on type
|
||
if (post.type === "sticky") {
|
||
return (
|
||
<div
|
||
onClick={() => onOpenDetail(post)}
|
||
className="group relative bg-[#FEF3C7] border border-[#D97706]/20 p-5 sm:p-6 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 transform hover:-translate-y-1 rotate-[-1deg] cursor-pointer flex flex-col justify-between"
|
||
>
|
||
{/* Washi Tape across top */}
|
||
<div className="washi-tape washi-tape-amber top-[-10px] left-1/2 -translate-x-1/2 w-28" />
|
||
|
||
<div>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between text-xs font-mono text-[#D97706] mb-3 pt-2">
|
||
<span className="font-bold">{post.date} • {post.timestamp}</span>
|
||
{post.stampedText && (
|
||
<span className="rubber-stamp text-[9px] text-[#B45309] border-[#B45309] rotate-[3deg]">
|
||
{post.stampedText}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<p className="font-serif text-[#18181B] text-base sm:text-lg leading-snug font-medium mb-4">
|
||
“{post.content}”
|
||
</p>
|
||
|
||
{/* Margin Notes */}
|
||
{post.marginNotes && post.marginNotes.length > 0 && (
|
||
<div className="my-2 p-2 rounded bg-[#FDE68A]/40 border border-[#D97706]/20">
|
||
{post.marginNotes.map((note, idx) => (
|
||
<div key={idx} className="font-handwritten text-[#92400E] text-base leading-tight">
|
||
{note}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between pt-3 border-t border-[#D97706]/15 mt-3">
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
{post.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onTagClick(tag);
|
||
}}
|
||
className="text-[11px] font-mono text-[#92400E] hover:underline"
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleLike}
|
||
className={`flex items-center gap-1 text-xs font-mono px-2 py-1 rounded transition-colors ${
|
||
hasLiked ? "text-[#E11D48] font-bold" : "text-[#B45309] hover:text-[#18181B]"
|
||
}`}
|
||
>
|
||
<Heart className={`w-3.5 h-3.5 ${hasLiked ? "fill-current" : ""}`} />
|
||
<span>{likes}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (post.type === "polaroid") {
|
||
return (
|
||
<div
|
||
onClick={() => onOpenDetail(post)}
|
||
className="group relative bg-[#FAF7F2] border border-[#18181B]/12 p-3 sm:p-4 rounded-sm shadow-sm hover:shadow-lg transition-all duration-300 transform hover:rotate-[0.5deg] rotate-[1deg] cursor-pointer"
|
||
>
|
||
{/* Washi Tape */}
|
||
<div className="washi-tape top-[-10px] left-1/2 -translate-x-1/2 w-32" />
|
||
|
||
{/* Image Container */}
|
||
<div className="w-full aspect-[4/3] rounded-xs overflow-hidden bg-[#18181B]/5 relative mb-3">
|
||
{post.imageUrl && (
|
||
<img
|
||
src={post.imageUrl}
|
||
alt={post.imageCaption || "Polaroid memory"}
|
||
className="w-full h-full object-cover group-hover:scale-102 transition-transform duration-500"
|
||
/>
|
||
)}
|
||
<div className="absolute bottom-2 right-2 px-2 py-0.5 rounded bg-black/60 backdrop-blur-xs text-white text-[10px] font-mono">
|
||
{post.date}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Polaroid Handwritten Caption */}
|
||
<div className="px-1 pb-1">
|
||
<div className="font-handwritten text-[#18181B] text-xl leading-snug font-bold">
|
||
{post.imageCaption || post.content}
|
||
</div>
|
||
<p className="text-xs font-serif text-[#52525B] mt-1.5 line-clamp-2">
|
||
{post.content}
|
||
</p>
|
||
|
||
{/* Margin Note */}
|
||
{post.marginNotes && post.marginNotes.length > 0 && (
|
||
<div className="font-handwritten text-[#0284C7] text-base mt-2 flex items-center gap-1">
|
||
<CornerDownRight className="w-3.5 h-3.5 shrink-0" />
|
||
<span>{post.marginNotes[0]}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between pt-2.5 border-t border-[#18181B]/8 mt-3">
|
||
<div className="flex items-center gap-1.5">
|
||
{post.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onTagClick(tag);
|
||
}}
|
||
className="text-[10px] font-mono text-[#787D89] hover:text-[#18181B]"
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleLike}
|
||
className={`flex items-center gap-1 text-xs font-mono transition-colors ${
|
||
hasLiked ? "text-[#E11D48] font-bold" : "text-[#787D89] hover:text-[#18181B]"
|
||
}`}
|
||
>
|
||
<Heart className={`w-3.5 h-3.5 ${hasLiked ? "fill-current" : ""}`} />
|
||
<span>{likes}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (post.type === "code") {
|
||
return (
|
||
<div
|
||
onClick={() => onOpenDetail(post)}
|
||
className="group relative bg-[#1E232D] text-[#FAF7F2] p-5 sm:p-6 rounded-xl shadow-md hover:shadow-xl transition-all duration-300 border border-white/10 cursor-pointer"
|
||
>
|
||
{/* Terminal Header */}
|
||
<div className="flex items-center justify-between pb-3 border-b border-white/10 mb-3">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<div className="w-2.5 h-2.5 rounded-full bg-[#EF4444]" />
|
||
<div className="w-2.5 h-2.5 rounded-full bg-[#F59E0B]" />
|
||
<div className="w-2.5 h-2.5 rounded-full bg-[#10B981]" />
|
||
</div>
|
||
<span className="text-xs font-mono text-white/60 ml-2">
|
||
{post.title || "terminal_scrap.rs"}
|
||
</span>
|
||
</div>
|
||
|
||
<span className="text-[10px] font-mono uppercase px-2 py-0.5 rounded bg-white/10 text-white/80">
|
||
{post.codeLanguage || "code"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Snippet */}
|
||
{post.codeSnippet && (
|
||
<pre className="p-3.5 rounded-lg bg-black/40 text-emerald-400 font-mono text-xs overflow-x-auto leading-relaxed border border-white/5 my-2">
|
||
<code>{post.codeSnippet}</code>
|
||
</pre>
|
||
)}
|
||
|
||
{/* Narrative */}
|
||
<p className="text-xs sm:text-sm font-serif text-white/80 mt-3 leading-relaxed">
|
||
{post.content}
|
||
</p>
|
||
|
||
{/* Handwritten Margin Note on Dark Card */}
|
||
{post.marginNotes && post.marginNotes.length > 0 && (
|
||
<div className="font-handwritten text-[#FDE047] text-base mt-2 flex items-center gap-1.5">
|
||
<span>>></span>
|
||
<span>{post.marginNotes.join(" • ")}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between pt-3 border-t border-white/10 mt-4">
|
||
<div className="flex items-center gap-2">
|
||
{post.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onTagClick(tag);
|
||
}}
|
||
className="text-[10px] font-mono text-white/60 hover:text-white"
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleLike}
|
||
className={`flex items-center gap-1 text-xs font-mono ${
|
||
hasLiked ? "text-[#E11D48] font-bold" : "text-white/60 hover:text-white"
|
||
}`}
|
||
>
|
||
<Heart className={`w-3.5 h-3.5 ${hasLiked ? "fill-current" : ""}`} />
|
||
<span>{likes}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (post.type === "audio") {
|
||
return (
|
||
<div
|
||
onClick={() => onOpenDetail(post)}
|
||
className="group relative bg-[#F3ECE2] border border-[#18181B]/12 p-5 sm:p-6 rounded-xl shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer"
|
||
>
|
||
{/* Brass clip top right */}
|
||
<div className="binder-clip absolute top-[-6px] right-6 w-8 h-4 rounded-xs" />
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between text-xs font-mono text-[#52525B] mb-2">
|
||
<span className="font-bold flex items-center gap-1.5">
|
||
<Volume2 className="w-3.5 h-3.5 text-[#0284C7]" />
|
||
SES KAYDI • {post.date}
|
||
</span>
|
||
<span className="text-[11px] font-mono">{post.audioDuration || "01:00"}</span>
|
||
</div>
|
||
|
||
{/* Cassette Tape Mockup */}
|
||
<div className="my-3 p-3.5 rounded-lg bg-[#18181B] text-[#FAF7F2] flex items-center gap-3">
|
||
<button
|
||
onClick={toggleAudio}
|
||
title={isPlayingAudio ? "Durdur" : "Dinle"}
|
||
aria-label={isPlayingAudio ? "Durdur" : "Dinle"}
|
||
className="w-10 h-10 rounded-full bg-[#0284C7] hover:bg-[#0369A1] text-white flex items-center justify-center transition-transform active:scale-95 shrink-0"
|
||
>
|
||
{isPlayingAudio ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
|
||
</button>
|
||
|
||
<div className="flex-1 overflow-hidden">
|
||
<div className="text-xs font-mono font-bold truncate text-white">
|
||
{post.audioTitle || "voice_note.m4a"}
|
||
</div>
|
||
{/* Animated Audio Bars */}
|
||
<div className="flex items-end gap-1 h-5 mt-1.5">
|
||
{[40, 75, 30, 90, 60, 85, 45, 95, 35, 70, 50, 80, 65, 40].map((h, i) => (
|
||
<div
|
||
key={i}
|
||
className={`w-1 rounded-full transition-all duration-150 ${
|
||
isPlayingAudio ? "bg-[#38BDF8] animate-pulse" : "bg-white/30"
|
||
}`}
|
||
style={{
|
||
height: isPlayingAudio ? `${Math.max(20, (h * (i % 2 === 0 ? 1.2 : 0.8)) % 100)}%` : `${h * 0.4}%`,
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-xs sm:text-sm font-serif text-[#18181B] mt-2 leading-relaxed">
|
||
{post.content}
|
||
</p>
|
||
|
||
{post.marginNotes && post.marginNotes.length > 0 && (
|
||
<div className="font-handwritten text-[#0284C7] text-base mt-2">
|
||
* {post.marginNotes[0]}
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between pt-3 border-t border-[#18181B]/8 mt-3">
|
||
<div className="flex items-center gap-1.5">
|
||
{post.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onTagClick(tag);
|
||
}}
|
||
className="text-[10px] font-mono text-[#787D89] hover:text-[#18181B]"
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleLike}
|
||
className={`flex items-center gap-1 text-xs font-mono ${
|
||
hasLiked ? "text-[#E11D48] font-bold" : "text-[#787D89] hover:text-[#18181B]"
|
||
}`}
|
||
>
|
||
<Heart className={`w-3.5 h-3.5 ${hasLiked ? "fill-current" : ""}`} />
|
||
<span>{likes}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Default: Field Notebook Page (Longform / Deep Note)
|
||
return (
|
||
<div
|
||
onClick={() => onOpenDetail(post)}
|
||
className="group relative notebook-sheet rounded-xl p-5 sm:p-7 shadow-sm hover:shadow-lg transition-all duration-300 border border-[#18181B]/10 cursor-pointer"
|
||
>
|
||
{/* Top Binder Clip */}
|
||
<div className="binder-clip absolute top-[-7px] left-8 w-10 h-4.5 rounded-xs" />
|
||
|
||
{/* Header with stamped date and mood badge */}
|
||
<div className="flex items-start justify-between gap-2 pb-3 border-b border-[#18181B]/8 mb-4">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono text-xs font-bold text-[#18181B]">
|
||
{post.date} • {post.timestamp}
|
||
</span>
|
||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-[#18181B]/5 text-[#52525B]">
|
||
{post.moodLabel}
|
||
</span>
|
||
</div>
|
||
{post.title && (
|
||
<h3 className="font-heading font-extrabold text-lg sm:text-xl text-[#18181B] mt-1.5 group-hover:text-[#E11D48] transition-colors leading-snug">
|
||
{post.title}
|
||
</h3>
|
||
)}
|
||
</div>
|
||
|
||
{post.stampedText && (
|
||
<span className="rubber-stamp text-[10px] text-[#059669] border-[#059669] rotate-[-2deg] shrink-0">
|
||
{post.stampedText}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Body with Highlighter markers */}
|
||
<div className="font-serif text-[#18181B] text-sm sm:text-base leading-relaxed space-y-2">
|
||
<p className="line-clamp-4 whitespace-pre-line">
|
||
{post.content}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Marginalia & Handwritten Comments */}
|
||
{post.marginNotes && post.marginNotes.length > 0 && (
|
||
<div className="my-3.5 p-3 rounded-lg bg-[#FAF7F2] border border-[#18181B]/8 space-y-1">
|
||
<div className="text-[10px] font-mono uppercase text-[#787D89] font-bold">Kenar Notları:</div>
|
||
{post.marginNotes.map((note, idx) => (
|
||
<div key={idx} className="font-handwritten text-[#E11D48] text-base leading-tight flex items-center gap-1.5">
|
||
<span>•</span>
|
||
<span>{note}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between pt-3 border-t border-[#18181B]/8 mt-4">
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
{post.tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onTagClick(tag);
|
||
}}
|
||
className="text-[11px] font-mono px-2 py-0.5 rounded bg-[#F3ECE2] text-[#52525B] hover:bg-[#EFE7D6] hover:text-[#18181B] transition-colors"
|
||
>
|
||
#{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={handleLike}
|
||
className={`flex items-center gap-1 text-xs font-mono transition-colors ${
|
||
hasLiked ? "text-[#E11D48] font-bold" : "text-[#787D89] hover:text-[#18181B]"
|
||
}`}
|
||
>
|
||
<Heart className={`w-3.5 h-3.5 ${hasLiked ? "fill-current" : ""}`} />
|
||
<span>{likes}</span>
|
||
</button>
|
||
|
||
<span className="text-xs font-heading font-semibold text-[#18181B] flex items-center gap-1 group-hover:translate-x-0.5 transition-transform">
|
||
<span>Oku</span>
|
||
<Maximize2 className="w-3 h-3 text-[#787D89]" />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|