feat: add chapter transition toast and smart title & episode badge parsing

main
I Luk Kim 3 weeks ago
parent 7a755f684f
commit 338ab8a16c

@ -484,6 +484,13 @@ kbd {
min-width: 0;
}
.history-title-row {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
}
.history-manga-title {
font-size: 13px;
font-weight: 600;
@ -501,6 +508,93 @@ kbd {
white-space: nowrap;
}
/* Toolbar Title and Episode Badge */
.toolbar-title-container {
display: flex;
align-items: center;
gap: 8px;
max-width: 360px;
min-width: 0;
}
.toolbar-title-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.episode-badge {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: #ffffff;
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
white-space: nowrap;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4);
}
.episode-badge.sm {
font-size: 10px;
padding: 1px 6px;
}
/* Chapter Transition Toast */
.chapter-toast {
position: absolute;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: rgba(15, 15, 20, 0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
padding: 10px 18px;
border-radius: 30px;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
animation: toastFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.toast-badge {
background: linear-gradient(135deg, #7c3aed, #4f46e5);
color: #fff;
font-size: 12px;
font-weight: 700;
padding: 3px 9px;
border-radius: 12px;
white-space: nowrap;
}
.toast-title {
color: #f3f4f6;
font-size: 14px;
font-weight: 600;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes toastFadeIn {
from {
opacity: 0;
transform: translate(-50%, -12px) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
.history-url {
flex: 1;
font-size: 13px;

@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ViewerSettings } from '../types';
import type { ChapterData } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
import { MangaPage } from './MangaPage';
import { ViewerToolbar } from './ViewerToolbar';
import { SettingsPanel } from './SettingsPanel';
@ -82,9 +83,21 @@ export function MangaViewer({
const [dismissNavError, setDismissNavError] = useState(false);
const [boundaryWarning, setBoundaryWarning] = useState<'first' | 'last' | null>(null);
const [dimMap, setDimMap] = useState<Record<string, { width: number; height: number }>>({});
const [toast, setToast] = useState<{ title: string; episode: string | null } | null>(null);
const toolbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Trigger Chapter Notification Toast on chapter load/change
useEffect(() => {
if (!chapter.title) return;
const parsed = parseTitleAndEpisode(chapter.title);
setToast(parsed);
const timer = setTimeout(() => {
setToast(null);
}, 2800);
return () => clearTimeout(timer);
}, [currentUrl, chapter.title]);
useEffect(() => {
setDismissNavError(false);
if (!navError) return;
@ -327,6 +340,14 @@ export function MangaViewer({
</div>
)}
{/* Chapter Transition Toast */}
{toast && (
<div className="chapter-toast">
{toast.episode && <span className="toast-badge">{toast.episode}</span>}
<span className="toast-title">{toast.title}</span>
</div>
)}
{/* Boundary Warning */}
{boundaryWarning && (
<div className="boundary-warning">

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import type { HistoryEntry } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
interface UrlInputProps {
onLoad: (url: string) => void;
@ -153,7 +154,7 @@ export function UrlInput({
</div>
<div className="history-list">
{history.map((entry) => {
const displayTitle = entry.chapterTitle || (entry.title && entry.title !== entry.url ? entry.title : null);
const parsed = parseTitleAndEpisode(entry.chapterTitle || entry.title);
return (
<div key={entry.url} className="history-item-wrapper">
<button
@ -168,14 +169,11 @@ export function UrlInput({
<path d="M12 6v6l4 2"/>
</svg>
<div className="history-info">
{displayTitle ? (
<>
<span className="history-manga-title">{displayTitle}</span>
<span className="history-url-sub">{entry.url}</span>
</>
) : (
<span className="history-url">{entry.url}</span>
)}
<div className="history-title-row">
<span className="history-manga-title">{parsed.title || entry.url}</span>
{parsed.episode && <span className="episode-badge sm">{parsed.episode}</span>}
</div>
{parsed.title ? <span className="history-url-sub">{entry.url}</span> : null}
</div>
<span className="history-time">
{new Date(entry.timestamp).toLocaleDateString('ko-KR')}

@ -1,4 +1,5 @@
import type { ViewerSettings } from '../types';
import { parseTitleAndEpisode } from '../utils/titleParser';
interface ViewerToolbarProps {
visible: boolean;
@ -72,7 +73,15 @@ export function ViewerToolbar({
{/* Center: Title + page info */}
<div className="toolbar-center">
{title && <span className="toolbar-title">{title}</span>}
{title && (() => {
const parsed = parseTitleAndEpisode(title);
return (
<div className="toolbar-title-container" title={title}>
<span className="toolbar-title-name">{parsed.title}</span>
{parsed.episode && <span className="episode-badge">{parsed.episode}</span>}
</div>
);
})()}
<span className="page-indicator">
{currentPage} {Math.min(currentPage + (settings.mode === 'spread' && currentSlot >= settings.spreadStartOffset ? 1 : 0), totalPages)} / {totalPages}
</span>

@ -0,0 +1,49 @@
export interface ParsedTitle {
title: string;
episode: string | null;
fullCleanTitle: string;
}
export function parseTitleAndEpisode(rawTitle: string | null): ParsedTitle {
if (!rawTitle) return { title: '', episode: null, fullCleanTitle: '' };
// Remove site name suffixes like "- 마나토키 330", "- 뉴토끼", "- newtoki"
let clean = rawTitle
.replace(/\s*[-|_]\s*(마나토키|뉴토끼|newtoki|manatoki|웹툰|만화).*$/i, '')
.trim();
// 1. Match explicit episode format like "150화", "150.5화", "EP.15"
const epMatch = clean.match(/(\d+(?:\.\d+)?\s*화|ep\.?\s*\d+)/i);
if (epMatch) {
let episode = epMatch[1].replace(/\s+/g, '');
if (!episode.endsWith('화') && !episode.toLowerCase().startsWith('ep')) {
episode += '화';
}
// Remove the matched episode part from the title
let title = clean.replace(epMatch[0], '').trim();
// Clean up trailing dashes/colons/dots left over
title = title.replace(/[-|_:]\s*$/, '').trim();
return {
title: title || clean,
episode,
fullCleanTitle: clean,
};
}
// 2. Match trailing numbers like "제목 150"
const numMatch = clean.match(/(\d+(?:\.\d+)?)\s*$/);
if (numMatch) {
const episode = `${numMatch[1]}`;
let title = clean.slice(0, numMatch.index).trim();
title = title.replace(/[-|_:]\s*$/, '').trim();
return {
title: title || clean,
episode,
fullCleanTitle: clean,
};
}
return { title: clean, episode: null, fullCleanTitle: clean };
}
Loading…
Cancel
Save