Compare commits

..

1 Commits
dev ... main

Author SHA1 Message Date
Daniel
c7ab767872 Merge pull request #1575 from linkwarden/dev
Dev
2026-01-05 18:09:36 +03:30
192 changed files with 1512 additions and 4435 deletions

View File

@@ -3,10 +3,8 @@
<h1>Linkwarden</h1>
<h3>Bookmarks, Evolved</h3>
<a href="https://trendshift.io/repositories/4006" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4006" alt="linkwarden%2Flinkwarden | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=43856801"><img src="https://img.shields.io/badge/Hacker%20News-301-%23FF6600"></img></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
<a href="https://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
<a href="https://crowdin.com/project/linkwarden">

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Linkwarden",
"slug": "linkwarden",
"version": "1.1.1",
"version": "1.0.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "linkwarden",
@@ -53,9 +53,7 @@
[
"expo-share-intent",
{
"iosAppGroupIdentifier": "group.app.linkwarden",
"iosActivationRules": "SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.url\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.text\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.plain-text\")).@count > 0).@count > 0",
"androidIntentFilters": ["text/*"]
"iosAppGroupIdentifier": "group.app.linkwarden"
}
],
[

View File

@@ -14,7 +14,7 @@ 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/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
export default function CollectionsScreen() {
const { colorScheme } = useColorScheme();

View File

@@ -19,7 +19,6 @@ export default function Layout() {
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerBackTitle: "Back",
headerStyle: {
backgroundColor:
Platform.OS === "ios"
@@ -29,15 +28,6 @@ export default function Layout() {
: "white",
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen
name="preferredCollection"
options={{
headerTitle: "Preferred Collection",
headerLargeTitle: false,
}}
/>
</Stack>
/>
);
}

View File

@@ -16,9 +16,7 @@ import { useEffect, useState } from "react";
import {
AppWindowMac,
Check,
ChevronRight,
ExternalLink,
Folder,
LogOut,
Mail,
Moon,
@@ -27,7 +25,6 @@ import {
} from "lucide-react-native";
import useDataStore from "@/store/data";
import * as Clipboard from "expo-clipboard";
import { useRouter } from "expo-router";
export default function SettingsScreen() {
const { signOut, auth } = useAuthStore();
@@ -43,8 +40,6 @@ export default function SettingsScreen() {
updateData({ theme: override });
}, [override]);
const router = useRouter();
return (
<View
style={styles.container}
@@ -201,33 +196,6 @@ export default function SettingsScreen() {
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Save Shared Links To</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={() => router.navigate("/settings/preferredCollection")}
>
<View className="flex-row items-center gap-2">
<Folder
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">Preferred collection</Text>
</View>
<View className="flex-row items-center gap-2">
<Text numberOfLines={1} className="text-neutral max-w-[140px]">
{data.preferredCollection?.name || "None"}
</Text>
<ChevronRight
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
</View>
</TouchableOpacity>
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Contact Us</Text>
<View className="bg-base-200 rounded-xl flex-col">

View File

@@ -1,99 +0,0 @@
import { View, Text, FlatList, TouchableOpacity } from "react-native";
import React, { useCallback, useMemo, useState } from "react";
import useAuthStore from "@/store/auth";
import useDataStore from "@/store/data";
import { useCollections } from "@linkwarden/router/collections";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import Input from "@/components/ui/Input";
import { Folder, Check } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
const PreferredCollectionScreen = () => {
const { auth } = useAuthStore();
const { data, updateData } = useDataStore();
const collections = useCollections(auth);
const { colorScheme } = useColorScheme();
const [searchQuery, setSearchQuery] = useState("");
const filteredCollections = useMemo(() => {
if (!collections.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return collections.data;
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
}, [collections.data, searchQuery]);
const renderCollection = useCallback(
({
item: collection,
}: {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const isSelected = data.preferredCollection?.id === collection.id;
return (
<TouchableOpacity
className="bg-base-200 rounded-lg px-4 py-3 mb-3 flex-row items-center justify-between"
onPress={() => updateData({ preferredCollection: collection })}
>
<View className="flex-row items-center gap-2 w-[70%]">
<Folder
size={20}
fill={collection.color || "gray"}
color={collection.color || "gray"}
/>
<Text numberOfLines={1} className="text-base-content">
{collection.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{isSelected ? (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
<Text className="text-neutral">
{collection._count?.links ?? 0}
</Text>
</View>
</TouchableOpacity>
);
},
[colorScheme, data.preferredCollection?.id, updateData]
);
return (
<View className="flex-1 bg-base-100">
<FlatList
data={filteredCollections}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={renderCollection}
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 20,
}}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Input
placeholder="Search collections"
className="mb-4 bg-base-200 h-10"
value={searchQuery}
onChangeText={setSearchQuery}
/>
}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No collections match {searchQuery}
</Text>
}
/>
</View>
);
};
export default PreferredCollectionScreen;

View File

@@ -13,7 +13,7 @@ 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/global";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { useTags } from "@linkwarden/router/tags";
export default function TagsScreen() {

View File

@@ -33,7 +33,7 @@ import useTmpStore from "@/store/tmp";
import {
LinkIncludingShortenedCollectionAndTags,
MobileAuth,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { deleteLinkCache } from "@/lib/cache";
import { queryClient } from "@/lib/queryClient";
@@ -120,8 +120,8 @@ const RootComponent = ({
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink({ auth, Alert });
const deleteLink = useDeleteLink({ auth, Alert });
const updateLink = useUpdateLink(auth);
const deleteLink = useDeleteLink(auth);
const { tmp } = useTmpStore();
@@ -229,12 +229,12 @@ const RootComponent = ({
{tmp.link && tmp.user && (
<DropdownMenu.Item
key="pin-link"
onSelect={() => {
onSelect={async () => {
const isAlreadyPinned =
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
? true
: false;
updateLink.mutateAsync({
await updateLink.mutateAsync({
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
@@ -282,15 +282,18 @@ const RootComponent = ({
{
text: "Delete",
style: "destructive",
onPress: async () => {
onPress: () => {
deleteLink.mutate(
tmp.link?.id as number
tmp.link?.id as number,
{
onSuccess: async () => {
await deleteLinkCache(
tmp.link?.id as number
);
},
}
);
await deleteLinkCache(
tmp.link?.id as number
);
// go back
router.back();
},
},

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import {
SafeAreaView,
View,
Text,
StyleSheet,
ActivityIndicator,
Alert,
TouchableOpacity,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
import useAuthStore from "@/store/auth";
@@ -14,29 +14,20 @@ import { Check } from "lucide-react-native";
import { useAddLink } from "@linkwarden/router/links";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { SheetManager } from "react-native-actions-sheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
export default function IncomingScreen() {
const { auth } = useAuthStore();
const router = useRouter();
const { data, updateData } = useDataStore();
const addLink = useAddLink({ auth });
const addLink = useAddLink(auth);
const { colorScheme } = useColorScheme();
const [showSuccess, setShowSuccess] = useState(false);
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (auth.status === "authenticated" && data.shareIntent.url)
addLink.mutate(
{ url: data.shareIntent.url },
{
url: data.shareIntent.url,
collection: { id: data.preferredCollection?.id },
},
{
onSuccess: (e) => {
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
setShowSuccess(true);
onSuccess: () => {
setTimeout(() => {
updateData({
shareIntent: {
@@ -45,7 +36,7 @@ export default function IncomingScreen() {
},
});
router.replace("/dashboard");
}, 1500);
}, 1000);
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
@@ -59,39 +50,49 @@ export default function IncomingScreen() {
return (
<SafeAreaView className="flex-1 bg-base-100">
<View className="flex-1 items-center justify-center">
{data?.shareIntent.url && showSuccess && link ? (
<>
<Check
size={140}
className="mb-3 text-base-content"
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl font-semibold text-base-content">
Link Saved!
</Text>
<TouchableOpacity
className="w-fit mx-auto mt-5"
onPress={() =>
SheetManager.show("edit-link-sheet", {
payload: {
link: link,
},
})
}
>
<Text className="text-neutral text-center w-fit">Edit Link</Text>
</TouchableOpacity>
</>
) : (
<>
<ActivityIndicator size="large" />
<Text className="mt-3 text-base text-base-content opacity-70">
One sec
</Text>
</>
)}
</View>
{data?.shareIntent.url ? (
<View className="flex-1 items-center justify-center">
<Check
size={140}
className="mb-3 text-base-content"
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl font-semibold text-base-content">
Link Saved!
</Text>
</View>
) : (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" />
<Text className="mt-3 text-base text-base-content opacity-70">
One sec {String(data?.shareIntent.url)}
</Text>
</View>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
check: {
marginBottom: 12,
},
title: {
fontSize: 28,
fontWeight: "600",
},
subtitle: {
marginTop: 12,
fontSize: 16,
opacity: 0.7,
},
});

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { useGetLink } from "@linkwarden/router/links";
import useTmpStore from "@/store/tmp";
import { ArchivedFormat } from "@linkwarden/types/global";
import { ArchivedFormat } from "@linkwarden/types";
import ReadableFormat from "@/components/Formats/ReadableFormat";
import ImageFormat from "@/components/Formats/ImageFormat";
import PdfFormat from "@/components/Formats/PdfFormat";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 241 KiB

View File

@@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const addLink = useAddLink({ auth, Alert });
const addLink = useAddLink(auth);
const [link, setLink] = useState("");
const { colorScheme } = useColorScheme();
@@ -23,7 +23,7 @@ export default function AddLinkSheet() {
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
display: "none",
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
@@ -31,10 +31,6 @@ export default function AddLinkSheet() {
safeAreaInsets={insets}
>
<View className="px-8 py-5">
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
New Link
</Text>
<Input
placeholder="e.g. https://example.com"
className="mb-4 bg-base-100"
@@ -43,12 +39,21 @@ export default function AddLinkSheet() {
/>
<Button
onPress={() => {
addLink.mutate({ url: link });
actionSheetRef.current?.hide();
setLink("");
}}
onPress={() =>
addLink.mutate(
{ url: link },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setLink("");
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
)
}
isLoading={addLink.isPending}
variant="accent"
className="mb-2"

View File

@@ -1,4 +1,4 @@
import { View, Text, Alert, TouchableOpacity } from "react-native";
import { View, Text, Alert } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActionSheet, {
FlatList,
@@ -15,15 +15,13 @@ import useAuthStore from "@/store/auth";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
TagIncludingLinkCount,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { useCollections } from "@linkwarden/router/collections";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { Folder, ChevronRight, ChevronLeft, Check } from "lucide-react-native";
import { Folder, ChevronRight, Check } from "lucide-react-native";
import useTmpStore from "@/store/tmp";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTags } from "@linkwarden/router/tags";
const Main = (props: SheetProps<"edit-link-sheet">) => {
const { auth } = useAuthStore();
@@ -33,7 +31,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
const [link, setLink] = useState<
LinkIncludingShortenedCollectionAndTags | undefined
>(props.payload?.link);
const updateLink = useUpdateLink({ auth, Alert });
const editLink = useUpdateLink(auth);
const router = useSheetRouter("edit-link-sheet");
const { colorScheme } = useColorScheme();
@@ -47,10 +45,6 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
return (
<View className="px-8 py-5">
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
Edit Link
</Text>
<Input
placeholder="Name"
className="mb-4 bg-base-100"
@@ -88,29 +82,23 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
/>
</Button>
<Button
variant="input"
className="mb-4 h-auto"
onPress={() => router?.navigate("tags", { link })}
>
{/* <Button variant="input" className="mb-4 h-auto">
{link?.tags && link?.tags.length > 0 ? (
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
{link.tags.map((tag) => (
<View
key={tag.id}
className="bg-neutral rounded-md h-7 px-2 py-1"
className="bg-gray-200 rounded-md h-7 px-2 py-1"
>
<Text numberOfLines={1} className="text-base-100">
{tag.name}
</Text>
<Text numberOfLines={1}>{tag.name}</Text>
</View>
))}
</View>
) : (
<Text className="text-neutral">No tags</Text>
<Text className="text-gray-500">No tags</Text>
)}
<ChevronRight size={16} color={"gray"} />
</Button>
</Button> */}
<Input
multiline
@@ -124,15 +112,23 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
/>
<Button
onPress={() => {
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
}}
isLoading={updateLink.isPending}
onPress={() =>
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
onSuccess: () => {
if (link && tmp.link)
updateTmp({
link,
});
SheetManager.hide("edit-link-sheet");
},
onError: (error) => {
Alert.alert("Error", "There was an error editing the link.");
console.error("Error editing link:", error);
},
})
}
isLoading={editLink.isPending}
variant="accent"
className="mb-2"
>
@@ -154,7 +150,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
const Collections = () => {
const { auth } = useAuthStore();
const addLink = useAddLink({ auth });
const addLink = useAddLink(auth);
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const { link: currentLink } = useSheetRouteParams<
@@ -179,11 +175,13 @@ const Collections = () => {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const onSelect = () => {
// 1. Create a brand-new link object with the new collection
const updatedLink = {
...currentLink,
...currentLink!,
collection,
};
// 2. Navigate back to "main", passing the updated link as payload
router?.popToTop();
router?.navigate("main", { link: updatedLink });
};
@@ -218,32 +216,16 @@ const Collections = () => {
);
return (
<View className="py-5 max-h-[80vh]">
<TouchableOpacity
className="flex-row items-center gap-1 top-6 left-8 absolute"
onPress={() => {
router?.popToTop();
router?.navigate("main", { link: currentLink });
}}
>
<ChevronLeft
size={18}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text className="text-primary">Back</Text>
</TouchableOpacity>
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
Collection
</Text>
<View className="px-8 py-5 max-h-[80vh]">
<Input
placeholder="Search collections"
className="mb-4 bg-base-100 mx-8"
className="mb-4 bg-base-100"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={[...filteredCollections]}
data={filteredCollections}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
@@ -254,106 +236,7 @@ const Collections = () => {
No collections match {searchQuery}
</Text>
}
contentContainerClassName="px-8"
/>
</View>
);
};
const Tags = () => {
const { auth } = useAuthStore();
const addLink = useAddLink({ auth });
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const params = useSheetRouteParams("edit-link-sheet", "tags");
const tags = useTags(auth);
const { colorScheme } = useColorScheme();
const [updatedLink, setUpdatedLink] =
useState<LinkIncludingShortenedCollectionAndTags>(params.link);
const filteredTags = useMemo(() => {
if (!tags.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return tags.data;
return tags.data.filter((tag) => tag.name.toLowerCase().includes(q));
}, [tags.data, searchQuery]);
const renderItem = useCallback(
({ item: tag }: { item: TagIncludingLinkCount }) => {
const onSelect = () => {
const isSelected = (updatedLink?.tags || []).some(
(t) => t.id === tag.id
);
const nextTags = isSelected
? (updatedLink?.tags || []).filter((t) => t.id !== tag.id)
: [...(updatedLink?.tags || []), tag];
setUpdatedLink({
...updatedLink,
tags: nextTags,
});
};
return (
<Button variant="input" className="mb-2" onPress={onSelect}>
<View className="flex-row items-center gap-2 w-[75%]">
<Text numberOfLines={1} className="w-full text-base-content">
{tag.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{updatedLink?.tags.find((e) => e.id === tag.id) && (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
)}
<Text className="text-neutral">{tag._count?.links ?? 0}</Text>
</View>
</Button>
);
},
[addLink, params.link, router]
);
return (
<View className="py-5 max-h-[80vh]">
<TouchableOpacity
className="flex-row items-center gap-1 top-6 left-8 absolute"
onPress={() => {
router?.popToTop();
router?.navigate("main", { link: updatedLink });
}}
>
<ChevronLeft
size={18}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text className="text-primary">Back</Text>
</TouchableOpacity>
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
Tags
</Text>
<Input
placeholder="Search tags"
className="mb-4 bg-base-100 mx-8"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={filteredTags}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No tags match {searchQuery}
</Text>
}
contentContainerClassName="px-8"
contentContainerStyle={{ paddingBottom: 20 }}
/>
</View>
);
@@ -368,10 +251,6 @@ const routes: Route[] = [
name: "collections",
component: Collections,
},
{
name: "tags",
component: Tags,
},
];
export default function EditLinkSheet() {
@@ -383,8 +262,9 @@ export default function EditLinkSheet() {
<ActionSheet
gestureEnabled
indicatorStyle={{
display: "none",
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
enableRouterBackNavigation={true}
routes={routes}
initialRoute="main"
containerStyle={{

View File

@@ -26,7 +26,7 @@ export default function NewCollectionSheet() {
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
display: "none",
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
@@ -34,10 +34,6 @@ export default function NewCollectionSheet() {
safeAreaInsets={insets}
>
<View className="px-8 py-5">
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
New Collection
</Text>
<Input
placeholder="Name"
className="mb-4 bg-base-100"

View File

@@ -7,7 +7,7 @@ import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import NewCollectionSheet from "./NewCollectionSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
registerSheet("support-sheet", SupportSheet);
registerSheet("add-link-sheet", AddLinkSheet);
@@ -29,9 +29,6 @@ declare module "react-native-actions-sheet" {
collections: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
tags: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
};
}>;
"new-collection-sheet": SheetDefinition;

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
@@ -19,7 +19,7 @@ const CollectionListing = ({ collection }: Props) => {
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useDeleteCollection({ auth, Alert });
const deleteCollection = useDeleteCollection(auth);
return (
<ContextMenu.Root>

View File

@@ -17,7 +17,7 @@ import {
Hash,
Link,
} from "lucide-react-native";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import LinkListing from "@/components/LinkListing";
import { useColorScheme } from "nativewind";
import { useRouter } from "expo-router";

View File

@@ -2,7 +2,7 @@ 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/global";
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";

View File

@@ -2,7 +2,7 @@ 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/global";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import Pdf from "react-native-pdf";

View File

@@ -11,7 +11,7 @@ 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/global";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
type Props = {

View File

@@ -2,7 +2,7 @@ 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/global";
import { ArchivedFormat } from "@linkwarden/types";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";

View File

@@ -9,8 +9,8 @@ import {
Linking,
} from "react-native";
import { decode } from "html-entities";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { ArchivedFormat } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types";
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
import {
@@ -40,12 +40,12 @@ type Props = {
const LinkListing = ({ link, dashboard }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const updateLink = useUpdateLink({ auth, Alert });
const updateLink = useUpdateLink(auth);
const { data: user } = useUser(auth);
const { colorScheme } = useColorScheme();
const { data } = useDataStore();
const deleteLink = useDeleteLink({ auth, Alert });
const deleteLink = useDeleteLink(auth);
const [url, setUrl] = useState("");
@@ -57,7 +57,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
} catch (error) {
console.log(error);
}
}, [link.url]);
}, [link]);
return (
<ContextMenu.Root>
@@ -122,8 +122,8 @@ const LinkListing = ({ link, dashboard }: Props) => {
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={link.collection.color || "#0ea5e9"}
color={link.collection.color || "#0ea5e9"}
fill={link.collection.color || ""}
color={link.collection.color || ""}
/>
<Text
numberOfLines={1}
@@ -215,11 +215,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
<ContextMenu.Item
key="pin-link"
onSelect={() => {
onSelect={async () => {
const isAlreadyPinned =
link?.pinnedBy && link.pinnedBy[0] ? true : false;
updateLink.mutateAsync({
await updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
@@ -319,10 +319,12 @@ const LinkListing = ({ link, dashboard }: Props) => {
{
text: "Delete",
style: "destructive",
onPress: async () => {
deleteLink.mutate(link.id as number);
await deleteLinkCache(link.id as number);
onPress: () => {
deleteLink.mutate(link.id as number, {
onSuccess: async () => {
await deleteLinkCache(link.id as number);
},
});
},
},
]

View File

@@ -7,7 +7,7 @@ import {
} from "react-native";
import LinkListing from "@/components/LinkListing";
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import { TagIncludingLinkCount } from "@linkwarden/types";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";

View File

@@ -1,7 +1,7 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types/global";
import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native";
import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister";
@@ -52,20 +52,13 @@ const useAuthStore = create<AuthStore>((set) => ({
console.log("Signing into", instance);
if (token) {
try {
// make a request to the API to validate the token
const res = await Promise.race([
fetch(instance + "/api/v1/users/me", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}),
new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
),
]);
// make a request to the API to validate the token
await fetch(instance + "/api/v1/users/me", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
}).then(async (res) => {
if (res.ok) {
await SecureStore.setItemAsync("INSTANCE", instance);
await SecureStore.setItemAsync("TOKEN", token);
@@ -80,19 +73,7 @@ const useAuthStore = create<AuthStore>((set) => ({
} else {
Alert.alert("Error", "Invalid token");
}
} catch (err: any) {
if (err?.message === "TIMEOUT") {
Alert.alert(
"Request timed out",
"Unable to reach the server in time. Please check your network configuration and try again."
);
} else {
Alert.alert(
"Network error",
"Could not connect to the server. Please check your network configuration and try again."
);
}
}
});
} else {
try {
const res = await Promise.race([

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { MobileData } from "@linkwarden/types/global";
import { MobileData } from "@linkwarden/types";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { colorScheme } from "nativewind";
@@ -17,7 +17,6 @@ const useDataStore = create<DataStore>((set, get) => ({
},
theme: "system",
preferredBrowser: "app",
preferredCollection: null,
},
setData: async () => {
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { User } from "@linkwarden/prisma/client";
type Tmp = {

View File

@@ -1,125 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import Image from "next/image";
export default function AdminSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
setActive(router.asPath);
}, [router]);
return (
<div
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between mb-4">
{user?.theme === "light" ? (
<Image
src={"/linkwarden_light.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
) : (
<Image
src={"/linkwarden_dark.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
)}
</div>
<Link href="/admin/user-administration">
<div
className={`${
active === "/admin/user-administration"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-people text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("user_administration")}
</p>
</div>
</Link>
<Link href="/admin/background-jobs">
<div
className={`${
active === "/admin/background-jobs"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-gear-wide-connected text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("background_jobs")}
</p>
</div>
</Link>
</div>
<div className="flex flex-col gap-1">
<Link
href={`https://github.com/linkwarden/linkwarden/releases`}
target="_blank"
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
>
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-github text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("github")}</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("twitter")}</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("mastodon")}</p>
</div>
</Link>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import Link from "next/link";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";

View File

@@ -11,7 +11,7 @@ import Tree, {
} from "@atlaskit/tree";
import { Collection } from "@linkwarden/prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
@@ -23,7 +23,7 @@ import { useUpdateUser, useUser } from "@linkwarden/router/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib/utils";
import { cn } from "@linkwarden/lib";
import { Active, useDndContext } from "@dnd-kit/core";
interface ExtendedTreeItem extends TreeItem {

View File

@@ -23,7 +23,7 @@ export default function ConfirmationModal({
const { t } = useTranslation();
return (
<Modal toggleModal={() => toggleModal()} className={className}>
<Modal toggleModal={toggleModal} className={className}>
<p className="text-xl font-thin">{title}</p>
<Separator className="mb-3 mt-1" />
{children}

View File

@@ -29,7 +29,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { cn } from "@linkwarden/lib/utils";
import { cn } from "@linkwarden/lib";
import toast from "react-hot-toast";
interface DashboardSectionOption {
@@ -274,7 +274,7 @@ export default function DashboardLayoutDropdown() {
>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 mx-2">
<p className="text-xs font-bold text-neutral mb-1">
<p className="text-sm text-neutral mb-1">
{t("display_on_dashboard")}
</p>

View File

@@ -1,9 +1,9 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import useLocalSettingsStore from "@/store/localSettings";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { useEffect, useRef, useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
@@ -17,7 +17,7 @@ import {
import useOnScreen from "@/hooks/useOnScreen";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink } from "@linkwarden/router/links";
import { useGetLink, useLinks } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import openLink from "@/lib/client/openLink";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
@@ -26,7 +26,7 @@ import LinkTypeBadge from "./LinkViews/LinkComponents/LinkTypeBadge";
import LinkPin from "./LinkViews/LinkComponents/LinkPin";
import { Separator } from "./ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@linkwarden/lib/utils";
import { cn } from "@linkwarden/lib";
import { useTranslation } from "next-i18next";
export function DashboardLinks({
@@ -82,6 +82,8 @@ export function Card({ link, editMode, dashboardType }: Props) {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
@@ -100,7 +102,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections, link]);
}, [collections, links]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);

View File

@@ -11,7 +11,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import toast from "react-hot-toast";
import { useUpdateLink } from "@linkwarden/router/links";
import { useTranslation } from "react-i18next";
@@ -53,7 +53,7 @@ export default function DragNDrop({
onDragEnd: onDragEndProp,
}: DragNDropProps) {
const { t } = useTranslation();
const updateLink = useUpdateLink({ toast, t });
const updateLink = useUpdateLink();
const pinLink = usePinLink();
const { data: user } = useUser();
const queryClient = useQueryClient();
@@ -104,7 +104,25 @@ export default function DragNDrop({
updatedLink: LinkIncludingShortenedCollectionAndTags,
opts?: { invalidateDashboardOnError?: boolean }
) => {
updateLink.mutateAsync(updatedLink);
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(updatedLink, {
onSettled: async (_, error) => {
toast.dismiss(load);
if (error) {
if (
opts?.invalidateDashboardOnError &&
typeof queryClient !== "undefined"
) {
await queryClient.invalidateQueries({
queryKey: ["dashboardData"],
});
}
toast.error(error.message);
} else {
toast.success(t("updated"));
}
},
});
};
// DROP ON TAG

View File

@@ -1,6 +1,6 @@
import React from "react";
import importBookmarks from "@/lib/client/importBookmarks";
import { MigrationFormat } from "@linkwarden/types/global";
import { MigrationFormat } from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import {
DropdownMenu,

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import Link from "next/link";
import {
atLeastOneFormatAvailable,
@@ -113,7 +113,7 @@ export default function LinkDetails({
);
};
const updateLink = useUpdateLink({ toast, t });
const updateLink = useUpdateLink();
const updateFile = useUpdateFile();
const submit = async (e?: any) => {
@@ -126,9 +126,21 @@ export default function LinkDetails({
return;
}
updateLink.mutateAsync(link);
const load = toast.loading(t("updating"));
setMode && setMode("view");
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
};
const setCollection = (e: any) => {

View File

@@ -10,7 +10,7 @@ import {
LinkIncludingShortenedCollectionAndTags,
Sort,
ViewMode,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { useArchiveAction, useBulkDeleteLinks } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import { Button } from "@/components/ui/button";

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import usePermissions from "@/hooks/usePermissions";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
@@ -54,7 +54,7 @@ export default function LinkActions({
const [refreshPreservationsModal, setRefreshPreservationsModal] =
useState(false);
const deleteLink = useDeleteLink({ toast, t });
const deleteLink = useDeleteLink();
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
@@ -131,7 +131,13 @@ export default function LinkActions({
onClick={async (e) => {
if (e.shiftKey) {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number);
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) toast.error(error.message);
else toast.success(t("deleted"));
},
});
} else {
setDeleteLinkModal(true);
}

View File

@@ -2,7 +2,7 @@ import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import React, { useRef, useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";

View File

@@ -2,7 +2,7 @@ import Icon from "@/components/Icon";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import React from "react";

View File

@@ -1,4 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import React from "react";
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {

View File

@@ -2,7 +2,7 @@ import { formatAvailable } from "@linkwarden/lib/formatStats";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";

View File

@@ -1,7 +1,7 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
@@ -30,10 +30,6 @@ function LinkIcon({
const [faviconLoaded, setFaviconLoaded] = useState(false);
useEffect(() => {
setFaviconLoaded(false);
}, [link.url]);
return (
<div onClick={() => onClick && onClick()}>
{link.icon ? (

View File

@@ -1,7 +1,7 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import React, { useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";

View File

@@ -2,7 +2,7 @@ import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import React, { useRef, useState } from "react";
import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
@@ -25,7 +25,7 @@ import openLink from "@/lib/client/openLink";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useDraggable } from "@dnd-kit/core";
import { cn } from "@linkwarden/lib/utils";
import { cn } from "@linkwarden/lib";
import { TFunction } from "i18next";
type Props = {

View File

@@ -1,4 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";

View File

@@ -1,4 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Link from "next/link";
import React, { useEffect, useState } from "react";

View File

@@ -3,7 +3,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";

View File

@@ -66,13 +66,7 @@ export default function MobileNavigation({}: Props) {
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}

View File

@@ -2,7 +2,7 @@ import React, { useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import TextInput from "@/components/TextInput";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
@@ -21,6 +22,7 @@ export default function DeleteCollectionModal({
const { t } = useTranslation();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const permissions = usePermissions(collection.id as number);
@@ -28,15 +30,32 @@ export default function DeleteCollectionModal({
setCollection(activeCollection);
}, []);
const deleteCollection = useDeleteCollection({ toast, t });
const deleteCollection = useDeleteCollection();
const submit = async () => {
if (!collection) return null;
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
deleteCollection.mutateAsync(collection.id as number);
setSubmitLoader(true);
onClose();
router.push("/collections");
const load = toast.loading(t("deleting_collection"));
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("deleted"));
router.push("/collections");
}
},
});
}
};
return (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { Button } from "@/components/ui/button";
@@ -18,7 +18,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const deleteLink = useDeleteLink({ toast, t });
const deleteLink = useDeleteLink();
const router = useRouter();
useEffect(() => {
@@ -26,15 +26,26 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
}, []);
const submit = async () => {
deleteLink.mutateAsync(link.id as number);
const load = toast.loading(t("deleting"));
if (
router.pathname.startsWith("/links/[id]") ||
router.pathname.startsWith("/preserved/[id]")
) {
router.push("/dashboard");
}
onClose();
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
if (
router.pathname.startsWith("/links/[id]") ||
router.pathname.startsWith("/preserved/[id]")
) {
router.push("/dashboard");
}
toast.success(t("deleted"));
onClose();
}
},
});
};
return (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import { TagIncludingLinkCount } from "@linkwarden/types";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";

View File

@@ -37,7 +37,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
const { data: config } = useConfig();
const isAdmin = data?.user?.id === (config?.ADMIN || 1);
const isAdmin = data?.user?.id === config?.ADMIN;
return (
<Modal toggleModal={onClose}>

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@linkwarden/router/collections";

View File

@@ -5,7 +5,7 @@ import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Member,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import getPublicUserData from "@/lib/client/getPublicUserData";
import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto";
@@ -41,9 +41,6 @@ export default function EditCollectionSharingModal({
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [propagateToSubcollections, setPropagateToSubcollections] =
useState(false);
const [submitLoader, setSubmitLoader] = useState(false);
const updateCollection = useUpdateCollection();
@@ -56,22 +53,19 @@ export default function EditCollectionSharingModal({
const load = toast.loading(t("updating_collection"));
await updateCollection.mutateAsync(
{ ...collection, propagateToSubcollections },
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
}
);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
}
};
@@ -371,27 +365,6 @@ export default function EditCollectionSharingModal({
</>
)}
{permissions === true && !isPublicRoute && (
<div>
<label className="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
checked={propagateToSubcollections}
onChange={() =>
setPropagateToSubcollections(!propagateToSubcollections)
}
className="checkbox checkbox-primary"
/>
<span className="label-text">
{t("apply_members_roles_to_subcollections")}
</span>
</label>
<p className="text-neutral text-sm">
{t("apply_members_roles_to_subcollections_desc")}
</p>
</div>
)}
{permissions === true && !isPublicRoute && (
<Button
variant="accent"

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@linkwarden/router/links";
import Drawer from "../Drawer";
@@ -43,7 +43,7 @@ export default function LinkModal({
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const deleteLink = useDeleteLink({ toast, t });
const deleteLink = useDeleteLink();
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
@@ -51,8 +51,13 @@ export default function LinkModal({
setTimeout(() => (document.body.style.pointerEvents = ""), 0);
if (e.shiftKey && link.id) {
deleteLink.mutateAsync(link.id);
const loading = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id, {
onSettled: (data, error) => {
toast.dismiss(loading);
error ? toast.error(error.message) : toast.success(t("deleted"));
},
});
onClose();
} else {
onDelete();

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import { Collection } from "@linkwarden/prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@linkwarden/router/collections";
import toast from "react-hot-toast";

View File

@@ -7,17 +7,14 @@ import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useAddLink } from "@linkwarden/router/links";
import toast from "react-hot-toast";
import {
PostLinkSchema,
PostLinkSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { PostLinkSchemaType } from "@linkwarden/lib/schemaValidation";
import { Button } from "@/components/ui/button";
import { Separator } from "../ui/separator";
import { useAddLink } from "@linkwarden/router/links";
type Props = {
onClose: () => void;
onClose: Function;
};
export default function NewLinkModal({ onClose }: Props) {
@@ -34,13 +31,10 @@ export default function NewLinkModal({ onClose }: Props) {
},
} as PostLinkSchemaType;
const addLink = useAddLink({
toast,
t,
});
const inputRef = useRef<HTMLInputElement>(null);
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const addLink = useAddLink();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { data: collections = [] } = useCollections();
@@ -86,17 +80,22 @@ export default function NewLinkModal({ onClose }: Props) {
}, []);
const submit = async () => {
const dataValidation = PostLinkSchema.safeParse(link);
if (!dataValidation.success)
return toast.error(
`Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`
);
addLink.mutateAsync(link);
onClose();
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_link"));
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
} else {
onClose();
toast.success(t("link_created"));
}
},
});
}
};
return (

View File

@@ -1,6 +1,6 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@linkwarden/types/global";
import { TokenExpiry } from "@linkwarden/types";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";

View File

@@ -26,20 +26,12 @@ import {
} from "@/components/ui/tooltip";
import { useUser } from "@linkwarden/router/user";
import Link from "next/link";
import SettingsSidebar from "@/components/SettingsSidebar";
import AdminSidebar from "@/components/AdminSidebar";
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
const TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
export default function Navbar({
settings,
admin,
}: {
settings?: boolean;
admin?: boolean;
}) {
export default function Navbar() {
const { t } = useTranslation();
const router = useRouter();
const { data: user } = useUser();
@@ -170,23 +162,13 @@ export default function Navbar({
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
{admin ? (
<AdminSidebar />
) : settings ? (
<SettingsSidebar />
) : (
<Sidebar />
)}
<Sidebar />
</div>
</ClickAwayHandler>
</div>
)}
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
<NewLinkModal onClose={() => setNewLinkModal(false)} />
)}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />

View File

@@ -40,13 +40,7 @@ export default function NoLinksFound({ text }: Props) {
</span>
</Button>
</div>
{newLinkModal && (
<NewLinkModal
onClose={() => {
setNewLinkModal(false);
}}
/>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
</div>
);
}

View File

@@ -8,8 +8,11 @@ import { PreservationSkeleton } from "../Skeletons";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types/global";
import { formatAvailable } from "@linkwarden/lib/formatStats";
} from "@linkwarden/types";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import getLinkTypeFromFormat from "@linkwarden/lib/getLinkTypeFromFormat";
type Props = {

View File

@@ -2,7 +2,7 @@ import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import React, { useEffect, useState } from "react";
import {
DropdownMenu,

View File

@@ -11,7 +11,7 @@ import usePermissions from "@/hooks/usePermissions";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import {
useGetLinkHighlights,

View File

@@ -1,7 +1,7 @@
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import Link from "next/link";
import { useRouter } from "next/router";
import { Button } from "@/components/ui/button";

View File

@@ -65,7 +65,7 @@ export default function ProfileDropdown() {
{isAdmin && (
<DropdownMenuItem asChild>
<Link
href="/admin/user-administration"
href="/admin"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
className="whitespace-nowrap"
>

View File

@@ -1,82 +1,16 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import { useUser } from "@linkwarden/router/user";
type Props = {
placeholder?: string;
};
const ADVANCED_SEARCH_OPERATORS = [
{
operator: "name:",
labelKey: "search_operator_name",
icon: "bi-type",
},
{
operator: "url:",
labelKey: "search_operator_url",
icon: "bi-link-45deg",
},
{
operator: "tag:",
labelKey: "search_operator_tag",
icon: "bi-tag",
},
{
operator: "collection:",
labelKey: "search_operator_collection",
icon: "bi-folder2",
},
{
operator: "before:",
labelKey: "search_operator_before",
icon: "bi-calendar-minus",
},
{
operator: "after:",
labelKey: "search_operator_after",
icon: "bi-calendar-plus",
},
{
operator: "public:true",
labelKey: "search_operator_public",
icon: "bi-globe2",
},
{
operator: "description:",
labelKey: "search_operator_description",
icon: "bi-card-text",
},
{
operator: "type:",
labelKey: "search_operator_type",
icon: "bi-file-earmark",
},
{
operator: "pinned:true",
labelKey: "search_operator_pinned",
icon: "bi-pin-angle",
},
{
operator: "!",
labelKey: "search_operator_exclude",
icon: "bi-slash-circle",
},
] as const;
export default function SearchBar({ placeholder }: Props) {
const router = useRouter();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const [showSuggestions, setShowSuggestions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: user } = useUser();
const [dismissSearchNote, setDismissSearchNote] = useState(false);
useEffect(() => {
router.query.q
@@ -84,15 +18,6 @@ export default function SearchBar({ placeholder }: Props) {
: setSearchQuery("");
}, [router.query.q]);
const handleSuggestionClick = (operator: string) => {
setSearchQuery((prev) => {
const needsSpace = prev.length > 0 && !prev.endsWith(" ");
return `${prev}${needsSpace ? " " : ""}${operator}`;
});
setShowSuggestions(false);
requestAnimationFrame(() => inputRef.current?.focus());
};
return (
<div className="flex items-center relative group">
<label
@@ -105,15 +30,8 @@ export default function SearchBar({ placeholder }: Props) {
<input
id="search-box"
type="text"
ref={inputRef}
placeholder={placeholder || t("search_for_links")}
value={searchQuery}
onFocus={() => {
setShowSuggestions(true);
}}
onBlur={() => {
setShowSuggestions(false);
}}
onChange={(e) => {
e.target.value.includes("%") &&
toast.error(t("search_query_invalid_symbol"));
@@ -139,75 +57,9 @@ export default function SearchBar({ placeholder }: Props) {
}
}
}}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-80 md:max-w-full outline-none"
style={{ transition: "width 0.2s ease-in-out" }}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:focus:w-80 md:w-[15rem] md:max-w-full outline-none"
/>
{showSuggestions && (
<div className="absolute left-0 top-full mt-2 w-full z-50">
<div
className="border border-neutral-content bg-base-200 shadow-md rounded-md px-2 py-1 flex flex-col gap-1"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex items-center justify-between">
<p className="text-xs font-bold text-neutral">
{t("search_operators")}
</p>
</div>
<div className="flex flex-col gap-1">
{ADVANCED_SEARCH_OPERATORS.map((entry) => (
<button
key={entry.operator}
type="button"
className="flex items-center gap-2 justify-between rounded-md px-2 py-1 text-left hover:bg-neutral-content duration-100"
onClick={() => handleSuggestionClick(entry.operator)}
>
<div className="flex items-center gap-2">
<i className={`${entry.icon} text-primary text-sm`} />
<span className="text-xs text-neutral">
{t(entry.labelKey)}
</span>
</div>
<span className="font-mono text-xs px-1 rounded-md bg-base-100 border border-neutral-content text-base-content">
{entry.operator}
</span>
</button>
))}
</div>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm" className="text-xs">
<Link
href="https://docs.linkwarden.app/Usage/advanced-search"
target="_blank"
className="flex items-center gap-1"
>
{t("learn_more")}
<i className="bi-box-arrow-up-right text-xs" />
</Link>
</Button>
</div>
{/* {user?.hasUnIndexedLinks && !dismissSearchNote ? (
<div
role="alert"
className="border border-neutral p-2 my-1 rounded flex flex-col gap-2"
>
<p className="text-xs text-neutral">
<i className="bi-info-circle text-primary mr-1" />
<b>{t("note")}:</b> {t("search_unindexed_links_in_bg_info")}
</p>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => setDismissSearchNote(true)}
>
Dismiss
</Button>
</div>
) : undefined} */}
</div>
</div>
)}
</div>
);
}

View File

@@ -3,13 +3,15 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import Image from "next/image";
import { useConfig } from "@linkwarden/router/config";
export default function SettingsSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const { data: config } = useConfig();
const isAdmin = user?.id === (config?.ADMIN || 1);
const router = useRouter();
const [active, setActive] = useState("");
@@ -20,46 +22,21 @@ export default function SettingsSidebar({ className }: { className?: string }) {
return (
<div
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
className={`bg-base-100 h-full w-64 overflow-y-auto border-solid border border-base-100 border-r-neutral-content p-5 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between mb-4">
{user?.theme === "light" ? (
<Image
src={"/linkwarden_light.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
) : (
<Image
src={"/linkwarden_dark.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
)}
</div>
<Link href="/settings/account">
<div
className={`${
active === "/settings/account"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-person text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("account")}
</p>
<i className="bi-person text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("account")}</p>
</div>
</Link>
@@ -69,12 +46,10 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/preference"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-sliders text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("preference")}
</p>
<i className="bi-sliders text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("preference")}</p>
</div>
</Link>
@@ -84,12 +59,10 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/rss-subscriptions"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-rss text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
RSS Subscriptions
</p>
<i className="bi-rss text-primary text-xl"></i>
<p className="truncate w-full pr-7">RSS Subscriptions</p>
</div>
</Link>
@@ -99,12 +72,10 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/access-tokens"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-key text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("access_tokens")}
</p>
<i className="bi-key text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("access_tokens")}</p>
</div>
</Link>
@@ -114,15 +85,28 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/password"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-lock text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("password")}
</p>
<i className="bi-lock text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("password")}</p>
</div>
</Link>
{isAdmin && (
<Link href="/settings/worker">
<div
className={`${
active === "/settings/worker"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-gear-wide-connected text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("worker")}</p>
</div>
</Link>
)}
{process.env.NEXT_PUBLIC_STRIPE && !user?.parentSubscriptionId && (
<Link href="/settings/billing">
<div
@@ -130,12 +114,10 @@ export default function SettingsSidebar({ className }: { className?: string }) {
active === "/settings/billing"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-credit-card text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("billing")}
</p>
<i className="bi-credit-card text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("billing")}</p>
</div>
</Link>
)}
@@ -151,40 +133,34 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
<i className="bi-question-circle text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-github text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("github")}
</p>
<i className="bi-github text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("github")}</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("twitter")}
</p>
<i className="bi-twitter-x text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("twitter")}</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("mastodon")}
</p>
<i className="bi-mastodon text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("mastodon")}</p>
</div>
</Link>
</div>

View File

@@ -1,5 +1,5 @@
import React, { Dispatch, SetStateAction, useEffect } from "react";
import { Sort } from "@linkwarden/types/global";
import { Sort } from "@linkwarden/types";
import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
import { resetInfiniteQueryPagination } from "@linkwarden/router/links";

View File

@@ -8,7 +8,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import { TagIncludingLinkCount } from "@linkwarden/types";
import DeleteTagModal from "./ModalContent/DeleteTagModal";
import { cn } from "@/lib/utils";
import { useRouter } from "next/router";

View File

@@ -2,7 +2,7 @@ import { Tag } from "@linkwarden/prisma/client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib/utils";
import { cn } from "@linkwarden/lib";
import { useDndContext } from "@dnd-kit/core";
interface TagListingProps {
@@ -14,6 +14,9 @@ export default function TagListing({ tags, active }: TagListingProps) {
const { active: droppableActive } = useDndContext();
const { t } = useTranslation();
const ctx = useDndContext();
console.log("DndContext active?", ctx.active);
if (!tags[0]) {
return (
<div

View File

@@ -1,4 +1,4 @@
import { cn } from "@linkwarden/lib/utils";
import { cn } from "@linkwarden/lib";
import React, { forwardRef } from "react";
export type TextInputProps = React.ComponentPropsWithoutRef<"input">;

View File

@@ -6,7 +6,7 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@linkwarden/types/global";
import { ViewMode } from "@linkwarden/types";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import { useEffect } from "react";
@@ -72,7 +72,7 @@ export default function ViewDropdown({
{!dashboard && (
<>
<div className="px-1">
<p className="text-xs font-bold text-neutral mb-1">{t("view")}</p>
<p className="text-sm text-neutral mb-1">{t("view")}</p>
<div className="flex gap-1 border-border">
{[ViewMode.Card, ViewMode.Masonry, ViewMode.List].map(
(mode) => {
@@ -112,7 +112,7 @@ export default function ViewDropdown({
</>
)}
<p className="text-xs font-bold text-neutral px-1 mb-1">{t("show")}</p>
<p className="text-sm text-neutral px-1 mb-1">{t("show")}</p>
{visibleShows.map((key) => (
<DropdownMenuCheckboxItem
key={key}
@@ -131,7 +131,7 @@ export default function ViewDropdown({
<DropdownMenuSeparator />
<div className="px-1">
<p className="text-xs font-bold text-neutral mb-1">
<p className="text-sm text-neutral mb-1">
{t("columns")}:{" "}
{settings.columns === 0 ? t("default") : settings.columns}
</p>

View File

@@ -5,7 +5,7 @@ import {
} from "@linkwarden/types/inputSelect";
import { useState, useEffect } from "react";
import { useTranslation } from "next-i18next";
import { isArchivalTag } from "@linkwarden/lib/isArchivalTag";
import { isArchivalTag } from "@linkwarden/lib";
const useArchivalTags = (initialTags: Tag[]) => {
const [archivalTags, setArchivalTags] = useState<ArchivalTagOption[]>([]);

View File

@@ -1,4 +1,4 @@
import { Member } from "@linkwarden/types/global";
import { Member } from "@linkwarden/types";
import { useEffect, useState } from "react";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";

View File

@@ -1,4 +1,4 @@
import { Member } from "@linkwarden/types/global";
import { Member } from "@linkwarden/types";
import { useEffect, useState } from "react";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";

View File

@@ -2,7 +2,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
Sort,
} from "@linkwarden/types/global";
} from "@linkwarden/types";
import { SetStateAction, useEffect } from "react";
type Props<

View File

@@ -1,38 +0,0 @@
import AdminSidebar from "@/components/AdminSidebar";
import Navbar from "@/components/Navbar";
import React, { ReactNode } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
interface Props {
children: ReactNode;
}
export default function AdminLayout({ children }: Props) {
const { t } = useTranslation();
return (
<div className="flex" data-testid="admin-wrapper">
<div className="hidden lg:block">
<AdminSidebar />
</div>
<div className="lg:w-[calc(100%-320px)] w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto">
<Navbar admin />
<div className="p-5 mx-auto w-full max-w-7xl">
<div className="gap-2 mb-3">
<Button asChild variant="ghost" size="sm" className="text-neutral">
<Link href="/dashboard">
<i className="bi-chevron-left text-md" />
<p>{t("back_to_dashboard")}</p>
</Link>
</Button>
</div>
{children}
</div>
</div>
</div>
);
}

View File

@@ -3,8 +3,10 @@ import Announcement from "@/components/Announcement";
import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect, useState } from "react";
import getLatestVersion from "@/lib/client/getLatestVersion";
import { DndContext } from "@dnd-kit/core";
import DragNDrop from "@/components/DragNDrop";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { useLinks } from "@linkwarden/router/links";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
interface Props {
children: ReactNode;

View File

@@ -1,38 +1,80 @@
import SettingsSidebar from "@/components/SettingsSidebar";
import Navbar from "@/components/Navbar";
import React, { ReactNode } from "react";
import React, { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
interface Props {
children: ReactNode;
}
export default function SettingsLayout({ children }: Props) {
const { t } = useTranslation();
const router = useRouter();
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => {
setSidebar(!sidebar);
};
return (
<div className="flex" data-testid="settings-wrapper">
<div className="hidden lg:block">
<SettingsSidebar />
</div>
<>
<div className="flex max-w-screen-md mx-auto">
<div className="hidden lg:block fixed h-screen">
<SettingsSidebar />
</div>
<div className="lg:w-[calc(100%-320px)] w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto">
<Navbar settings />
<div className="p-5 mx-auto w-full max-w-7xl">
<div className="gap-2 mb-3">
<Button asChild variant="ghost" size="sm" className="text-neutral">
<div className="w-full min-h-screen p-5 lg:ml-64">
<div className="gap-2 inline-flex mr-3">
<Button
variant="ghost"
size="icon"
className="text-neutral lg:hidden"
onClick={toggleSidebar}
>
<i className="bi-list text-xl leading-none" />
</Button>
<Button
asChild
variant="ghost"
size="icon"
className="text-neutral"
>
<Link href="/dashboard">
<i className="bi-chevron-left text-md" />
<p>{t("back_to_dashboard")}</p>
<i className="bi-chevron-left text-xl" />
</Link>
</Button>
</div>
{children}
{sidebar && (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<SettingsSidebar />
</div>
</ClickAwayHandler>
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -1,8 +1,8 @@
import { prisma } from "@linkwarden/prisma";
import getPermission from "@/lib/api/getPermission";
import { UsersAndCollections } from "@linkwarden/prisma/client";
import { Link, UsersAndCollections } from "@linkwarden/prisma/client";
import { removeFolder } from "@linkwarden/filesystem";
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
import { meiliClient } from "@linkwarden/lib";
export default async function deleteCollection(
userId: number,

View File

@@ -65,61 +65,6 @@ export default async function updateCollection(
);
const updatedCollection = await prisma.$transaction(async () => {
if (data.propagateToSubcollections) {
const getAllSubCollections = async (
parentId: number
): Promise<{ id: number; ownerId: number }[]> => {
const result: { id: number; ownerId: number }[] = [];
let frontier: number[] = [parentId];
const seen = new Set<number>(frontier);
while (frontier.length > 0) {
const children = await prisma.collection.findMany({
where: { parentId: { in: frontier } },
select: { id: true, ownerId: true },
});
if (children.length === 0) break;
for (const child of children) {
if (seen.has(child.id)) continue;
seen.add(child.id);
result.push(child);
}
frontier = children.map((c) => c.id);
}
return result;
};
const subCollections = await getAllSubCollections(collectionId);
for (const sub of subCollections) {
await prisma.usersAndCollections.deleteMany({
where: { collectionId: sub.id },
});
const subMembers = uniqueMembers.filter(
(m) => m.userId !== sub.ownerId
);
if (subMembers.length > 0) {
await prisma.usersAndCollections.createMany({
data: subMembers.map((e) => ({
userId: e.userId,
collectionId: sub.id,
canCreate: e.canCreate,
canUpdate: e.canUpdate,
canDelete: e.canDelete,
})),
});
}
}
}
await prisma.usersAndCollections.deleteMany({
where: {
collection: {

View File

@@ -4,9 +4,6 @@ import {
PostCollectionSchema,
PostCollectionSchemaType,
} from "@linkwarden/lib/schemaValidation";
import getPermission from "@/lib/api/getPermission";
import { UsersAndCollections } from "@linkwarden/prisma/client";
import getCollectionRootOwnerAndMembers from "../../getCollectionRootOwnerAndMembers";
export default async function postCollection(
body: PostCollectionSchemaType,
@@ -25,60 +22,24 @@ export default async function postCollection(
const collection = dataValidation.data;
let rootOwnerId = userId;
let dedupedUsers: {
userId: number;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}[] = [];
if (collection.parentId) {
if (typeof collection.parentId !== "number") {
return {
response: "Invalid parentId.",
status: 400,
};
}
const permissionCheck = await getPermission({
userId,
collectionId: collection.parentId,
const findParentCollection = await prisma.collection.findUnique({
where: {
id: collection.parentId,
},
select: {
ownerId: true,
},
});
const memberHasAccess = permissionCheck?.members.some(
(e: UsersAndCollections) =>
e.userId === userId && e.canCreate && e.canUpdate && e.canDelete
);
if (!memberHasAccess && permissionCheck?.ownerId !== userId) {
if (
findParentCollection?.ownerId !== userId ||
typeof collection.parentId !== "number"
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
const result = await getCollectionRootOwnerAndMembers(collection.parentId);
if (!result.rootOwnerId) {
return {
response: "Parent collection not found.",
status: 404,
};
}
rootOwnerId = result.rootOwnerId;
dedupedUsers = result.members;
const exists = dedupedUsers.some((u) => u.userId === userId);
if (!exists) {
dedupedUsers.push({
userId,
canCreate: true,
canUpdate: true,
canDelete: true,
});
}
}
const newCollection = await prisma.collection.create({
@@ -88,31 +49,35 @@ export default async function postCollection(
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
owner: { connect: { id: rootOwnerId } },
createdBy: { connect: { id: userId } },
members:
userId !== rootOwnerId
? {
create: [
{
userId,
canCreate: true,
canUpdate: true,
canDelete: true,
},
],
}
: undefined,
parent: collection.parentId
? { connect: { id: collection.parentId } }
? {
connect: {
id: collection.parentId,
},
}
: undefined,
owner: {
connect: {
id: userId,
},
},
createdBy: {
connect: {
id: userId,
},
},
},
include: {
_count: { select: { links: true } },
_count: {
select: { links: true },
},
members: {
include: {
user: {
select: { username: true, name: true },
select: {
username: true,
name: true,
},
},
},
},
@@ -120,9 +85,13 @@ export default async function postCollection(
});
await prisma.user.update({
where: { id: userId },
where: {
id: userId,
},
data: {
collectionOrder: { push: newCollection.id },
collectionOrder: {
push: newCollection.id,
},
},
});

View File

@@ -1,5 +1,5 @@
import { prisma } from "@linkwarden/prisma";
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types/global";
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types";
export default async function getDashboardData(
userId: number,

View File

@@ -1,5 +1,5 @@
import { prisma } from "@linkwarden/prisma";
import { Order } from "@linkwarden/types/global";
import { Order } from "@linkwarden/types";
export default async function getDashboardData(userId: number) {
const order: Order = { id: "desc" };

View File

@@ -2,7 +2,7 @@ import { prisma } from "@linkwarden/prisma";
import { UsersAndCollections } from "@linkwarden/prisma/client";
import getPermission from "@/lib/api/getPermission";
import { removeFiles } from "@linkwarden/filesystem";
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
import { meiliClient } from "@linkwarden/lib";
export default async function deleteLinksById(
userId: number,

View File

@@ -1,4 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import updateLinkById from "../linkId/updateLinkById";
import { UpdateLinkSchemaType } from "@linkwarden/lib/schemaValidation";
import { prisma } from "@linkwarden/prisma";

View File

@@ -1,5 +1,5 @@
import { prisma } from "@linkwarden/prisma";
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types/global";
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types";
export default async function getLink(userId: number, query: LinkRequestQuery) {
if (process.env.DISABLE_DEPRECATED_ROUTES === "true")

View File

@@ -2,7 +2,7 @@ import { prisma } from "@linkwarden/prisma";
import { Link, UsersAndCollections } from "@linkwarden/prisma/client";
import getPermission from "@/lib/api/getPermission";
import { removeFiles } from "@linkwarden/filesystem";
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
import { meiliClient } from "@linkwarden/lib";
export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 };

View File

@@ -6,7 +6,7 @@ import {
PostLinkSchema,
PostLinkSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
import { hasPassedLimit } from "@linkwarden/lib";
export default async function postLink(
body: PostLinkSchemaType,

View File

@@ -23,6 +23,7 @@ export default async function exportData(userId: number) {
},
},
pinnedLinks: true,
whitelistedUsers: true,
},
});

View File

@@ -1,380 +0,0 @@
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
let prisma: typeof import("@linkwarden/prisma").prisma;
let importFromHTMLFile: typeof import("./importFromHTMLFile").default;
let removeFolder: typeof import("@linkwarden/filesystem").removeFolder;
const createdUserIds: number[] = [];
const ensureTestEnv = async () => {
await import("dotenv/config");
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set to run integration tests for importFromHTMLFile."
);
}
vi.stubEnv("NODE_ENV", "test");
process.env.STRIPE_SECRET_KEY = "";
process.env.NEXT_PUBLIC_STRIPE = "false";
process.env.NEXT_PUBLIC_REQUIRE_CC = "false";
process.env.MAX_LINKS_PER_USER = process.env.MAX_LINKS_PER_USER || "5";
process.env.STORAGE_FOLDER = process.env.STORAGE_FOLDER || "data-test";
delete process.env.SPACES_ENDPOINT;
delete process.env.SPACES_REGION;
delete process.env.SPACES_KEY;
delete process.env.SPACES_SECRET;
};
const createTestUser = async () => {
const suffix = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const user = await prisma.user.create({
data: {
username: `import_test_${suffix}`,
email: `import_test_${suffix}@example.com`,
},
});
createdUserIds.push(user.id);
return user;
};
const cleanupUser = async (userId: number) => {
const collections = await prisma.collection.findMany({
where: { ownerId: userId },
select: { id: true },
});
try {
await prisma.user.delete({ where: { id: userId } });
} catch (error) {
return;
}
for (const { id } of collections) {
await removeFolder({ filePath: `archives/${id}` });
}
};
beforeAll(async () => {
await ensureTestEnv();
const prismaModule = await import("@linkwarden/prisma");
prisma = prismaModule.prisma;
const filesystemModule = await import("@linkwarden/filesystem");
removeFolder = filesystemModule.removeFolder;
importFromHTMLFile = (await import("./importFromHTMLFile")).default;
await prisma.$connect();
});
afterEach(async () => {
const users = createdUserIds.splice(0, createdUserIds.length);
for (const userId of users) {
await cleanupUser(userId);
}
});
afterAll(async () => {
await prisma.$disconnect();
});
describe.sequential("importFromHTMLFile integration", () => {
it("returns an error when the link limit is exceeded", async () => {
const user = await createTestUser();
const collection = await prisma.collection.create({
data: {
name: "Existing",
owner: { connect: { id: user.id } },
createdBy: { connect: { id: user.id } },
},
});
for (let i = 0; i < 5; i += 1) {
await prisma.link.create({
data: {
name: `Existing ${i}`,
url: `https://example.com/existing-${i}`,
collectionId: collection.id,
createdById: user.id,
},
});
}
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><A HREF="https://example.com/new">New</A></DT>
</DL><p>
</body>
</html>`;
const beforeCount = await prisma.link.count({
where: { createdById: user.id },
});
const result = await importFromHTMLFile(user.id, html);
const afterCount = await prisma.link.count({
where: { createdById: user.id },
});
expect(result).toEqual({
response:
"Your subscription has reached the maximum number of links allowed.",
status: 400,
});
expect(afterCount).toBe(beforeCount);
});
it("imports root links into the Imports collection with tags, date, and description", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
<P>Bookmarks</P>
<DL><p>
<DT><A HREF="https://example.com/path?q=fish&amp;chips" ADD_DATE="1700000000" tags="news,tech">Example</A>
<DD>Example description</DD>
</DL><p>
</body>
</html>`;
const result = await importFromHTMLFile(user.id, html);
expect(result).toEqual({ response: "Success.", status: 200 });
const importsCollection = await prisma.collection.findFirst({
where: { ownerId: user.id, name: "Imports" },
});
expect(importsCollection).toBeTruthy();
const link = await prisma.link.findFirst({
where: {
collectionId: importsCollection?.id,
url: "https://example.com/path?q=fish&chips",
},
include: { tags: true },
});
expect(link).toBeTruthy();
expect(link?.description).toBe("Example description");
expect(link?.importDate?.toISOString()).toBe(
new Date(1700000000 * 1000).toISOString()
);
expect(link?.tags.map((tag) => tag.name).sort()).toEqual(["news", "tech"]);
});
it("creates nested collections and assigns links to the correct parent", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><H3>Recipes</H3>
<DL><p>
<DT><A HREF="https://example.com/soup">Soup</A></DT>
<DT><H3>Desserts</H3>
<DL><p>
<DT><A HREF="https://example.com/cake">Cake</A></DT>
</DL><p>
</DL><p>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const recipesCollection = await prisma.collection.findFirst({
where: { ownerId: user.id, name: "Recipes" },
});
expect(recipesCollection).toBeTruthy();
const dessertsCollection = await prisma.collection.findFirst({
where: {
ownerId: user.id,
name: "Desserts",
parentId: recipesCollection?.id,
},
});
expect(dessertsCollection).toBeTruthy();
const soupLink = await prisma.link.findFirst({
where: { url: "https://example.com/soup" },
});
const cakeLink = await prisma.link.findFirst({
where: { url: "https://example.com/cake" },
});
expect(soupLink?.collectionId).toBe(recipesCollection?.id);
expect(cakeLink?.collectionId).toBe(dessertsCollection?.id);
});
it("reuses an existing Imports collection instead of creating a duplicate", async () => {
const user = await createTestUser();
const importsCollection = await prisma.collection.create({
data: {
name: "Imports",
owner: { connect: { id: user.id } },
createdBy: { connect: { id: user.id } },
},
});
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><A HREF="https://example.com/alpha">Alpha</A></DT>
<DT><A HREF="https://example.com/beta">Beta</A></DT>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const importsCollections = await prisma.collection.findMany({
where: { ownerId: user.id, name: "Imports" },
});
expect(importsCollections).toHaveLength(1);
const importedLinks = await prisma.link.findMany({
where: { createdById: user.id },
});
expect(importedLinks).toHaveLength(2);
importedLinks.forEach((link) => {
expect(link.collectionId).toBe(importsCollection.id);
});
});
it("falls back to an Untitled Collection when a folder name is empty", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><H3></H3>
<DL><p>
<DT><A HREF="https://example.com/blank">Blank Folder</A></DT>
</DL><p>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const untitledCollection = await prisma.collection.findFirst({
where: { ownerId: user.id, name: "Untitled Collection" },
});
expect(untitledCollection).toBeTruthy();
const link = await prisma.link.findFirst({
where: { url: "https://example.com/blank" },
});
expect(link?.collectionId).toBe(untitledCollection?.id);
});
it("skips invalid URLs and only creates links for valid URLs", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><A HREF="not a url">Broken</A></DT>
<DT><A HREF="https://valid.example.com">Valid</A></DT>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const links = await prisma.link.findMany({
where: { createdById: user.id },
});
expect(links).toHaveLength(1);
expect(links[0]?.url).toBe("https://valid.example.com");
});
// it("keeps link ids in the same chronological order as importDate (createdAt fallback)", async () => {
// const user = await createTestUser();
// const nowSeconds = Math.floor(Date.now() / 1000);
// const olderSeconds = nowSeconds - 86400;
// const newerSeconds = nowSeconds + 86400;
// const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
// <html>
// <body>
// <DL><p>
// <DT><A HREF="https://example.com/old" ADD_DATE="${olderSeconds}">Old</A></DT>
// <DT><A HREF="https://example.com/new" ADD_DATE="${newerSeconds}">New</A></DT>
// <DT><A HREF="https://example.com/now">Now</A></DT>
// <DT><H3>Folder One</H3>
// <DL><p>
// <DT><A HREF="https://example.com/f1-old" ADD_DATE="${olderSeconds}">F1 Old</A></DT>
// <DT><A HREF="https://example.com/f1-new" ADD_DATE="${newerSeconds}">F1 New</A></DT>
// </DL><p>
// <DT><H3>Folder Two</H3>
// <DL><p>
// <DT><A HREF="https://example.com/f2-now">F2 Now</A></DT>
// <DT><A HREF="https://example.com/f2-newer" ADD_DATE="${newerSeconds}">F2 Newer</A></DT>
// </DL><p>
// </DL><p>
// </body>
// </html>`;
// await importFromHTMLFile(user.id, html);
// const linksById = await prisma.link.findMany({
// where: { createdById: user.id },
// orderBy: { id: "asc" },
// select: { id: true, importDate: true, createdAt: true, url: true },
// });
// console.log(linksById);
// expect(linksById).toHaveLength(3);
// const idsByIdOrder = linksById.map((link) => link.id);
// const idsByEffectiveDateOrder = [...linksById]
// .sort(
// (a, b) =>
// (a.importDate ?? a.createdAt).getTime() -
// (b.importDate ?? b.createdAt).getTime()
// )
// .map((link) => link.id);
// expect(idsByIdOrder).toEqual(idsByEffectiveDateOrder);
// });
});

View File

@@ -3,14 +3,12 @@ import { createFolder } from "@linkwarden/filesystem";
import { JSDOM } from "jsdom";
import { decodeHTML } from "entities";
import { parse, Node, Element, TextNode } from "himalaya";
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
import { hasPassedLimit } from "@linkwarden/lib";
export default async function importFromHTMLFile(
userId: number,
rawData: string
) {
// const importStartMs = Date.now();
const dom = new JSDOM(rawData);
const document = dom.window.document;
@@ -35,8 +33,6 @@ export default async function importFromHTMLFile(
const processedArray = processNodes(jsonData);
// sortBookmarksTreeByEffectiveDate(processedArray, importStartMs, "asc");
for (const item of processedArray) {
await processBookmarks(userId, item as Element);
}
@@ -53,6 +49,7 @@ async function processBookmarks(
for (const item of data.children) {
if (item.type === "element" && item.tagName === "dt") {
// process collection or sub-collection
let collectionId;
const collectionName = item.children.find(
(e) => e.type === "element" && e.tagName === "h3"
@@ -76,7 +73,6 @@ async function processBookmarks(
);
}
}
await processBookmarks(
userId,
item,
@@ -84,16 +80,14 @@ async function processBookmarks(
);
} else if (item.type === "element" && item.tagName === "a") {
// process link
const rawLinkUrl = item?.attributes.find(
(e) => e.key.toLowerCase() === "href"
)?.value;
const linkUrl = decodeEntities(rawLinkUrl);
const linkName = (
item?.children.find((e) => e.type === "text") as TextNode
)?.content;
const linkTags = item?.attributes
.find((e) => e.key === "tags")
?.value.split(",")
@@ -128,7 +122,7 @@ async function processBookmarks(
linkDate
);
} else if (linkUrl) {
// create a collection named "Imports" and add the link to it
// create a collection named "Imported Bookmarks" and add the link to it
const collectionId = await createCollection(userId, "Imports");
await createLink(
@@ -144,6 +138,7 @@ async function processBookmarks(
await processBookmarks(userId, item, parentCollectionId);
} else {
// process anything else
await processBookmarks(userId, item, parentCollectionId);
}
}
@@ -212,11 +207,9 @@ const createLink = async (
} catch (e) {
return;
}
tags = tags?.map((tag) => tag.trim().slice(0, 49));
name = name?.trim().slice(0, 254);
description = description?.trim().slice(0, 254);
if (importDate) {
const dateString = importDate.toISOString();
if (dateString.length > 50) {
@@ -289,6 +282,7 @@ function processNodes(nodes: Node[]) {
aElement.children.push(nextSibling);
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
dlNode.children.splice(i + 1, 1);
// Adjust the loop counter due to the removal
}
}
}
@@ -302,87 +296,3 @@ function processNodes(nodes: Node[]) {
function decodeEntities(encoded: string | undefined): string {
return decodeHTML(encoded ?? "");
}
/**
* Sort <DT> entries inside each <DL> by "effective date":
* - If an entry has ADD_DATE (on <A> or <H3>), use that.
* - Otherwise, use importStartMs as the createdAt fallback for ordering.
*
* This ensures auto-increment IDs are created in the same chronological order
* as (importDate ?? createdAt), satisfying your test.
*/
// function sortBookmarksTreeByEffectiveDate(
// nodes: Node[],
// importStartMs: number,
// direction: "asc" | "desc" = "asc"
// ) {
// const dir = direction === "asc" ? 1 : -1;
// const getAttrCaseInsensitive = (el: Element, key: string) =>
// el.attributes?.find((a) => a.key.toLowerCase() === key.toLowerCase())
// ?.value;
// const getAddDateMsFromDT = (dt: Element): number | null => {
// // Links: <A ADD_DATE="...">
// const aEl = dt.children.find(
// (c) => c.type === "element" && c.tagName === "a"
// ) as Element | undefined;
// // Folders: <H3 ADD_DATE="...">
// const h3El = dt.children.find(
// (c) => c.type === "element" && c.tagName === "h3"
// ) as Element | undefined;
// const raw =
// (aEl && getAttrCaseInsensitive(aEl, "add_date")) ||
// (h3El && getAttrCaseInsensitive(h3El, "add_date"));
// if (!raw) return null;
// const seconds = Number(raw);
// if (!Number.isFinite(seconds)) return null;
// return seconds * 1000;
// };
// const stableSort = <T>(arr: T[], cmp: (a: T, b: T) => number): T[] =>
// arr
// .map((v, i) => ({ v, i }))
// .sort((a, b) => cmp(a.v, b.v) || a.i - b.i)
// .map((x) => x.v);
// const sortDLChildren = (dl: Element) => {
// const dtChildren: Element[] = [];
// const otherChildren: Node[] = [];
// for (const child of dl.children) {
// if (child.type === "element" && child.tagName === "dt") {
// dtChildren.push(child as Element);
// } else {
// otherChildren.push(child);
// }
// }
// const sortedDTs = stableSort(dtChildren, (a, b) => {
// const aMs = getAddDateMsFromDT(a) ?? importStartMs;
// const bMs = getAddDateMsFromDT(b) ?? importStartMs;
// return (aMs - bMs) * dir;
// });
// dl.children = [...sortedDTs, ...otherChildren];
// };
// const walk = (node: Node) => {
// if (node.type !== "element") return;
// if (node.tagName === "dl") {
// sortDLChildren(node);
// }
// if (node.children?.length) {
// node.children.forEach(walk);
// }
// };
// nodes.forEach(walk);
// }

View File

@@ -1,7 +1,7 @@
import { prisma } from "@linkwarden/prisma";
import { Backup } from "@linkwarden/types/global";
import { Backup } from "@linkwarden/types";
import { createFolder } from "@linkwarden/filesystem";
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
import { hasPassedLimit } from "@linkwarden/lib";
export default async function importFromLinkwarden(
userId: number,

View File

@@ -1,6 +1,6 @@
import { prisma } from "@linkwarden/prisma";
import { createFolder } from "@linkwarden/filesystem";
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
import { hasPassedLimit } from "@linkwarden/lib";
type OmnivoreItem = {
id: string;

View File

@@ -1,6 +1,6 @@
import { prisma } from "@linkwarden/prisma";
import { createFolder } from "@linkwarden/filesystem";
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
import { hasPassedLimit } from "@linkwarden/lib";
import Papa from "papaparse";
type PocketBackup = {

View File

@@ -1,6 +1,6 @@
import { prisma } from "@linkwarden/prisma";
import { createFolder } from "@linkwarden/filesystem";
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
import { hasPassedLimit } from "@linkwarden/lib";
type WallabagBackup = {
is_archived: number;

View File

@@ -2,7 +2,8 @@ import { prisma } from "@linkwarden/prisma";
export default async function getPublicUser(
targetId: number | string,
isId: boolean
isId: boolean,
requestingId?: number
) {
const user = await prisma.user.findFirst({
where: isId
@@ -19,9 +20,59 @@ export default async function getPublicUser(
},
],
},
include: {
whitelistedUsers: {
select: {
username: true,
},
},
},
});
if (!user || !user.id) return { response: "User not found.", status: 404 };
if (!user || !user.id)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
(usernames) => usernames.username
);
const isInAPublicCollection = await prisma.collection.findFirst({
where: {
OR: [
{ ownerId: user.id },
{
members: {
some: {
userId: user.id,
},
},
},
],
isPublic: true,
},
});
if (user?.isPrivate && !isInAPublicCollection) {
if (requestingId) {
const requestingUser = await prisma.user.findUnique({
where: { id: requestingId },
});
if (
requestingUser?.id !== requestingId &&
(!requestingUser?.username ||
!whitelistedUsernames.includes(
requestingUser.username?.toLowerCase()
))
) {
return {
response: "User not found or profile is private.",
status: 404,
};
}
} else
return { response: "User not found or profile is private.", status: 404 };
}
const { password, ...lessSensitiveInfo } = user;
@@ -29,6 +80,7 @@ export default async function getPublicUser(
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
email: lessSensitiveInfo.email,
image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,

View File

@@ -1,6 +1,6 @@
import { prisma } from "@linkwarden/prisma";
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types/global";
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types";
import { meiliClient } from "@linkwarden/lib";
import {
buildMeiliFilters,
buildMeiliQuery,

Some files were not shown because too many files have changed in this diff Show More