mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 03:47:02 +00:00
@@ -131,10 +131,10 @@ AUTH0_CLIENT_SECRET=
|
||||
AUTH0_CLIENT_ID=
|
||||
|
||||
# Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
||||
AUTHELIA_CLIENT_ID=""
|
||||
AUTHELIA_CLIENT_SECRET=""
|
||||
AUTHELIA_WELLKNOWN_URL=""
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||
AUTHELIA_CLIENT_ID=
|
||||
AUTHELIA_CLIENT_SECRET=
|
||||
AUTHELIA_WELLKNOWN_URL=
|
||||
|
||||
# Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
|
||||
2
apps/mobile/.gitignore
vendored
2
apps/mobile/.gitignore
vendored
@@ -42,3 +42,5 @@ ios/
|
||||
android/
|
||||
|
||||
service-account-file.json
|
||||
|
||||
.env.local
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"foregroundImage": "./assets/images/maskable_logo.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "app.linkwarden"
|
||||
@@ -56,7 +56,6 @@
|
||||
"iosAppGroupIdentifier": "group.app.linkwarden"
|
||||
}
|
||||
],
|
||||
"./plugins/with-daynight-transparent-nav",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -66,10 +65,21 @@
|
||||
"allowBackup": false,
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0"
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
"react-native-edge-to-edge",
|
||||
{
|
||||
"android": {
|
||||
"parentTheme": "Default",
|
||||
"enforceNavigationBarContrast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"./plugins/with-daynight-transparent-nav"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -5,7 +5,7 @@ import HapticTab from "@/components/HapticTab";
|
||||
import TabBarBackground from "@/components/ui/TabBarBackground";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { House, Link, Settings } from "lucide-react-native";
|
||||
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
@@ -23,11 +23,15 @@ export default function TabLayout() {
|
||||
borderTopWidth: 0,
|
||||
elevation: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
default: {
|
||||
borderTopWidth: 0,
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
elevation: 0,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
@@ -37,7 +41,7 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Dashboard",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <House size={26} color={color} />,
|
||||
tabBarIcon: ({ color }) => <House size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
@@ -45,7 +49,23 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Links",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Link size={26} color={color} />,
|
||||
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="collections"
|
||||
options={{
|
||||
title: "Collections",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="tags"
|
||||
options={{
|
||||
title: "Tags",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
@@ -53,7 +73,7 @@ export default function TabLayout() {
|
||||
options={{
|
||||
title: "Settings",
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ color }) => <Settings size={26} color={color} />,
|
||||
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
62
apps/mobile/app/(tabs)/collections/[id].tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, id } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
collectionId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const activeCollection = collections.data?.filter(
|
||||
(e) => e.id === Number(id)
|
||||
)[0];
|
||||
|
||||
if (activeCollection?.name)
|
||||
navigation?.setOptions?.({
|
||||
headerTitle: activeCollection?.name,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${activeCollection.name}`,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/collections/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Collections",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Collections",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
search: encodeURIComponent(e.nativeEvent.text),
|
||||
});
|
||||
},
|
||||
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||
},
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
94
apps/mobile/app/(tabs)/collections/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import CollectionListing from "@/components/CollectionListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
|
||||
export default function CollectionsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const [filteredCollections, setFilteredCollections] = useState<
|
||||
CollectionIncludingMembersAndLinkCount[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
collections.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredCollections(filter);
|
||||
}, [search, collections.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{collections.isLoading ? (
|
||||
<View className="flex justify-center h-full items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={filteredCollections}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={collections.isRefetching}
|
||||
onRefresh={() => collections.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={collections.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <CollectionListing collection={item} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
@@ -1,20 +1,10 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, FlatList, Platform } from "react-native";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -23,24 +13,32 @@ export default function LinksScreen() {
|
||||
section?: "pinned-links" | "recent-links" | "collection";
|
||||
collectionId?: string;
|
||||
}>();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const navigation = useNavigation();
|
||||
const collections = useCollections(auth);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (section === "pinned-links") return "Pinned Links";
|
||||
if (section === "recent-links") return "Recent Links";
|
||||
|
||||
if (section === "collection") {
|
||||
return (
|
||||
collections.data?.find((c) => c.id?.toString() === collectionId)
|
||||
?.name || "Collection"
|
||||
);
|
||||
}
|
||||
|
||||
return "Links";
|
||||
}, [section, collections.data, collectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle:
|
||||
section === "pinned-links"
|
||||
? "Pinned Links"
|
||||
: section === "recent-links"
|
||||
? "Recent Links"
|
||||
: section === "collection"
|
||||
? collections.data?.find((c) => c.id?.toString() === collectionId)
|
||||
?.name || "Collection"
|
||||
: "Links",
|
||||
headerTitle: title,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${title}`,
|
||||
},
|
||||
});
|
||||
}, [section, navigation]);
|
||||
}, [title, navigation]);
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
@@ -59,29 +57,7 @@ export default function LinksScreen() {
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={links || []}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={data.isRefetching}
|
||||
onRefresh={() => data.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
onEndReached={() => data.fetchNextPage()}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => <View className="bg-base-200 h-px" />}
|
||||
/>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Platform, TouchableOpacity } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -54,9 +54,12 @@ export default function RootLayout() {
|
||||
>
|
||||
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item key="more-options" disabled>
|
||||
<DropdownMenu.Item
|
||||
key="new-collection"
|
||||
onSelect={() => SheetManager.show("new-collection-sheet")}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
More Coming Soon!
|
||||
New Collection
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
@@ -1,43 +1,15 @@
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Platform, ScrollView, StyleSheet } from "react-native";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useDashboardData } from "@linkwarden/router/dashboardData";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { DashboardSection } from "@linkwarden/prisma/client";
|
||||
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import clsx from "clsx";
|
||||
import DashboardItem from "@/components/DashboardItem";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useRouter } from "expo-router";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import {
|
||||
Clock8,
|
||||
ChevronRight,
|
||||
Pin,
|
||||
Folder,
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
|
||||
// Don't remove this, spent a couple of days to figure out why the app crashes in production :|
|
||||
type DashboardSectionType =
|
||||
| "STATS"
|
||||
| "RECENT_LINKS"
|
||||
| "PINNED_LINKS"
|
||||
| "COLLECTION";
|
||||
import DashboardSection from "@/components/DashboardSection";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -48,15 +20,13 @@ export default function DashboardScreen() {
|
||||
...dashboardData
|
||||
} = useDashboardData(auth);
|
||||
const { data: user, ...userData } = useUser(auth);
|
||||
const { data: collections = [] } = useCollections(auth);
|
||||
const { data: tags = [] } = useTags(auth);
|
||||
const { data: collections = [], ...collectionsData } = useCollections(auth);
|
||||
const { data: tags = [], ...tagsData } = useTags(auth);
|
||||
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [dashboardSections, setDashboardSections] = useState<
|
||||
DashboardSection[]
|
||||
DashboardSectionType[]
|
||||
>(user?.dashboardSections || []);
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
@@ -83,320 +53,59 @@ export default function DashboardScreen() {
|
||||
});
|
||||
}, [dashboardSections]);
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} dashboard />;
|
||||
}
|
||||
);
|
||||
|
||||
interface SectionProps {
|
||||
sectionData: { type: DashboardSectionType };
|
||||
collection?: any;
|
||||
links?: any[];
|
||||
tagsLength: number;
|
||||
numberOfLinks: number;
|
||||
collectionsLength: number;
|
||||
numberOfPinnedLinks: number;
|
||||
dashboardData: { isLoading: boolean };
|
||||
collectionLinks?: any[];
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({
|
||||
sectionData,
|
||||
collection,
|
||||
links = [],
|
||||
tagsLength,
|
||||
numberOfLinks,
|
||||
collectionsLength,
|
||||
numberOfPinnedLinks,
|
||||
dashboardData,
|
||||
collectionLinks = [],
|
||||
}) => {
|
||||
switch (sectionData.type) {
|
||||
case "STATS":
|
||||
return (
|
||||
<View className="flex-col gap-4 max-w-full px-5">
|
||||
<View className="flex-row gap-4">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||
value={numberOfLinks}
|
||||
icon={<Link size={23} color="white" />}
|
||||
color="#9c00cc"
|
||||
/>
|
||||
<DashboardItem
|
||||
name={collectionsLength === 1 ? "Collection" : "Collections"}
|
||||
value={collectionsLength}
|
||||
icon={<Folder size={23} color="white" fill="white" />}
|
||||
color="#0096cc"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row gap-4">
|
||||
<DashboardItem
|
||||
name={tagsLength === 1 ? "Tag" : "Tags"}
|
||||
value={tagsLength}
|
||||
icon={<Hash size={23} color="white" />}
|
||||
color="#00cc99"
|
||||
/>
|
||||
<DashboardItem
|
||||
name={"Pinned Links"}
|
||||
value={numberOfPinnedLinks}
|
||||
icon={<Pin size={23} color="white" fill="white" />}
|
||||
color="#cc6d00"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
case "RECENT_LINKS":
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center">
|
||||
<View className={"flex-row items-center gap-2"}>
|
||||
<Clock8
|
||||
size={30}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl capitalize text-base-content">
|
||||
Recent Links
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1"
|
||||
onPress={() =>
|
||||
router.navigate("/(tabs)/dashboard/recent-links")
|
||||
}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading ||
|
||||
(links.length > 0 && !dashboardData.isLoading) ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
directionalLockEnabled
|
||||
data={links || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Clock8
|
||||
size={40}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
No Recent Links
|
||||
</Text>
|
||||
|
||||
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
|
||||
<Button onPress={() => setNewLinkModal(true)} variant="accent">
|
||||
<Icon name="bi-plus-lg" className="text-xl" />
|
||||
<Text>{t("add_link")}</Text>
|
||||
</Button>
|
||||
<ImportDropdown />
|
||||
</View> */}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case "PINNED_LINKS":
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center">
|
||||
<View className={"flex-row items-center gap-2"}>
|
||||
<Pin
|
||||
size={30}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl capitalize text-base-content">
|
||||
Pinned Links
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1"
|
||||
onPress={() =>
|
||||
router.navigate("/(tabs)/dashboard/pinned-links")
|
||||
}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading ||
|
||||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={
|
||||
links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []
|
||||
}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Pin
|
||||
size={40}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
No Pinned Links
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case "COLLECTION":
|
||||
return collection?.id ? (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center max-w-[60%]">
|
||||
<View className={clsx("flex-row items-center gap-2")}>
|
||||
<Folder
|
||||
size={30}
|
||||
fill={collection.color || "#0ea5e9"}
|
||||
color={collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<Text
|
||||
className="text-2xl capitalize w-full text-base-content"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading || collectionLinks.length > 0 ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={collectionLinks || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Empty Collection
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={styles.container}
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
className="bg-base-100 h-full"
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={
|
||||
dashboardData.isRefetching ||
|
||||
userData.isRefetching ||
|
||||
collectionsData.isRefetching ||
|
||||
tagsData.isRefetching
|
||||
}
|
||||
onRefresh={() => {
|
||||
dashboardData.refetch();
|
||||
userData.refetch();
|
||||
collectionsData.refetch();
|
||||
tagsData.refetch();
|
||||
}}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.container}
|
||||
className="bg-base-100"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={dashboardData.isLoading || userData.isLoading}
|
||||
onRefresh={() => {
|
||||
dashboardData.refetch();
|
||||
userData.refetch();
|
||||
}}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
{orderedSections.map((sectionData, i) => {
|
||||
if (!collections || !collections[0]) return null;
|
||||
|
||||
const collection = collections.find(
|
||||
(c) => c.id === sectionData.collectionId
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardSection
|
||||
key={sectionData.id}
|
||||
sectionData={sectionData}
|
||||
collection={collection}
|
||||
collectionLinks={
|
||||
sectionData.collectionId
|
||||
? collectionLinks[sectionData.collectionId]
|
||||
: []
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
links={links}
|
||||
tagsLength={tags.length}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={{
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
}}
|
||||
className="bg-base-100"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
>
|
||||
{orderedSections.map((sectionData) => {
|
||||
return (
|
||||
<Section
|
||||
key={sectionData.id}
|
||||
sectionData={sectionData}
|
||||
collection={collections.find(
|
||||
(c) => c.id === sectionData.collectionId
|
||||
)}
|
||||
collectionLinks={
|
||||
sectionData.collectionId
|
||||
? collectionLinks[sectionData.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tagsLength={tags.length}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -404,7 +113,14 @@ const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 49,
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
default: {
|
||||
flexDirection: "column",
|
||||
gap: 15,
|
||||
paddingVertical: 20,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function RootLayout() {
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search",
|
||||
placeholder: "Search Links",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, FlatList, Platform } from "react-native";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
@@ -35,32 +24,7 @@ export default function LinksScreen() {
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={links || []}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={data.isRefetching}
|
||||
onRefresh={() => data.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={data.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
onEndReached={() => data.fetchNextPage()}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
/>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function RootLayout() {
|
||||
export default function Layout() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,9 +14,9 @@ import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AppWindowMac,
|
||||
Check,
|
||||
FileText,
|
||||
Globe,
|
||||
ExternalLink,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Sun,
|
||||
} from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import { ArchivedFormat } from "@/types/global";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
@@ -145,26 +144,24 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">
|
||||
Default Behavior for Opening Links
|
||||
</Text>
|
||||
<Text className="mb-4 mx-4 text-neutral">Preferred Browser</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() =>
|
||||
updateData({
|
||||
preferredFormat: null,
|
||||
preferredBrowser: "app",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Globe
|
||||
<AppWindowMac
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">Open original content</Text>
|
||||
<Text className="text-base-content">In app browser</Text>
|
||||
</View>
|
||||
{data.preferredFormat === null ? (
|
||||
{data.preferredBrowser === "app" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
@@ -176,18 +173,20 @@ export default function SettingsScreen() {
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() =>
|
||||
updateData({
|
||||
preferredFormat: ArchivedFormat.readability,
|
||||
preferredBrowser: "system",
|
||||
})
|
||||
}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<FileText
|
||||
<ExternalLink
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">Open reader view</Text>
|
||||
<Text className="text-base-content">
|
||||
System default browser
|
||||
</Text>
|
||||
</View>
|
||||
{data.preferredFormat === ArchivedFormat.readability ? (
|
||||
{data.preferredBrowser === "system" ? (
|
||||
<Check
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
@@ -230,7 +229,13 @@ export default function SettingsScreen() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
flex: 1,
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {
|
||||
flex: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
60
apps/mobile/app/(tabs)/tags/[id].tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { View, StyleSheet, Platform } from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import Links from "@/components/Links";
|
||||
|
||||
export default function LinksScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { search, id } = useLocalSearchParams<{
|
||||
search?: string;
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
const { links, data } = useLinks(
|
||||
{
|
||||
sort: 0,
|
||||
searchQueryString: decodeURIComponent(search ?? ""),
|
||||
tagId: Number(id),
|
||||
},
|
||||
auth
|
||||
);
|
||||
|
||||
const tags = useTags(auth);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
|
||||
|
||||
if (activeTag?.name)
|
||||
navigation?.setOptions?.({
|
||||
headerTitle: activeTag?.name,
|
||||
headerSearchBarOptions: {
|
||||
placeholder: `Search ${activeTag.name}`,
|
||||
},
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
<Links links={links} data={data} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
44
apps/mobile/app/(tabs)/tags/_layout.tsx
Normal file
44
apps/mobile/app/(tabs)/tags/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Tags",
|
||||
headerLargeTitle: true,
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect:
|
||||
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerSearchBarOptions: {
|
||||
placeholder: "Search Tags",
|
||||
autoCapitalize: "none",
|
||||
onChangeText: (e) => {
|
||||
router.setParams({
|
||||
search: encodeURIComponent(e.nativeEvent.text),
|
||||
});
|
||||
},
|
||||
headerIconColor: colorScheme === "dark" ? "white" : "black",
|
||||
},
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
92
apps/mobile/app/(tabs)/tags/index.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import TagListing from "@/components/TagListing";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
export default function TagsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { auth } = useAuthStore();
|
||||
const { search } = useLocalSearchParams<{ search?: string }>();
|
||||
|
||||
const tags = useTags(auth);
|
||||
|
||||
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filter =
|
||||
tags.data?.filter((e) =>
|
||||
e.name.includes(decodeURIComponent(search || ""))
|
||||
) || [];
|
||||
|
||||
setFilteredTags(filter);
|
||||
}, [search, tags.data]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
className="h-full bg-base-100"
|
||||
collapsable={false}
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{tags.isLoading ? (
|
||||
<View className="flex justify-center h-full items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={filteredTags}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={tags.isRefetching}
|
||||
onRefresh={() => tags.refetch()}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={tags.isRefetching}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => <TagListing tag={item} />}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: Platform.select({
|
||||
ios: {
|
||||
paddingBottom: 83,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
router,
|
||||
Stack,
|
||||
usePathname,
|
||||
useRootNavigationState,
|
||||
@@ -8,33 +9,39 @@ import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { useState, useEffect } from "react";
|
||||
import "../styles/global.css";
|
||||
import { SheetProvider } from "react-native-actions-sheet";
|
||||
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
|
||||
import "@/components/ActionSheets/Sheets";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { lightTheme, darkTheme } from "../lib/theme";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
Share,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useShareIntent } from "expo-share-intent";
|
||||
import useDataStore from "@/store/data";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 60 * 24,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Compass, Ellipsis } from "lucide-react-native";
|
||||
import { Chromium } from "@/components/ui/Icons";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
MobileAuth,
|
||||
} from "@linkwarden/types";
|
||||
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
|
||||
export default function RootLayout() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { hasShareIntent, shareIntent, error, resetShareIntent } =
|
||||
useShareIntent();
|
||||
const { updateData, setData, data } = useDataStore();
|
||||
@@ -50,20 +57,6 @@ export default function RootLayout() {
|
||||
setData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (auth.status === "unauthenticated") {
|
||||
queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
mmkvPersister.removeClient?.();
|
||||
|
||||
const CACHE_DIR =
|
||||
FileSystem.documentDirectory + "archivedData/readable/";
|
||||
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
|
||||
}
|
||||
})();
|
||||
}, [auth.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootNavState?.key) return;
|
||||
|
||||
@@ -114,91 +107,219 @@ export default function RootLayout() {
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
|
||||
>
|
||||
<KeyboardProvider>
|
||||
<SheetProvider>
|
||||
{!isLoading && (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
...Platform.select({
|
||||
android: {
|
||||
statusBarStyle: colorScheme === "dark" ? "light" : "dark",
|
||||
statusBarBackgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* <Stack.Screen name="(tabs)" /> */}
|
||||
<Stack.Screen
|
||||
name="links/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBackTitle: "Back",
|
||||
headerTitle: "",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
...Platform.select({
|
||||
android: {
|
||||
statusBarStyle:
|
||||
colorScheme === "light" ? "light" : "dark",
|
||||
statusBarBackgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["primary"],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
...Platform.select({
|
||||
android: {
|
||||
statusBarStyle:
|
||||
colorScheme === "light" ? "light" : "dark",
|
||||
statusBarBackgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["primary"],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="incoming"
|
||||
options={{
|
||||
navigationBarColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
)}
|
||||
</SheetProvider>
|
||||
</KeyboardProvider>
|
||||
</View>
|
||||
<RootComponent isLoading={isLoading} auth={auth} />
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const RootComponent = ({
|
||||
isLoading,
|
||||
auth,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
auth: MobileAuth;
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const updateLink = useUpdateLink(auth);
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
|
||||
const { tmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
|
||||
>
|
||||
<KeyboardProvider>
|
||||
<SheetProvider>
|
||||
<StatusBar
|
||||
style={colorScheme === "dark" ? "light" : "dark"}
|
||||
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* <Stack.Screen name="(tabs)" /> */}
|
||||
<Stack.Screen
|
||||
name="links/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBackTitle: "Back",
|
||||
headerTitle: "",
|
||||
headerTintColor: colorScheme === "dark" ? "white" : "black",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
headerRight: () => (
|
||||
<View className="flex-row gap-5">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (tmp.link) {
|
||||
if (tmp.link.url) {
|
||||
return Linking.openURL(tmp.link.url);
|
||||
} else {
|
||||
const format = getOriginalFormat(tmp.link);
|
||||
|
||||
return Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${tmp.link.id}?format=${format}`
|
||||
: tmp.link.url || ""
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<Compass
|
||||
size={21}
|
||||
color={
|
||||
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Chromium
|
||||
stroke={
|
||||
rawTheme[colorScheme as ThemeName]["base-content"]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity>
|
||||
<Ellipsis
|
||||
size={21}
|
||||
color={
|
||||
rawTheme[colorScheme as ThemeName][
|
||||
"base-content"
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content>
|
||||
{tmp.link?.url && (
|
||||
<DropdownMenu.Item
|
||||
key="share"
|
||||
onSelect={async () => {
|
||||
await Share.share({
|
||||
...(Platform.OS === "android"
|
||||
? { message: tmp.link?.url as string }
|
||||
: { url: tmp.link?.url as string }),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Share
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && tmp.user && (
|
||||
<DropdownMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={async () => {
|
||||
const isAlreadyPinned =
|
||||
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? true
|
||||
: false;
|
||||
await updateLink.mutateAsync({
|
||||
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
: [{ id: tmp.user?.id }]) as any,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? "Unpin Link"
|
||||
: "Pin Link"}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && (
|
||||
<DropdownMenu.Item
|
||||
key="edit-link"
|
||||
onSelect={() => {
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: {
|
||||
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Edit Link
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{tmp.link && (
|
||||
<DropdownMenu.Item
|
||||
key="delete-link"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Link",
|
||||
"Are you sure you want to delete this link? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteLink.mutate(
|
||||
tmp.link?.id as number,
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
// go back
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
Delete
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="incoming" />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
)}
|
||||
</SheetProvider>
|
||||
</KeyboardProvider>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import Animated, { SlideInDown } from "react-native-reanimated";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -43,7 +44,7 @@ export default function HomeScreen() {
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={100}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<Path
|
||||
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
@@ -51,11 +52,14 @@ export default function HomeScreen() {
|
||||
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
||||
/>
|
||||
</Svg>
|
||||
<View className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||
>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
onPress={() => router.push("/login")}
|
||||
onPress={() => router.navigate("/login")}
|
||||
>
|
||||
<Text className="text-white text-xl">Get Started</Text>
|
||||
</Button>
|
||||
@@ -65,7 +69,7 @@ export default function HomeScreen() {
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,172 +1,93 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
ActivityIndicator,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { View, ActivityIndicator, Text, Platform } from "react-native";
|
||||
import { WebView } from "react-native-webview";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import RenderHtml from "@linkwarden/react-native-render-html";
|
||||
import ElementNotSupported from "@/components/ElementNotSupported";
|
||||
import { decode } from "html-entities";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { CalendarDays, Link } from "lucide-react-native";
|
||||
|
||||
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/readable/";
|
||||
const htmlPath = (id: string) => `${CACHE_DIR}link_${id}.html`;
|
||||
|
||||
async function ensureCacheDir() {
|
||||
const info = await FileSystem.getInfoAsync(CACHE_DIR);
|
||||
if (!info.exists) {
|
||||
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
||||
}
|
||||
}
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import ReadableFormat from "@/components/Formats/ReadableFormat";
|
||||
import ImageFormat from "@/components/Formats/ImageFormat";
|
||||
import PdfFormat from "@/components/Formats/PdfFormat";
|
||||
import WebpageFormat from "@/components/Formats/WebpageFormat";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function LinkScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const { id, format } = useLocalSearchParams();
|
||||
const { data: user } = useUser(auth);
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [htmlContent, setHtmlContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { width } = useWindowDimensions();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
|
||||
|
||||
const { updateTmp } = useTmpStore();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
await ensureCacheDir();
|
||||
const htmlFile = htmlPath(id as string);
|
||||
if (link?.id && user?.id)
|
||||
updateTmp({
|
||||
link,
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [htmlInfo] = await Promise.all([FileSystem.getInfoAsync(htmlFile)]);
|
||||
return () =>
|
||||
updateTmp({
|
||||
link: null,
|
||||
});
|
||||
}, [link, user]);
|
||||
|
||||
if (format === "3" && htmlInfo.exists) {
|
||||
const rawHtml = await FileSystem.readAsStringAsync(htmlFile);
|
||||
setHtmlContent(rawHtml);
|
||||
setIsLoading(false);
|
||||
useEffect(() => {
|
||||
if (user?.id && link?.id && format) {
|
||||
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
||||
} else if (!url) {
|
||||
if (link?.url) {
|
||||
setUrl(link.url);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
if (net.isConnected) {
|
||||
await fetchLinkData();
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.id && link?.id && !url) {
|
||||
loadCacheOrFetch();
|
||||
}
|
||||
}, [user, link]);
|
||||
|
||||
async function fetchLinkData() {
|
||||
// readable
|
||||
if (link?.id && format === "3") {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${format}`;
|
||||
setUrl(apiUrl);
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
const html = (await response.json()).content;
|
||||
setHtmlContent(html);
|
||||
await FileSystem.writeAsStringAsync(htmlPath(id as string), html, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch HTML content", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// original
|
||||
else if (link?.id && !format && user && link.url) {
|
||||
setUrl(link.url);
|
||||
}
|
||||
|
||||
// other formats
|
||||
else if (link?.id && format) {
|
||||
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
|
||||
}
|
||||
}
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<>
|
||||
{format === "3" && htmlContent ? (
|
||||
<ScrollView
|
||||
className="flex-1 bg-base-100"
|
||||
contentContainerClassName="p-4"
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<Text className="text-2xl font-bold mb-2.5 text-base-content">
|
||||
{decode(link?.name || link?.description || link?.url || "")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 mb-2.5 pr-5"
|
||||
onPress={() => router.replace(`/links/${id}`)}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
|
||||
{link?.url}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="flex-row items-center gap-1 mb-2.5">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text className="text-base text-neutral">
|
||||
{new Date(
|
||||
(link?.importDate || link?.createdAt) as string
|
||||
).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="border-t border-neutral-content mt-2.5 mb-5" />
|
||||
|
||||
<RenderHtml
|
||||
contentWidth={width}
|
||||
source={{ html: htmlContent }}
|
||||
renderers={{
|
||||
table: () => (
|
||||
<ElementNotSupported
|
||||
onPress={() => router.replace(`/links/${id}`)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
tagsStyles={{
|
||||
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
||||
}}
|
||||
baseStyle={{
|
||||
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
|
||||
>
|
||||
{link?.id && Number(format) === ArchivedFormat.readability ? (
|
||||
<ReadableFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : link?.id &&
|
||||
(Number(format) === ArchivedFormat.jpeg ||
|
||||
Number(format) === ArchivedFormat.png) ? (
|
||||
<ImageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
format={Number(format)}
|
||||
/>
|
||||
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
|
||||
<PdfFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
|
||||
<WebpageFormat
|
||||
link={link as any}
|
||||
setIsLoading={(state) => setIsLoading(state)}
|
||||
/>
|
||||
) : url ? (
|
||||
<WebView
|
||||
className={isLoading ? "opacity-0" : "flex-1"}
|
||||
source={{
|
||||
uri: url,
|
||||
headers: format ? { Authorization: `Bearer ${auth.session}` } : {},
|
||||
headers:
|
||||
format || link?.type !== "url"
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {},
|
||||
}}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
@@ -185,6 +106,6 @@ export default function LinkScreen() {
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardStickyView,
|
||||
KeyboardToolbar,
|
||||
} from "react-native-keyboard-controller";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { auth, signIn } = useAuthStore();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [method, setMethod] = useState<"password" | "token">("password");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
user: "",
|
||||
@@ -55,10 +57,7 @@ export default function HomeScreen() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={62}
|
||||
contentContainerClassName="flex-col justify-end h-full bg-base-100 relative"
|
||||
>
|
||||
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
|
||||
<View className="flex-col justify-end h-full bg-primary relative">
|
||||
<View className="my-auto">
|
||||
<Image
|
||||
@@ -97,7 +96,7 @@ export default function HomeScreen() {
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={100}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<Path
|
||||
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
|
||||
@@ -105,7 +104,10 @@ export default function HomeScreen() {
|
||||
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
|
||||
/>
|
||||
</Svg>
|
||||
<View className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4"
|
||||
>
|
||||
{showInstanceField && (
|
||||
<Input
|
||||
className="w-full text-xl p-3 leading-tight h-12"
|
||||
@@ -161,12 +163,20 @@ export default function HomeScreen() {
|
||||
<Button
|
||||
variant="accent"
|
||||
size="lg"
|
||||
onPress={() => {
|
||||
isLoading={isLoading}
|
||||
onPress={async () => {
|
||||
if (
|
||||
((form.user && form.password) || form.token) &&
|
||||
form.instance
|
||||
) {
|
||||
signIn(form.user, form.password, form.instance, form.token);
|
||||
setIsLoading(true);
|
||||
await signIn(
|
||||
form.user,
|
||||
form.password,
|
||||
form.instance,
|
||||
form.token
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -178,9 +188,9 @@ export default function HomeScreen() {
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Need help?</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
</KeyboardStickyView>
|
||||
<KeyboardToolbar />
|
||||
</>
|
||||
);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
BIN
apps/mobile/assets/images/maskable_logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -1,4 +1,4 @@
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { Alert, Platform, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
@@ -7,6 +7,7 @@ import { useAddLink } from "@linkwarden/router/links";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function AddLinkSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
@@ -15,6 +16,8 @@ export default function AddLinkSheet() {
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
@@ -25,6 +28,7 @@ export default function AddLinkSheet() {
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
@@ -50,6 +54,7 @@ export default function AddLinkSheet() {
|
||||
}
|
||||
)
|
||||
}
|
||||
isLoading={addLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Alert } from "react-native";
|
||||
import { View, Text, Alert, Platform } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActionSheet, {
|
||||
FlatList,
|
||||
@@ -20,6 +20,8 @@ import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Folder, ChevronRight, Check } from "lucide-react-native";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -39,6 +41,8 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
}
|
||||
}, [params?.link]);
|
||||
|
||||
const { tmp, updateTmp } = useTmpStore();
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
@@ -111,6 +115,11 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
onPress={() =>
|
||||
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
|
||||
onSuccess: () => {
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -119,6 +128,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
},
|
||||
})
|
||||
}
|
||||
isLoading={editLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
@@ -246,6 +256,8 @@ const routes: Route[] = [
|
||||
export default function EditLinkSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
@@ -258,6 +270,7 @@ export default function EditLinkSheet() {
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
92
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
92
apps/mobile/components/ActionSheets/NewCollectionSheet.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Alert, Platform, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCreateCollection } from "@linkwarden/router/collections";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function NewCollectionSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
const { auth } = useAuthStore();
|
||||
const createCollection = useCreateCollection(auth);
|
||||
const [collection, setCollection] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
value={collection.name}
|
||||
onChangeText={(text) => setCollection({ ...collection, name: text })}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Description"
|
||||
className="mb-4 bg-base-100"
|
||||
value={collection.description}
|
||||
onChangeText={(text) =>
|
||||
setCollection({ ...collection, description: text })
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
createCollection.mutate(
|
||||
{ name: collection.name, description: collection.description },
|
||||
{
|
||||
onSuccess: () => {
|
||||
actionSheetRef.current?.hide();
|
||||
setCollection({ name: "", description: "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"There was an error creating the collection."
|
||||
);
|
||||
console.error("Error creating collection:", error);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
isLoading={createCollection.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-white">Save Collection</Text>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
actionSheetRef.current?.hide();
|
||||
setCollection({ name: "", description: "" });
|
||||
}}
|
||||
variant="outline"
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-base-content">Cancel</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ActionSheet>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
import SupportSheet from "./SupportSheet";
|
||||
import AddLinkSheet from "./AddLinkSheet";
|
||||
import EditLinkSheet from "./EditLinkSheet";
|
||||
import NewCollectionSheet from "./NewCollectionSheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
registerSheet("support-sheet", SupportSheet);
|
||||
registerSheet("add-link-sheet", AddLinkSheet);
|
||||
registerSheet("edit-link-sheet", EditLinkSheet);
|
||||
registerSheet("new-collection-sheet", NewCollectionSheet);
|
||||
|
||||
declare module "react-native-actions-sheet" {
|
||||
interface Sheets {
|
||||
@@ -29,6 +31,7 @@ declare module "react-native-actions-sheet" {
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
"new-collection-sheet": SheetDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function SupportSheet() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
@@ -13,11 +14,11 @@ export default function SupportSheet() {
|
||||
async function handleEmailPress() {
|
||||
await Clipboard.setStringAsync("support@linkwarden.app");
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
@@ -27,6 +28,7 @@ export default function SupportSheet() {
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
}}
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5 flex-col gap-4">
|
||||
<Text className="text-2xl font-bold text-base-content">Need help?</Text>
|
||||
|
||||
132
apps/mobile/components/CollectionListing.tsx
Normal file
132
apps/mobile/components/CollectionListing.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Folder, Link } from "lucide-react-native";
|
||||
import { useDeleteCollection } from "@linkwarden/router/collections";
|
||||
|
||||
type Props = {
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
};
|
||||
|
||||
const CollectionListing = ({ collection }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useDeleteCollection(auth);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
"bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => router.navigate(`/collections/${collection.id}`)}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View className="w-full">
|
||||
<View className="w-[90%] flex-col justify-between gap-3">
|
||||
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||
<Folder
|
||||
size={16}
|
||||
fill={collection.color || ""}
|
||||
color={collection.color || ""}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(collection.name)}
|
||||
</Text>
|
||||
</View>
|
||||
{collection.description && (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-light text-sm text-base-content"
|
||||
>
|
||||
{decode(collection.description)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{new Date(collection.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{collection._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-collection"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Collection",
|
||||
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(collection.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionListing;
|
||||
346
apps/mobile/components/DashboardSection.tsx
Normal file
346
apps/mobile/components/DashboardSection.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import {
|
||||
FlatList,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import DashboardItem from "@/components/DashboardItem";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import {
|
||||
Clock8,
|
||||
ChevronRight,
|
||||
Pin,
|
||||
Folder,
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
// Don't use prisma client's DashboardSectionType, it'll crash in production (React Native)
|
||||
type DashboardSectionType =
|
||||
| "STATS"
|
||||
| "RECENT_LINKS"
|
||||
| "PINNED_LINKS"
|
||||
| "COLLECTION";
|
||||
|
||||
type DashboardSectionProps = {
|
||||
sectionData: { type: DashboardSectionType };
|
||||
collection?: any;
|
||||
links?: any[];
|
||||
tagsLength: number;
|
||||
numberOfLinks: number;
|
||||
collectionsLength: number;
|
||||
numberOfPinnedLinks: number;
|
||||
dashboardData: {
|
||||
isLoading: boolean;
|
||||
refetch: Function;
|
||||
isRefetching: boolean;
|
||||
};
|
||||
collectionLinks?: any[];
|
||||
};
|
||||
|
||||
const DashboardSection: React.FC<DashboardSectionProps> = ({
|
||||
sectionData,
|
||||
collection,
|
||||
links = [],
|
||||
tagsLength,
|
||||
numberOfLinks,
|
||||
collectionsLength,
|
||||
numberOfPinnedLinks,
|
||||
dashboardData,
|
||||
collectionLinks = [],
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
switch (sectionData.type) {
|
||||
case "STATS":
|
||||
return (
|
||||
<View className="flex-col gap-4 max-w-full px-5">
|
||||
<View className="flex-row gap-4">
|
||||
<DashboardItem
|
||||
name={numberOfLinks === 1 ? "Link" : "Links"}
|
||||
value={numberOfLinks}
|
||||
icon={<Link size={23} color="white" />}
|
||||
color="#9c00cc"
|
||||
/>
|
||||
<DashboardItem
|
||||
name={collectionsLength === 1 ? "Collection" : "Collections"}
|
||||
value={collectionsLength}
|
||||
icon={<Folder size={23} color="white" fill="white" />}
|
||||
color="#0096cc"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row gap-4">
|
||||
<DashboardItem
|
||||
name={tagsLength === 1 ? "Tag" : "Tags"}
|
||||
value={tagsLength}
|
||||
icon={<Hash size={23} color="white" />}
|
||||
color="#00cc99"
|
||||
/>
|
||||
<DashboardItem
|
||||
name={"Pinned Links"}
|
||||
value={numberOfPinnedLinks}
|
||||
icon={<Pin size={23} color="white" fill="white" />}
|
||||
color="#cc6d00"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
case "RECENT_LINKS":
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center">
|
||||
<View className={"flex-row items-center gap-2"}>
|
||||
<Clock8
|
||||
size={30}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl capitalize text-base-content">
|
||||
Recent Links
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1"
|
||||
onPress={() => router.navigate("/(tabs)/dashboard/recent-links")}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading ||
|
||||
(links.length > 0 && !dashboardData.isLoading) ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
directionalLockEnabled
|
||||
data={links || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Clock8
|
||||
size={40}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
No Recent Links
|
||||
</Text>
|
||||
|
||||
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
|
||||
<Button onPress={() => setNewLinkModal(true)} variant="accent">
|
||||
<Icon name="bi-plus-lg" className="text-xl" />
|
||||
<Text>{t("add_link")}</Text>
|
||||
</Button>
|
||||
<ImportDropdown />
|
||||
</View> */}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case "PINNED_LINKS":
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center">
|
||||
<View className={"flex-row items-center gap-2"}>
|
||||
<Pin
|
||||
size={30}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl capitalize text-base-content">
|
||||
Pinned Links
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1"
|
||||
onPress={() => router.navigate("/(tabs)/dashboard/pinned-links")}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading ||
|
||||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Pin
|
||||
size={40}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
No Pinned Links
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
case "COLLECTION":
|
||||
return collection?.id ? (
|
||||
<>
|
||||
<View className="flex-row justify-between items-center px-5">
|
||||
<View className="flex-row gap-2 items-center max-w-[60%]">
|
||||
<View className={clsx("flex-row items-center gap-2")}>
|
||||
<Folder
|
||||
size={30}
|
||||
fill={collection.color || "#0ea5e9"}
|
||||
color={collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<Text
|
||||
className="text-2xl capitalize w-full text-base-content"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
|
||||
onPress={() =>
|
||||
router.navigate(
|
||||
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text className="text-primary">View All</Text>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{dashboardData.isLoading || collectionLinks.length > 0 ? (
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={collectionLinks || []}
|
||||
refreshing={dashboardData.isLoading}
|
||||
initialNumToRender={2}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (
|
||||
!dashboardData.isRefetching &&
|
||||
links.some((e) => e.id && !e.preview)
|
||||
) {
|
||||
dashboardData.refetch();
|
||||
}
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Empty Collection
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default DashboardSection;
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} dashboard />;
|
||||
}
|
||||
);
|
||||
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
136
apps/mobile/components/Formats/ImageFormat.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
import { Image, Platform, ScrollView } from "react-native";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
format: ArchivedFormat.png | ArchivedFormat.jpeg;
|
||||
};
|
||||
|
||||
export default function ImageFormat({ link, setIsLoading, format }: Props) {
|
||||
const FORMAT = format;
|
||||
|
||||
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [dimension, setDimension] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (content)
|
||||
Image.getSize(content, (width, height) => {
|
||||
setDimension({ width, height });
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/${extension}/link_${link.id}.${extension}`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
content &&
|
||||
dimension && (
|
||||
<ScrollView maximumZoomScale={10}>
|
||||
<Image
|
||||
source={{ uri: content }}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: dimension.width / dimension.height,
|
||||
}}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
else
|
||||
return (
|
||||
content && (
|
||||
<WebView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{
|
||||
baseUrl: content,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${content}" />
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
}}
|
||||
scalesPageToFit
|
||||
originWhitelist={["*"]}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
72
apps/mobile/components/Formats/PdfFormat.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import Pdf from "react-native-pdf";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function PdfFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.pdf;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<Pdf
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{ uri: content }}
|
||||
onLoadComplete={() => setIsLoading(false)}
|
||||
onPressLink={(uri) => {
|
||||
console.log(`Link pressed: ${uri}`);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
139
apps/mobile/components/Formats/ReadableFormat.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import RenderHtml from "@linkwarden/react-native-render-html";
|
||||
import ElementNotSupported from "@/components/ElementNotSupported";
|
||||
import { decode } from "html-entities";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { CalendarDays, Link } from "lucide-react-native";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ReadableFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.readability;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
const { width } = useWindowDimensions();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/readable/link_${link.id}.html`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
const rawContent = await FileSystem.readAsStringAsync(filePath);
|
||||
setContent(rawContent);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
const data = (await response.json()).content;
|
||||
setContent(data);
|
||||
await FileSystem.writeAsStringAsync(filePath, data, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<ScrollView
|
||||
className="flex-1 bg-base-100"
|
||||
contentContainerClassName="p-4"
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<Text className="text-2xl font-bold mb-2.5 text-base-content">
|
||||
{decode(link.name || link.description || link.url || "")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 mb-2.5 pr-5"
|
||||
onPress={() => router.replace(`/links/${link.id}`)}
|
||||
>
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
|
||||
{link.url}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="flex-row items-center gap-1 mb-2.5">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text className="text-base text-neutral">
|
||||
{new Date(link?.importDate || link.createdAt).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="border-t border-neutral-content mt-2.5 mb-5" />
|
||||
|
||||
<RenderHtml
|
||||
contentWidth={width}
|
||||
source={{ html: content }}
|
||||
renderers={{
|
||||
table: () => (
|
||||
<ElementNotSupported
|
||||
onPress={() => router.replace(`/links/${link.id}`)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onHTMLLoaded={() => setIsLoading(false)}
|
||||
tagsStyles={{
|
||||
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
|
||||
}}
|
||||
baseStyle={{
|
||||
color: rawTheme[colorScheme as ThemeName]["base-content"],
|
||||
}}
|
||||
/>
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
80
apps/mobile/components/Formats/WebpageFormat.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
type Props = {
|
||||
link: LinkType;
|
||||
setIsLoading: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export default function WebpageFormat({ link, setIsLoading }: Props) {
|
||||
const FORMAT = ArchivedFormat.monolith;
|
||||
|
||||
const { auth } = useAuthStore();
|
||||
const [content, setContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCacheOrFetch() {
|
||||
const filePath =
|
||||
FileSystem.documentDirectory +
|
||||
`archivedData/webpage/link_${link.id}.html`;
|
||||
|
||||
await FileSystem.makeDirectoryAsync(
|
||||
filePath.substring(0, filePath.lastIndexOf("/")),
|
||||
{
|
||||
intermediates: true,
|
||||
}
|
||||
).catch(() => {});
|
||||
|
||||
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
|
||||
|
||||
if (info.exists) {
|
||||
setContent(filePath);
|
||||
}
|
||||
|
||||
const net = await NetInfo.fetch();
|
||||
|
||||
if (net.isConnected) {
|
||||
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
|
||||
|
||||
try {
|
||||
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
|
||||
headers: { Authorization: `Bearer ${auth.session}` },
|
||||
});
|
||||
|
||||
setContent(result.uri);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch content", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheOrFetch();
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
content && (
|
||||
<WebView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
source={{
|
||||
uri: content,
|
||||
baseUrl: FileSystem.documentDirectory,
|
||||
}}
|
||||
scalesPageToFit
|
||||
originWhitelist={["*"]}
|
||||
mixedContentMode="always"
|
||||
javaScriptEnabled={true}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
onLoadEnd={() => setIsLoading(false)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import { View, Text, Image, Pressable, Platform, Alert } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Pressable,
|
||||
Platform,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
formatAvailable,
|
||||
@@ -18,6 +29,8 @@ import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Folder } from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
@@ -34,15 +47,17 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
|
||||
let shortendURL;
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (link.url) {
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
@@ -55,13 +70,27 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
dashboard && "rounded-xl"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
data.preferredFormat
|
||||
? `/links/${link.id}?format=${data.preferredFormat}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
if (user) {
|
||||
const format = getFormatBasedOnPreference({
|
||||
link,
|
||||
preference: user.linksRouteTo,
|
||||
});
|
||||
|
||||
data.preferredBrowser === "app"
|
||||
? router.navigate(
|
||||
format !== null
|
||||
? `/links/${link.id}?format=${format}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
: Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string)
|
||||
);
|
||||
}
|
||||
}}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
@@ -81,12 +110,12 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
{decode(link.name || link.description || link.url)}
|
||||
</Text>
|
||||
|
||||
{shortendURL && (
|
||||
{url && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-1.5 font-light text-sm text-base-content"
|
||||
>
|
||||
{shortendURL}
|
||||
{url}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -117,6 +146,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
}}
|
||||
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
|
||||
/>
|
||||
) : !link.preview ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
className="h-[60px] w-[90px]"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-[60px] w-[90px]" />
|
||||
)}
|
||||
@@ -144,21 +178,39 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="open-link"
|
||||
onSelect={() => router.push(`/links/${link.id}`)}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Open Link</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
key="open-original"
|
||||
onSelect={() => {
|
||||
if (link) {
|
||||
const format = getOriginalFormat(link);
|
||||
|
||||
{link.url && (
|
||||
<ContextMenu.Item
|
||||
key="copy-url"
|
||||
onSelect={async () => {
|
||||
await Clipboard.setStringAsync(link.url as string);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
data.preferredBrowser === "app"
|
||||
? router.navigate(
|
||||
format !== null
|
||||
? `/links/${link.id}?format=${format}`
|
||||
: `/links/${link.id}`
|
||||
)
|
||||
: Linking.openURL(
|
||||
format !== null
|
||||
? auth.instance +
|
||||
`/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string)
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Open Original</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
{link?.url && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
key="copy-url"
|
||||
onSelect={async () => {
|
||||
await Clipboard.setStringAsync(link.url as string);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenu.Item
|
||||
@@ -201,7 +253,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-webpage"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.monolith}`
|
||||
)
|
||||
}
|
||||
@@ -213,7 +265,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-screenshot"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${
|
||||
link.image?.endsWith(".png")
|
||||
? ArchivedFormat.png
|
||||
@@ -229,7 +281,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-pdf"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.pdf}`
|
||||
)
|
||||
}
|
||||
@@ -241,7 +293,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<ContextMenu.Item
|
||||
key="preserved-formats-readable"
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
router.navigate(
|
||||
`/links/${link.id}?format=${ArchivedFormat.readability}`
|
||||
)
|
||||
}
|
||||
@@ -268,7 +320,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteLink.mutate(link.id as number);
|
||||
deleteLink.mutate(link.id as number, {
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
87
apps/mobile/components/Links.tsx
Normal file
87
apps/mobile/components/Links.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
ViewToken,
|
||||
} from "react-native";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
const RenderItem = React.memo(
|
||||
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
|
||||
return <LinkListing link={item} />;
|
||||
}
|
||||
);
|
||||
|
||||
type Props = {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
data: any;
|
||||
};
|
||||
|
||||
export default function Links({ links, data }: Props) {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [promptedRefetch, setPromptedRefetch] = useState(false);
|
||||
|
||||
return data.isLoading ? (
|
||||
<View className="flex justify-center h-full items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={() => <></>}
|
||||
data={links || []}
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={data.isRefetching && promptedRefetch}
|
||||
onRefresh={async () => {
|
||||
setPromptedRefetch(true);
|
||||
await data.refetch();
|
||||
setPromptedRefetch(false);
|
||||
}}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
|
||||
/>
|
||||
}
|
||||
refreshing={data.isRefetching && promptedRefetch}
|
||||
initialNumToRender={4}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={({ item }) => (
|
||||
<RenderItem item={item} key={item.id?.toString()} />
|
||||
)}
|
||||
onEndReached={() => data.fetchNextPage()}
|
||||
onEndReachedThreshold={0.5}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View className="bg-neutral-content h-px" />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex justify-center py-10 items-center">
|
||||
<Text className="text-center text-xl text-neutral">
|
||||
Nothing found...
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
onViewableItemsChanged={({
|
||||
viewableItems,
|
||||
}: {
|
||||
viewableItems: ViewToken[];
|
||||
}) => {
|
||||
const links = viewableItems.map(
|
||||
(e) => e.item
|
||||
) as LinkIncludingShortenedCollectionAndTags[];
|
||||
|
||||
if (!data.isRefetching && links.some((e) => e.id && !e.preview))
|
||||
data.refetch();
|
||||
}}
|
||||
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
apps/mobile/components/TagListing.tsx
Normal file
120
apps/mobile/components/TagListing.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { CalendarDays, Hash, Link } from "lucide-react-native";
|
||||
import { useRemoveTag } from "@linkwarden/router/tags";
|
||||
|
||||
type Props = {
|
||||
tag: TagIncludingLinkCount;
|
||||
};
|
||||
|
||||
const TagListing = ({ tag }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useRemoveTag(auth);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger asChild>
|
||||
<Pressable
|
||||
className={cn(
|
||||
"p-5 flex-row justify-between",
|
||||
"bg-base-100",
|
||||
Platform.OS !== "android" && "active:bg-base-200/50"
|
||||
)}
|
||||
onLongPress={() => {}}
|
||||
onPress={() => router.navigate(`/tags/${tag.id}`)}
|
||||
android_ripple={{
|
||||
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
|
||||
borderless: false,
|
||||
}}
|
||||
>
|
||||
<View className="w-full">
|
||||
<View className="w-[90%] flex-col justify-between gap-3">
|
||||
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
|
||||
<Hash
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
className="font-medium text-lg text-base-content"
|
||||
>
|
||||
{decode(tag.name)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<CalendarDays
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{new Date(tag.createdAt).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row gap-1 items-center mt-5 self-start">
|
||||
<Link
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName]["neutral"]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="font-light text-xs text-base-content"
|
||||
>
|
||||
{tag._count?.links}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Content avoidCollisions>
|
||||
<ContextMenu.Item
|
||||
key="delete-tag"
|
||||
onSelect={() => {
|
||||
return Alert.alert(
|
||||
"Delete Tag",
|
||||
"Are you sure you want to delete this Tag? This action cannot be undone.",
|
||||
[
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteCollection.mutate(tag.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagListing;
|
||||
21
apps/mobile/components/ui/Icons.tsx
Normal file
21
apps/mobile/components/ui/Icons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
|
||||
|
||||
export const Chromium = (props: SvgProps) => (
|
||||
<Svg
|
||||
width={21}
|
||||
height={21}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<Path d="M10.88 21.94 15.46 14" />
|
||||
<Path d="M21.17 8H12" />
|
||||
<Path d="M3.95 6.06 8.54 14" />
|
||||
<Circle cx={12} cy={12} r={10} />
|
||||
<Circle cx={12} cy={12} r={4} />
|
||||
</Svg>
|
||||
);
|
||||
33
apps/mobile/lib/cache.ts
Normal file
33
apps/mobile/lib/cache.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
export const clearCache = async () => {
|
||||
await Promise.all([
|
||||
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
|
||||
idempotent: true,
|
||||
}),
|
||||
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
|
||||
idempotent: true,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
export const deleteLinkCache = async (linkId: number) => {
|
||||
const readablePath =
|
||||
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
|
||||
const webpagePath =
|
||||
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
|
||||
const jpegPath =
|
||||
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
|
||||
const pngPath =
|
||||
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
|
||||
const pdfPath =
|
||||
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
|
||||
|
||||
await Promise.all([
|
||||
FileSystem.deleteAsync(readablePath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(pngPath, { idempotent: true }),
|
||||
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
|
||||
]);
|
||||
};
|
||||
14
apps/mobile/lib/queryClient.ts
Normal file
14
apps/mobile/lib/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 60 * 24,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export { queryClient };
|
||||
@@ -55,11 +55,14 @@
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-actions-sheet": "^0.9.7",
|
||||
"react-native-blob-util": "^0.23.2",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-ios-context-menu": "3.1.3",
|
||||
"react-native-ios-utilities": "5.1.7",
|
||||
"react-native-keyboard-controller": "^1.19.0",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
"react-native-pdf": "^7.0.3",
|
||||
"react-native-reanimated": "3.16.2",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
|
||||
@@ -3,6 +3,12 @@ import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
import { MobileAuth } from "@linkwarden/types";
|
||||
import { Alert } from "react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { clearCache } from "@/lib/cache";
|
||||
|
||||
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/";
|
||||
|
||||
type AuthStore = {
|
||||
auth: MobileAuth;
|
||||
@@ -10,10 +16,10 @@ type AuthStore = {
|
||||
username: string,
|
||||
password: string,
|
||||
instance: string,
|
||||
token: string
|
||||
) => void;
|
||||
signOut: () => void;
|
||||
setAuth: () => void;
|
||||
token?: string
|
||||
) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
setAuth: () => Promise<void>;
|
||||
};
|
||||
|
||||
const useAuthStore = create<AuthStore>((set) => ({
|
||||
@@ -103,6 +109,12 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||
await SecureStore.deleteItemAsync("TOKEN");
|
||||
await SecureStore.deleteItemAsync("INSTANCE");
|
||||
|
||||
queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
mmkvPersister.removeClient?.();
|
||||
|
||||
await clearCache();
|
||||
|
||||
set({
|
||||
auth: {
|
||||
instance: "",
|
||||
|
||||
@@ -16,7 +16,7 @@ const useDataStore = create<DataStore>((set, get) => ({
|
||||
url: "",
|
||||
},
|
||||
theme: "light",
|
||||
preferredFormat: null,
|
||||
preferredBrowser: "app",
|
||||
},
|
||||
setData: async () => {
|
||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||
|
||||
26
apps/mobile/store/tmp.ts
Normal file
26
apps/mobile/store/tmp.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { User } from "@linkwarden/prisma/client";
|
||||
|
||||
type Tmp = {
|
||||
link: LinkIncludingShortenedCollectionAndTags | null;
|
||||
user: Pick<User, "id"> | null;
|
||||
};
|
||||
|
||||
type TmpStore = {
|
||||
tmp: Tmp;
|
||||
updateTmp: (newData: Partial<Tmp>) => void;
|
||||
};
|
||||
|
||||
const useTmpStore = create<TmpStore>((set, get) => ({
|
||||
tmp: {
|
||||
link: null,
|
||||
user: null,
|
||||
},
|
||||
updateTmp: async (patch) => {
|
||||
const merged = { ...get().tmp, ...patch };
|
||||
set({ tmp: merged });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useTmpStore;
|
||||
@@ -85,16 +85,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
|
||||
@@ -102,16 +102,6 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
|
||||
@@ -104,16 +104,6 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
try {
|
||||
if (link.url) {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function LinkTypeBadge({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
let shortendURL;
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
if (link.type === "url" && link.url) {
|
||||
try {
|
||||
shortendURL = new URL(link.url).host.toLowerCase();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
useEffect(() => {
|
||||
if (link.type === "url" && link.url) {
|
||||
try {
|
||||
setUrl(new URL(link.url).host.toLowerCase());
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [link]);
|
||||
|
||||
const typeIcon = () => {
|
||||
switch (link.type) {
|
||||
@@ -27,7 +30,7 @@ export default function LinkTypeBadge({
|
||||
}
|
||||
};
|
||||
|
||||
return link.url && shortendURL ? (
|
||||
return link.url && url ? (
|
||||
<Link
|
||||
href={link.url || ""}
|
||||
target="_blank"
|
||||
@@ -38,7 +41,7 @@ export default function LinkTypeBadge({
|
||||
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
|
||||
>
|
||||
<i className="bi-link-45deg text-lg leading-none"></i>
|
||||
<p className="text-xs truncate">{shortendURL}</p>
|
||||
<p className="text-xs truncate">{url}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex gap-1 item-center select-none text-neutral duration-100 max-w-full w-fit">
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
AccountSettings,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { generateLinkHref } from "@linkwarden/lib/generateLinkHref";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||
import { LinksRouteTo } from "@linkwarden/prisma/client";
|
||||
|
||||
const openLink = (
|
||||
@@ -13,7 +10,17 @@ const openLink = (
|
||||
if (user.linksRouteTo === LinksRouteTo.DETAILS) {
|
||||
openModal();
|
||||
} else {
|
||||
window.open(generateLinkHref(link, user), "_blank");
|
||||
const format = getFormatBasedOnPreference({
|
||||
link,
|
||||
preference: user.linksRouteTo,
|
||||
});
|
||||
|
||||
window.open(
|
||||
format !== null
|
||||
? `/preserved/${link?.id}?format=${format}`
|
||||
: (link.url as string),
|
||||
"_blank"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ArchivedFormat } from "@linkwarden/types";
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
responseLimit: "50mb",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
AccountSettings,
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { LinksRouteTo } from "@linkwarden/prisma/client";
|
||||
import { formatAvailable } from "@linkwarden/lib/formatStats";
|
||||
|
||||
export const generateLinkHref = (
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
account: AccountSettings,
|
||||
instanceURL: string | null = "",
|
||||
apiEndpoint: boolean = false
|
||||
): string => {
|
||||
// Return the links href based on the account's preference
|
||||
// If the user's preference is not available, return the original link
|
||||
|
||||
let endpoint = "/preserved";
|
||||
if (apiEndpoint) {
|
||||
endpoint = "/api/v1/archives";
|
||||
}
|
||||
|
||||
if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
|
||||
return link.url || "";
|
||||
} else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
|
||||
if (!formatAvailable(link, "pdf")) return link.url || "";
|
||||
|
||||
return instanceURL + `${endpoint}/${link?.id}?format=${ArchivedFormat.pdf}`;
|
||||
} else if (
|
||||
account.linksRouteTo === LinksRouteTo.READABLE &&
|
||||
link.type === "url"
|
||||
) {
|
||||
if (!formatAvailable(link, "readable")) return link.url || "";
|
||||
|
||||
return (
|
||||
instanceURL +
|
||||
`${endpoint}/${link?.id}?format=${ArchivedFormat.readability}`
|
||||
);
|
||||
} else if (
|
||||
account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
|
||||
link.type === "image"
|
||||
) {
|
||||
if (!formatAvailable(link, "image")) return link.url || "";
|
||||
|
||||
return (
|
||||
instanceURL +
|
||||
`${endpoint}/${link?.id}?format=${
|
||||
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
|
||||
}`
|
||||
);
|
||||
} else if (account.linksRouteTo === LinksRouteTo.MONOLITH) {
|
||||
if (!formatAvailable(link, "monolith")) return link.url || "";
|
||||
|
||||
return (
|
||||
instanceURL + `${endpoint}/${link?.id}?format=${ArchivedFormat.monolith}`
|
||||
);
|
||||
} else {
|
||||
return link.url || "";
|
||||
}
|
||||
};
|
||||
50
packages/lib/getFormatBasedOnPreference.ts
Normal file
50
packages/lib/getFormatBasedOnPreference.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
|
||||
// Don't use prisma client's LinksRouteTo, it'll crash in production (React Native)
|
||||
type LinksRouteTo =
|
||||
| "ORIGINAL"
|
||||
| "PDF"
|
||||
| "READABLE"
|
||||
| "MONOLITH"
|
||||
| "SCREENSHOT"
|
||||
| "DETAILS";
|
||||
|
||||
const getFormatBasedOnPreference = ({
|
||||
link,
|
||||
preference,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
preference: LinksRouteTo;
|
||||
}) => {
|
||||
// Return the format based on the account's preference
|
||||
// If the user's preference is not available, return null (original url)
|
||||
|
||||
if (preference === "ORIGINAL" && link.type === "url") {
|
||||
return null;
|
||||
} else if (preference === "PDF" || link.type === "pdf") {
|
||||
if (!link.pdf || link.pdf === "unavailable") return null;
|
||||
|
||||
return ArchivedFormat.pdf;
|
||||
} else if (preference === "READABLE" && link.type === "url") {
|
||||
if (!link.readable || link.readable === "unavailable") return null;
|
||||
|
||||
return ArchivedFormat.readability;
|
||||
} else if (preference === "SCREENSHOT" || link.type === "image") {
|
||||
if (!link.image || link.image === "unavailable") return null;
|
||||
|
||||
return link?.image?.endsWith("png")
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg;
|
||||
} else if (preference === "MONOLITH") {
|
||||
if (!link.monolith || link.monolith === "unavailable") return null;
|
||||
|
||||
return ArchivedFormat.monolith;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default getFormatBasedOnPreference;
|
||||
17
packages/lib/getOriginalFormat.ts
Normal file
17
packages/lib/getOriginalFormat.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
|
||||
const getOriginalFormat = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
): ArchivedFormat | null => {
|
||||
if (link.type === "pdf") return ArchivedFormat.pdf;
|
||||
else if (link.type === "image")
|
||||
return link.image?.endsWith("png")
|
||||
? ArchivedFormat.png
|
||||
: ArchivedFormat.jpeg;
|
||||
else return null;
|
||||
};
|
||||
|
||||
export default getOriginalFormat;
|
||||
@@ -37,18 +37,24 @@ const useCollections = (auth?: MobileAuth) => {
|
||||
});
|
||||
};
|
||||
|
||||
const useCreateCollection = () => {
|
||||
const useCreateCollection = (auth?: MobileAuth) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: any) => {
|
||||
const response = await fetch("/api/v1/collections", {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const response = await fetch(
|
||||
(auth?.instance ? auth?.instance : "") + "/api/v1/collections",
|
||||
{
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(auth?.session
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {}),
|
||||
},
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -103,17 +109,23 @@ const useUpdateCollection = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const useDeleteCollection = () => {
|
||||
const useDeleteCollection = (auth?: MobileAuth) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(`/api/v1/collections/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const response = await fetch(
|
||||
(auth?.instance ? auth?.instance : "") + `/api/v1/collections/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(auth?.session
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -111,14 +111,22 @@ const useUpsertTags = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const useRemoveTag = () => {
|
||||
const useRemoveTag = (auth?: MobileAuth) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (tagId: number) => {
|
||||
const response = await fetch(`/api/v1/tags/${tagId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const response = await fetch(
|
||||
(auth?.instance ? auth?.instance : "") + `/api/v1/tags/${tagId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
...(auth?.session
|
||||
? { Authorization: `Bearer ${auth.session}` }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.response);
|
||||
|
||||
@@ -186,5 +186,5 @@ export interface MobileData {
|
||||
url: string;
|
||||
};
|
||||
theme: "light" | "dark" | "system";
|
||||
preferredFormat: ArchivedFormat | null;
|
||||
preferredBrowser: "app" | "system";
|
||||
}
|
||||
|
||||
49
yarn.lock
49
yarn.lock
@@ -4026,6 +4026,11 @@
|
||||
hermes-parser "0.23.1"
|
||||
nullthrows "^1.1.1"
|
||||
|
||||
"@react-native/normalize-color@*":
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-native/normalize-color/-/normalize-color-2.1.0.tgz#939b87a9849e81687d3640c5efa2a486ac266f91"
|
||||
integrity sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==
|
||||
|
||||
"@react-native/normalize-colors@0.76.8":
|
||||
version "0.76.8"
|
||||
resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.76.8.tgz#79380c178ec7437f4857bebeb860ee97bb069318"
|
||||
@@ -5557,6 +5562,11 @@ balanced-match@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base-64@0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb"
|
||||
integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==
|
||||
|
||||
base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
@@ -6312,6 +6322,11 @@ crypt@0.0.2:
|
||||
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
|
||||
integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==
|
||||
|
||||
crypto-js@4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
|
||||
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
|
||||
|
||||
crypto-random-string@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
|
||||
@@ -6623,6 +6638,15 @@ depd@~1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
|
||||
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
|
||||
|
||||
deprecated-react-native-prop-types@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz#c10c6ee75ff2b6de94bb127f142b814e6e08d9ab"
|
||||
integrity sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==
|
||||
dependencies:
|
||||
"@react-native/normalize-color" "*"
|
||||
invariant "*"
|
||||
prop-types "*"
|
||||
|
||||
dequal@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
|
||||
@@ -8667,7 +8691,7 @@ internal-slot@^1.0.3, internal-slot@^1.0.4:
|
||||
has "^1.0.3"
|
||||
side-channel "^1.0.4"
|
||||
|
||||
invariant@2.2.4, invariant@^2.2.4:
|
||||
invariant@*, invariant@2.2.4, invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
||||
@@ -11459,7 +11483,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
prop-types@*, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@@ -11689,6 +11713,14 @@ react-native-actions-sheet@^0.9.7:
|
||||
resolved "https://registry.yarnpkg.com/react-native-actions-sheet/-/react-native-actions-sheet-0.9.7.tgz#43ff2d9252f7af2da6dc051be5a5c375a844cdfb"
|
||||
integrity sha512-rjUwxUr5dxbdSLDtLDUFAdSlFxpNSpJsbXLhHkBzEBMxEMPUhRT3zqbvKqsPj0JUkjwuRligxrhbIJZkg/6ZDw==
|
||||
|
||||
react-native-blob-util@^0.23.2:
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-blob-util/-/react-native-blob-util-0.23.2.tgz#a5464947a4624a5def458e2dcbd0fae98f64da7b"
|
||||
integrity sha512-ZsUUFQYyZ7BI57c31XdPCkPlteoH7+PvcVy2w6wh1OPSUWGtKL79pj7fa6MepMX0v87fn0V9Heq0n6OjEpLdCw==
|
||||
dependencies:
|
||||
base-64 "0.1.0"
|
||||
glob "^10.3.10"
|
||||
|
||||
react-native-css-interop@0.1.22:
|
||||
version "0.1.22"
|
||||
resolved "https://registry.yarnpkg.com/react-native-css-interop/-/react-native-css-interop-0.1.22.tgz#70cc6ca7a8f14126e123e44a19ceed74dd2a167a"
|
||||
@@ -11701,6 +11733,11 @@ react-native-css-interop@0.1.22:
|
||||
lightningcss "^1.27.0"
|
||||
semver "^7.6.3"
|
||||
|
||||
react-native-edge-to-edge@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-edge-to-edge/-/react-native-edge-to-edge-1.7.0.tgz#6999ee86febe920b9b4e730ec1f0eaf0c28f0003"
|
||||
integrity sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ==
|
||||
|
||||
react-native-gesture-handler@~2.20.2:
|
||||
version "2.20.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz#73844c8e9c417459c2f2981bc4d8f66ba8a5ee66"
|
||||
@@ -11754,6 +11791,14 @@ react-native-mmkv@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.2.0.tgz#460723eb23b9cc92c65b0416eae9874a6ddf5b82"
|
||||
integrity sha512-9y7K//1MaU46TFrXkyU0wT5VGk9Y2FDLFV6NPl+z3jTbm2G7SC1UIxneF6CfhSmBX5CfKze9SO7kwDuRx7flpQ==
|
||||
|
||||
react-native-pdf@^7.0.3:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/react-native-pdf/-/react-native-pdf-7.0.3.tgz#f72691ac84014f2886e79ebbaf1b28a03ba8b8e9"
|
||||
integrity sha512-zDtF6CGXPAfGptQZqX7LQK3CVQrIGsD+rYuBnMK0sVmd8mrq7ciwmWXINT+d92emMtZ7+PLnx1IQZIdsh0fphA==
|
||||
dependencies:
|
||||
crypto-js "4.2.0"
|
||||
deprecated-react-native-prop-types "^2.3.0"
|
||||
|
||||
react-native-reanimated@3.16.2:
|
||||
version "3.16.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.16.2.tgz#8ae2d632cbf02174ce3ef1329b3ce6e3cfe46aa2"
|
||||
|
||||
Reference in New Issue
Block a user