Add embedded about & open-source credits screen (license audit)

This commit is contained in:
2026-09-09 21:05:59 +07:00
parent 568f06306a
commit 5799210949
4 changed files with 696 additions and 2 deletions
+163
View File
@@ -0,0 +1,163 @@
import React, { useMemo, useState } from 'react';
import { View, Text, Modal, ScrollView, TouchableOpacity, Linking } from 'react-native';
import { X, ChevronDown, ChevronRight, ExternalLink, Scale } from 'lucide-react-native';
import { CREDITS, LICENSE_TEXTS, CreditEntry } from '../utils/credits';
// About + open-source credits, reachable from the settings sheet. Third-party
// data is generated from node_modules (src/utils/credits.ts + THIRD_PARTY_NOTICES.md);
// each project row links to its homepage, each license group expands to the
// full license text embedded in the app.
const APP_NAME = 'recipescamera';
const APP_VERSION = '1.0.0'; // keep in sync with app.json
function Group({
license,
entries,
}: {
license: string;
entries: CreditEntry[];
}) {
const [open, setOpen] = useState(false);
const text = LICENSE_TEXTS[license];
return (
<View className="rounded-xl border border-zinc-800/70 bg-black/30 overflow-hidden">
<TouchableOpacity
onPress={() => setOpen((v) => !v)}
className="flex-row items-center justify-between px-3 py-2.5"
activeOpacity={0.7}
>
<View className="flex-row items-center">
<Scale size={12} color="#f59e0b" strokeWidth={1.75} />
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-widest ml-2">
{license}
</Text>
<Text className="text-zinc-600 font-mono text-[10px] ml-2">
{entries.length} {entries.length === 1 ? 'component' : 'components'}
</Text>
</View>
{open ? (
<ChevronDown size={14} color="#71717a" />
) : (
<ChevronRight size={14} color="#71717a" />
)}
</TouchableOpacity>
{open && (
<View className="px-3 pb-3 space-y-2">
{entries.map((e) => (
<View key={e.name} className="border-t border-zinc-800/60 pt-2">
<View className="flex-row items-start justify-between">
<Text className="text-zinc-200 font-mono text-[11px] font-semibold flex-1 pr-2">
{e.name}
</Text>
{e.url.startsWith('http') && (
<TouchableOpacity
onPress={() => Linking.openURL(e.url)}
accessibilityLabel={`Open ${e.name} project page`}
className="flex-row items-center"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<ExternalLink size={12} color="#f59e0b" />
</TouchableOpacity>
)}
</View>
{e.copyright ? (
<Text className="text-zinc-500 font-mono text-[10px] mt-0.5">{e.copyright}</Text>
) : null}
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
{e.version}
</Text>
</View>
))}
{text ? (
<View className="border-t border-zinc-800/60 pt-2">
<Text selectable className="text-zinc-500 font-mono text-[9px] leading-4">
{text}
</Text>
</View>
) : null}
</View>
)}
</View>
);
}
export default function CreditsModal({
visible,
onClose,
}: {
visible: boolean;
onClose: () => void;
}) {
const groups = useMemo(() => {
const byLicense = new Map<string, CreditEntry[]>();
for (const e of CREDITS) {
const arr = byLicense.get(e.license) ?? [];
arr.push(e);
byLicense.set(e.license, arr);
}
// stable order: licenses as declared in LICENSE_TEXTS, then rest
const order = Object.keys(LICENSE_TEXTS);
const sorted = [...byLicense.keys()].sort(
(a, b) => order.indexOf(a) - order.indexOf(b)
);
return sorted
.filter((k) => byLicense.has(k))
.map((k) => ({ license: k, entries: byLicense.get(k)! }));
}, []);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View className="flex-1 bg-black/70 items-center justify-center px-4">
<TouchableOpacity className="absolute inset-0" activeOpacity={1} onPress={onClose} />
<View
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5"
style={{ backgroundColor: 'rgba(8,8,10,0.97)', maxHeight: '85%' }}
>
<View className="flex-row items-center justify-between mb-3">
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-widest">
ABOUT &amp; CREDITS
</Text>
<TouchableOpacity
onPress={onClose}
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={16} color="#ffffff" />
</TouchableOpacity>
</View>
<View className="rounded-xl border border-zinc-800/70 bg-black/30 px-3 py-2.5 mb-3">
<Text className="text-zinc-200 font-mono text-sm font-bold">{APP_NAME}</Text>
<Text className="text-zinc-500 font-mono text-[10px] mt-0.5">
version {APP_VERSION} · film-recipe camera (experimental)
</Text>
<View className="flex-row flex-wrap items-center mt-1">
<Text className="text-zinc-400 font-mono text-[10px]">Author: Lộc Phạm</Text>
<TouchableOpacity
onPress={() => Linking.openURL('mailto:tranloclqd@gmail.com')}
accessibilityLabel="Email author"
className="flex-row items-center ml-3"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Text className="text-amber-500/90 font-mono text-[10px] underline">
tranloclqd@gmail.com
</Text>
</TouchableOpacity>
</View>
<Text className="text-zinc-600 font-mono text-[10px] mt-1">
© 2026 Lộc Phạm · Built with React Native + Expo; icons by Lucide;
on-screen graphics rendered with Skia.
</Text>
</View>
<ScrollView className="space-y-2" showsVerticalScrollIndicator={false}>
{groups.map((g) => (
<Group key={g.license} license={g.license} entries={g.entries} />
))}
</ScrollView>
</View>
</View>
</Modal>
);
}
+28 -2
View File
@@ -1,8 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { View, Text, Modal, TouchableOpacity } from 'react-native';
import * as Haptics from 'expo-haptics';
import { X, Volume2, Settings as SettingsIcon } from 'lucide-react-native';
import { X, Volume2, Info, ChevronRight, Settings as SettingsIcon } from 'lucide-react-native';
import { AspectRatio, MeterMode, ShutterSound, StartupMode } from '../types';
import CreditsModal from './CreditsModal';
// Gear-sheet (gear button between library picker and shutter). Groups the
// capture/composition choices that are NOT look-adjustments, so the tool rail
@@ -90,6 +91,7 @@ export default function SettingsModal({
startupMode,
onStartupMode,
}: SettingsModalProps) {
const [showCredits, setShowCredits] = useState(false);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View className="flex-1 bg-black/70 items-center justify-center px-6">
@@ -189,8 +191,32 @@ export default function SettingsModal({
/>
<CameraOnlyNote cameraMode={cameraMode} />
</View>
{/* About & open-source credits */}
<View>
<TouchableOpacity
onPress={() => {
haptic();
setShowCredits(true);
}}
className="flex-row items-center justify-between rounded-xl border border-zinc-800/70 bg-black/30 px-3 py-2.5"
activeOpacity={0.7}
>
<View className="flex-row items-center">
<Info size={13} color="#a1a1aa" strokeWidth={1.75} />
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-[0.2em] ml-2">
ABOUT &amp; CREDITS
</Text>
</View>
<ChevronRight size={14} color="#52525b" />
</TouchableOpacity>
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
App info + third-party open-source licenses.
</Text>
</View>
</View>
</View>
<CreditsModal visible={showCredits} onClose={() => setShowCredits(false)} />
</Modal>
);
}