- 章节:详情页增加删除按钮,软删除(is_active=False),AI 不再修改但保留供参考 - 章节:get_chapter 增加 is_active 校验,已删除章节返回 404 - 章节:AI 生成时参考同类别已删除章节摘要 - 对话:左滑显示删除,调用 hard delete API,删除前二次确认 - 对话:根布局包裹 GestureHandlerRootView 以支持 Swipeable - 对话:移除已读/未读状态展示及相关 i18n
703 lines
20 KiB
TypeScript
703 lines
20 KiB
TypeScript
import { Image } from 'expo-image';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { router, useLocalSearchParams } from 'expo-router';
|
|
import { Settings, Trash2, X } from 'lucide-react-native';
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Modal,
|
|
Platform,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
useWindowDimensions,
|
|
View,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Icon } from '@/components/ui/icon';
|
|
import { Text } from '@/components/ui/text';
|
|
import { ScreenHeader } from '@/components/screen-header';
|
|
import { ScreenGutter } from '@/constants/layout';
|
|
import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks';
|
|
|
|
// Life-Echo reading colors (from HTML reference)
|
|
const READING_COLORS = {
|
|
background: '#F8F9FA',
|
|
backgroundSepia: '#F2E8CF',
|
|
primary: '#8177A6',
|
|
onSurface: '#1b1b1f',
|
|
onSurfaceVariant: '#48454e',
|
|
divider: 'rgba(141, 140, 144, 0.2)',
|
|
surfaceContainerHigh: '#e9e7ec',
|
|
outlineVariant: 'rgba(121, 117, 127, 0.3)',
|
|
};
|
|
|
|
type FontSize = 'small' | 'default' | 'large';
|
|
type FontFamily = 'serif' | 'sans';
|
|
type BackgroundTheme = 'white' | 'sepia';
|
|
|
|
const FONT_SIZES: Record<FontSize, number> = {
|
|
small: 16,
|
|
default: 20,
|
|
large: 24,
|
|
};
|
|
|
|
const LINE_HEIGHTS: Record<FontSize, number> = {
|
|
small: 30,
|
|
default: 38,
|
|
large: 44,
|
|
};
|
|
|
|
const FONT_FAMILIES: Record<FontFamily, string> = {
|
|
serif:
|
|
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
|
|
'serif',
|
|
sans:
|
|
Platform.select({
|
|
ios: 'System',
|
|
android: 'sans-serif',
|
|
default: 'sans-serif',
|
|
}) ?? 'sans-serif',
|
|
};
|
|
|
|
const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
|
|
white: READING_COLORS.background,
|
|
sepia: READING_COLORS.backgroundSepia,
|
|
};
|
|
|
|
function ChapterContent({
|
|
sections,
|
|
coverImageUrl,
|
|
fontSize,
|
|
fontFamily,
|
|
backgroundColor,
|
|
}: {
|
|
sections: { content: string; image: { url: string | null } | null }[];
|
|
coverImageUrl: string | null;
|
|
fontSize: FontSize;
|
|
fontFamily: FontFamily;
|
|
backgroundColor: BackgroundTheme;
|
|
}) {
|
|
const { width } = useWindowDimensions();
|
|
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
|
|
const heroAspectRatio = 4 / 5;
|
|
const bodySize = FONT_SIZES[fontSize];
|
|
const lineHeight = LINE_HEIGHTS[fontSize];
|
|
const fontFam = FONT_FAMILIES[fontFamily];
|
|
const bgColor = BACKGROUND_COLORS[backgroundColor];
|
|
|
|
const [heroLoadFailed, setHeroLoadFailed] = useState(false);
|
|
useEffect(() => {
|
|
setHeroLoadFailed(false);
|
|
}, [coverImageUrl]);
|
|
const hasCoverImage = !!coverImageUrl && !heroLoadFailed;
|
|
|
|
return (
|
|
<>
|
|
{/* Hero Image: 仅在有封面图且加载成功时显示,避免无图或加载失败时大片空白 */}
|
|
{hasCoverImage && (
|
|
<View
|
|
style={{
|
|
width: '100%',
|
|
aspectRatio: heroAspectRatio,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Image
|
|
source={{ uri: coverImageUrl! }}
|
|
alt="Chapter hero"
|
|
onError={() => setHeroLoadFailed(true)}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
<LinearGradient
|
|
colors={['transparent', bgColor]}
|
|
locations={[0.3, 1]}
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Memoir Content */}
|
|
<View
|
|
style={{
|
|
paddingHorizontal: ScreenGutter,
|
|
marginTop: hasCoverImage ? -60 : 0,
|
|
paddingTop: hasCoverImage ? 0 : 12,
|
|
paddingBottom: 48,
|
|
backgroundColor: bgColor,
|
|
}}
|
|
>
|
|
<View
|
|
style={{ maxWidth: contentWidth, alignSelf: 'center', width: '100%' }}
|
|
>
|
|
{(() => {
|
|
const indent = '\u3000\u3000';
|
|
let isFirstParagraph = true;
|
|
|
|
return sections.flatMap((section, i) => {
|
|
const text = section.content?.trim() ?? '';
|
|
const hasImage = !!section.image?.url;
|
|
if (!text && !hasImage) return [];
|
|
|
|
const paragraphs = text
|
|
? text
|
|
.split(/\n\n+/)
|
|
.map((p) => p.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const nodes: React.ReactNode[] = [];
|
|
|
|
paragraphs.forEach((para, pIdx) => {
|
|
const firstChar = para.charAt(0);
|
|
const rest = para.slice(1);
|
|
const isLastInSection =
|
|
pIdx === paragraphs.length - 1 && !hasImage;
|
|
const mb = isLastInSection ? 20 : 16;
|
|
|
|
if (isFirstParagraph && firstChar) {
|
|
const firstParaLineHeight = Math.round(lineHeight * 1.35);
|
|
nodes.push(
|
|
<Text
|
|
key={`${i}-${pIdx}`}
|
|
selectable
|
|
style={{
|
|
fontSize: bodySize,
|
|
lineHeight: firstParaLineHeight,
|
|
color: READING_COLORS.onSurfaceVariant,
|
|
fontFamily: fontFam,
|
|
marginBottom: mb,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: bodySize * 2.2,
|
|
fontWeight: '700',
|
|
color: READING_COLORS.primary,
|
|
lineHeight: firstParaLineHeight,
|
|
fontFamily: fontFam,
|
|
}}
|
|
>
|
|
{firstChar}
|
|
</Text>
|
|
{rest}
|
|
</Text>,
|
|
);
|
|
isFirstParagraph = false;
|
|
} else {
|
|
nodes.push(
|
|
<Text
|
|
key={`${i}-${pIdx}`}
|
|
selectable
|
|
style={{
|
|
fontSize: bodySize,
|
|
lineHeight,
|
|
color: READING_COLORS.onSurfaceVariant,
|
|
fontFamily: fontFam,
|
|
marginBottom: mb,
|
|
}}
|
|
>
|
|
{indent}
|
|
{para}
|
|
</Text>,
|
|
);
|
|
}
|
|
});
|
|
|
|
if (hasImage) {
|
|
nodes.push(
|
|
<Image
|
|
key={`${i}-img`}
|
|
source={{ uri: section.image!.url }}
|
|
style={{
|
|
width: '100%',
|
|
aspectRatio: 16 / 9,
|
|
borderRadius: 12,
|
|
marginTop: paragraphs.length > 0 ? 12 : 0,
|
|
marginBottom: 20,
|
|
}}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
return nodes;
|
|
});
|
|
})()}
|
|
|
|
{sections.length > 0 && (
|
|
<View
|
|
style={{
|
|
paddingTop: 24,
|
|
paddingBottom: 24,
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 96,
|
|
height: 1,
|
|
backgroundColor: READING_COLORS.divider,
|
|
}}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ReadingSettingsModal({
|
|
visible,
|
|
onClose,
|
|
fontSize,
|
|
fontFamily,
|
|
backgroundColor,
|
|
onFontSizeChange,
|
|
onFontFamilyChange,
|
|
onBackgroundChange,
|
|
t,
|
|
}: {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
fontSize: FontSize;
|
|
fontFamily: FontFamily;
|
|
backgroundColor: BackgroundTheme;
|
|
onFontSizeChange: (v: FontSize) => void;
|
|
onFontFamilyChange: (v: FontFamily) => void;
|
|
onBackgroundChange: (v: BackgroundTheme) => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
const insets = useSafeAreaInsets();
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
animationType="slide"
|
|
transparent
|
|
onRequestClose={onClose}
|
|
>
|
|
<Pressable style={readingSettingsStyles.backdrop} onPress={onClose}>
|
|
<Pressable
|
|
style={[
|
|
readingSettingsStyles.sheet,
|
|
{ paddingBottom: insets.bottom + 24 },
|
|
]}
|
|
onPress={() => {}}
|
|
>
|
|
<View style={readingSettingsStyles.handle} />
|
|
<View style={readingSettingsStyles.header}>
|
|
<Text style={readingSettingsStyles.title}>
|
|
{t('chapterReading.readingSettings')}
|
|
</Text>
|
|
<Pressable
|
|
onPress={onClose}
|
|
hitSlop={12}
|
|
style={({ pressed }) => [
|
|
readingSettingsStyles.closeBtn,
|
|
pressed && { opacity: 0.6 },
|
|
]}
|
|
accessibilityLabel={t('chapterReading.close')}
|
|
accessibilityRole="button"
|
|
>
|
|
<Icon as={X} size={22} color={READING_COLORS.onSurfaceVariant} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<View style={readingSettingsStyles.section}>
|
|
<Text style={readingSettingsStyles.label}>
|
|
{t('chapterReading.fontSize')}
|
|
</Text>
|
|
<View style={readingSettingsStyles.segmented}>
|
|
{(['small', 'default', 'large'] as const).map((s) => (
|
|
<Pressable
|
|
key={s}
|
|
onPress={() => onFontSizeChange(s)}
|
|
style={({ pressed }) => [
|
|
readingSettingsStyles.segItem,
|
|
fontSize === s && readingSettingsStyles.segItemActive,
|
|
pressed && { opacity: 0.8 },
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
readingSettingsStyles.segText,
|
|
fontSize === s && readingSettingsStyles.segTextActive,
|
|
]}
|
|
>
|
|
{t(
|
|
`chapterReading.fontSize${s.charAt(0).toUpperCase() + s.slice(1)}`,
|
|
)}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
<View style={readingSettingsStyles.section}>
|
|
<Text style={readingSettingsStyles.label}>
|
|
{t('chapterReading.typography')}
|
|
</Text>
|
|
<View style={readingSettingsStyles.segmented}>
|
|
{(['serif', 'sans'] as const).map((f) => (
|
|
<Pressable
|
|
key={f}
|
|
onPress={() => onFontFamilyChange(f)}
|
|
style={({ pressed }) => [
|
|
readingSettingsStyles.segItem,
|
|
fontFamily === f && readingSettingsStyles.segItemActive,
|
|
pressed && { opacity: 0.8 },
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
readingSettingsStyles.segText,
|
|
fontFamily === f && readingSettingsStyles.segTextActive,
|
|
{ fontFamily: FONT_FAMILIES[f] },
|
|
]}
|
|
>
|
|
{t(
|
|
`chapterReading.font${f.charAt(0).toUpperCase() + f.slice(1)}`,
|
|
)}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
<View style={readingSettingsStyles.section}>
|
|
<Text style={readingSettingsStyles.label}>
|
|
{t('chapterReading.backgroundColor')}
|
|
</Text>
|
|
<View style={readingSettingsStyles.bgRow}>
|
|
{(['white', 'sepia'] as const).map((theme) => (
|
|
<Pressable
|
|
key={theme}
|
|
onPress={() => onBackgroundChange(theme)}
|
|
style={({ pressed }) => [
|
|
readingSettingsStyles.bgOption,
|
|
{ backgroundColor: BACKGROUND_COLORS[theme] },
|
|
backgroundColor === theme &&
|
|
readingSettingsStyles.bgOptionActive,
|
|
pressed && { opacity: 0.9 },
|
|
]}
|
|
>
|
|
<View
|
|
style={[
|
|
readingSettingsStyles.bgSwatch,
|
|
{ backgroundColor: BACKGROUND_COLORS[theme] },
|
|
theme === 'sepia' && {
|
|
borderColor: 'rgba(91,77,62,0.5)',
|
|
},
|
|
]}
|
|
/>
|
|
<Text
|
|
style={[
|
|
readingSettingsStyles.bgLabel,
|
|
theme === 'sepia' && { color: '#5B4D3E' },
|
|
backgroundColor === theme &&
|
|
readingSettingsStyles.bgLabelActive,
|
|
]}
|
|
>
|
|
{t(
|
|
`chapterReading.bg${theme === 'white' ? 'PureWhite' : 'Sepia'}`,
|
|
)}
|
|
</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const readingSettingsStyles = StyleSheet.create({
|
|
backdrop: {
|
|
flex: 1,
|
|
justifyContent: 'flex-end',
|
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
},
|
|
sheet: {
|
|
backgroundColor: '#fff',
|
|
borderTopLeftRadius: 16,
|
|
borderTopRightRadius: 16,
|
|
paddingTop: 8,
|
|
paddingHorizontal: ScreenGutter,
|
|
...Platform.select({
|
|
ios: {
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: -2 },
|
|
shadowOpacity: 0.06,
|
|
shadowRadius: 12,
|
|
},
|
|
android: { elevation: 12 },
|
|
}),
|
|
},
|
|
handle: {
|
|
alignSelf: 'center',
|
|
width: 40,
|
|
height: 4,
|
|
borderRadius: 2,
|
|
backgroundColor: 'rgba(0,0,0,0.2)',
|
|
marginBottom: 16,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 20,
|
|
},
|
|
title: {
|
|
fontSize: 17,
|
|
fontWeight: '700',
|
|
color: READING_COLORS.onSurface,
|
|
},
|
|
closeBtn: { padding: 8 },
|
|
label: {
|
|
fontSize: 11,
|
|
fontWeight: '600',
|
|
letterSpacing: 0.5,
|
|
color: READING_COLORS.onSurfaceVariant,
|
|
marginBottom: 10,
|
|
textTransform: 'uppercase',
|
|
},
|
|
section: { marginBottom: 20 },
|
|
segmented: {
|
|
flexDirection: 'row',
|
|
backgroundColor: '#eeeef0',
|
|
borderRadius: 10,
|
|
padding: 4,
|
|
},
|
|
segItem: {
|
|
flex: 1,
|
|
minHeight: 44,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
borderRadius: 8,
|
|
},
|
|
segItemActive: {
|
|
backgroundColor: READING_COLORS.primary,
|
|
},
|
|
segText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
color: READING_COLORS.onSurfaceVariant,
|
|
},
|
|
segTextActive: {
|
|
fontWeight: '700',
|
|
color: '#fff',
|
|
},
|
|
bgRow: { flexDirection: 'row', gap: 12 },
|
|
bgOption: {
|
|
flex: 1,
|
|
minHeight: 64,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: 10,
|
|
borderRadius: 10,
|
|
borderWidth: 2,
|
|
borderColor: 'transparent',
|
|
},
|
|
bgOptionActive: {
|
|
borderColor: READING_COLORS.primary,
|
|
},
|
|
bgSwatch: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(0,0,0,0.1)',
|
|
},
|
|
bgLabel: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
color: READING_COLORS.onSurface,
|
|
},
|
|
bgLabelActive: { fontWeight: '700' },
|
|
});
|
|
|
|
export default function ChapterScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const insets = useSafeAreaInsets();
|
|
const { t } = useTranslation('memoir');
|
|
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
|
const deleteChapter = useDeleteChapter();
|
|
|
|
const [settingsVisible, setSettingsVisible] = useState(false);
|
|
const [fontSize, setFontSize] = useState<FontSize>('default');
|
|
const [fontFamily, setFontFamily] = useState<FontFamily>('serif');
|
|
const [backgroundColor, setBackgroundColor] =
|
|
useState<BackgroundTheme>('white');
|
|
|
|
const bgColor = BACKGROUND_COLORS[backgroundColor];
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: bgColor,
|
|
}}
|
|
>
|
|
<ActivityIndicator size="large" color={READING_COLORS.primary} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!chapter) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: bgColor,
|
|
gap: 16,
|
|
}}
|
|
>
|
|
<Text
|
|
selectable
|
|
style={{ color: READING_COLORS.onSurfaceVariant, fontSize: 16 }}
|
|
>
|
|
{t('chapterReading.chapterNotFound')}
|
|
</Text>
|
|
<Pressable
|
|
onPress={() => router.back()}
|
|
style={({ pressed }) => ({
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 12,
|
|
borderRadius: 12,
|
|
backgroundColor: READING_COLORS.primary,
|
|
opacity: pressed ? 0.8 : 1,
|
|
})}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('chapterReading.back')}
|
|
>
|
|
<Text
|
|
style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}
|
|
selectable={false}
|
|
>
|
|
{t('chapterReading.back')}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const sections = chapter.sections ?? [];
|
|
const coverImageUrl = chapter.cover_image?.url ?? null;
|
|
|
|
const handleDeletePress = () => {
|
|
Alert.alert(
|
|
t('chapterReading.deleteChapter'),
|
|
t('chapterReading.confirmDeleteMessage'),
|
|
[
|
|
{ text: t('chapterReading.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('chapterReading.deleteChapterAction'),
|
|
style: 'destructive',
|
|
onPress: () => {
|
|
deleteChapter.mutate(chapter.id, {
|
|
onSuccess: () => router.back(),
|
|
});
|
|
},
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
return (
|
|
<View style={{ flex: 1, backgroundColor: bgColor }}>
|
|
<ScreenHeader
|
|
variant="reading"
|
|
absolute
|
|
backgroundColor={bgColor}
|
|
title={chapter.title}
|
|
backAccessibilityLabel={t('chapterReading.back')}
|
|
right={
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
|
<Pressable
|
|
onPress={handleDeletePress}
|
|
disabled={deleteChapter.isPending}
|
|
style={({ pressed }) => ({
|
|
padding: 8,
|
|
borderRadius: 9999,
|
|
opacity: pressed ? 0.7 : 1,
|
|
})}
|
|
accessibilityLabel={t('chapterReading.deleteChapter')}
|
|
accessibilityRole="button"
|
|
>
|
|
<Icon
|
|
as={Trash2}
|
|
size={22}
|
|
color={READING_COLORS.onSurfaceVariant}
|
|
/>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => setSettingsVisible(true)}
|
|
style={({ pressed }) => ({
|
|
padding: 8,
|
|
marginRight: -8,
|
|
borderRadius: 9999,
|
|
opacity: pressed ? 0.7 : 1,
|
|
})}
|
|
accessibilityLabel={t('chapterReading.settings')}
|
|
accessibilityRole="button"
|
|
>
|
|
<Icon as={Settings} size={24} color={READING_COLORS.primary} />
|
|
</Pressable>
|
|
</View>
|
|
}
|
|
/>
|
|
|
|
{/* Scrollable content */}
|
|
<ScrollView
|
|
contentInsetAdjustmentBehavior="never"
|
|
contentContainerStyle={{
|
|
paddingTop: insets.top + 72,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
style={{ backgroundColor: bgColor }}
|
|
>
|
|
<ChapterContent
|
|
sections={sections}
|
|
coverImageUrl={coverImageUrl}
|
|
fontSize={fontSize}
|
|
fontFamily={fontFamily}
|
|
backgroundColor={backgroundColor}
|
|
/>
|
|
</ScrollView>
|
|
|
|
<ReadingSettingsModal
|
|
visible={settingsVisible}
|
|
onClose={() => setSettingsVisible(false)}
|
|
fontSize={fontSize}
|
|
fontFamily={fontFamily}
|
|
backgroundColor={backgroundColor}
|
|
onFontSizeChange={setFontSize}
|
|
onFontFamilyChange={setFontFamily}
|
|
onBackgroundChange={setBackgroundColor}
|
|
t={t as (key: string) => string}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|