mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 15:57:01 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edb450b6a | ||
|
|
4fa1f57351 | ||
|
|
f3d30085de | ||
|
|
da8761387f | ||
|
|
c9fd573b31 | ||
|
|
c99f9edd9a | ||
|
|
389a96dadc | ||
|
|
c8b1129e4f | ||
|
|
b9fd802288 | ||
|
|
549299743c | ||
|
|
21b6ab3de4 | ||
|
|
155ca17b55 | ||
|
|
686e3b44e1 | ||
|
|
f13c5e1cfc | ||
|
|
7e34d98bc4 | ||
|
|
e9c1c5217b | ||
|
|
209e0faa1b | ||
|
|
27a86c0b28 | ||
|
|
0198a9148e | ||
|
|
45dc95122a | ||
|
|
8c9cd34ec3 | ||
|
|
6b3dba3faf | ||
|
|
81ae7c64a9 | ||
|
|
d39a0ed5b2 | ||
|
|
ffc9971ce6 | ||
|
|
a8a9ad602f | ||
|
|
bc750bd588 | ||
|
|
e3de382739 | ||
|
|
57601413d4 | ||
|
|
af8a650096 | ||
|
|
b445fde85a | ||
|
|
7bbdec0f85 | ||
|
|
4743aa8144 | ||
|
|
fedd19770e | ||
|
|
6c5253121c | ||
|
|
6536b34c41 | ||
|
|
2c812e11e4 | ||
|
|
13305c06c4 | ||
|
|
3cbbeb55a4 | ||
|
|
6bb261c81a | ||
|
|
dbad316bac | ||
|
|
cc37543324 | ||
|
|
7c9307dd84 | ||
|
|
c794c0814e | ||
|
|
78d6d1c70a | ||
|
|
f79f57ccda | ||
|
|
eb8402448d | ||
|
|
350cdb485a | ||
|
|
8bd3bd3763 | ||
|
|
f2cfbf0b10 | ||
|
|
513c03dcae | ||
|
|
98b7e38139 | ||
|
|
9b5c08655a | ||
|
|
caf706e8ea | ||
|
|
d54f7da5a5 | ||
|
|
ae2e3c80db | ||
|
|
06285ce6d7 | ||
|
|
b4b6edd618 | ||
|
|
d066378076 | ||
|
|
cddfc5dba6 | ||
|
|
4bdcfa0ee7 | ||
|
|
95e662358f | ||
|
|
eb31acbc30 | ||
|
|
7a34d836be | ||
|
|
3926e566b7 | ||
|
|
a8d2c55d12 | ||
|
|
0ab4a2d883 | ||
|
|
e0f357513c | ||
|
|
f072bcd0b0 | ||
|
|
27997b8f4b |
@@ -3,8 +3,10 @@
|
||||
<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=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%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=43856801"><img src="https://img.shields.io/badge/Hacker%20News-301-%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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Linkwarden",
|
||||
"slug": "linkwarden",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "linkwarden",
|
||||
@@ -53,7 +53,9 @@
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"iosAppGroupIdentifier": "group.app.linkwarden"
|
||||
"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/*"]
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
|
||||
export default function CollectionsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function Layout() {
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerBackTitle: "Back",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
@@ -28,6 +29,15 @@ export default function Layout() {
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="preferredCollection"
|
||||
options={{
|
||||
headerTitle: "Preferred Collection",
|
||||
headerLargeTitle: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
AppWindowMac,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
@@ -25,6 +27,7 @@ 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();
|
||||
@@ -40,6 +43,8 @@ export default function SettingsScreen() {
|
||||
updateData({ theme: override });
|
||||
}, [override]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
@@ -196,6 +201,33 @@ 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">
|
||||
|
||||
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
@@ -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";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
export default function TagsScreen() {
|
||||
|
||||
@@ -33,7 +33,7 @@ import useTmpStore from "@/store/tmp";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
MobileAuth,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
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);
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const { tmp } = useTmpStore();
|
||||
|
||||
@@ -229,12 +229,12 @@ const RootComponent = ({
|
||||
{tmp.link && tmp.user && (
|
||||
<DropdownMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={async () => {
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? true
|
||||
: false;
|
||||
await updateLink.mutateAsync({
|
||||
updateLink.mutateAsync({
|
||||
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
@@ -282,18 +282,15 @@ const RootComponent = ({
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
onPress: async () => {
|
||||
deleteLink.mutate(
|
||||
tmp.link?.id as number,
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
},
|
||||
}
|
||||
tmp.link?.id as number
|
||||
);
|
||||
// go back
|
||||
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } 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,20 +14,29 @@ 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 },
|
||||
{
|
||||
onSuccess: () => {
|
||||
url: data.shareIntent.url,
|
||||
collection: { id: data.preferredCollection?.id },
|
||||
},
|
||||
{
|
||||
onSuccess: (e) => {
|
||||
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
updateData({
|
||||
shareIntent: {
|
||||
@@ -36,7 +45,7 @@ export default function IncomingScreen() {
|
||||
},
|
||||
});
|
||||
router.replace("/dashboard");
|
||||
}, 1000);
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error adding the link.");
|
||||
@@ -50,49 +59,39 @@ export default function IncomingScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-base-100">
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
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: 241 KiB After Width: | Height: | Size: 237 KiB |
@@ -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);
|
||||
const addLink = useAddLink({ auth, Alert });
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AddLinkSheet() {
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
@@ -31,6 +31,10 @@ 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"
|
||||
@@ -39,21 +43,12 @@ export default function AddLinkSheet() {
|
||||
/>
|
||||
|
||||
<Button
|
||||
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);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
addLink.mutate({ url: link });
|
||||
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
}}
|
||||
isLoading={addLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Alert } from "react-native";
|
||||
import { View, Text, Alert, TouchableOpacity } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActionSheet, {
|
||||
FlatList,
|
||||
@@ -15,13 +15,15 @@ import useAuthStore from "@/store/auth";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
TagIncludingLinkCount,
|
||||
} from "@linkwarden/types/global";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Folder, ChevronRight, Check } from "lucide-react-native";
|
||||
import { Folder, ChevronRight, ChevronLeft, 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();
|
||||
@@ -31,7 +33,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
const [link, setLink] = useState<
|
||||
LinkIncludingShortenedCollectionAndTags | undefined
|
||||
>(props.payload?.link);
|
||||
const editLink = useUpdateLink(auth);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -45,6 +47,10 @@ 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"
|
||||
@@ -82,23 +88,29 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* <Button variant="input" className="mb-4 h-auto">
|
||||
<Button
|
||||
variant="input"
|
||||
className="mb-4 h-auto"
|
||||
onPress={() => router?.navigate("tags", { link })}
|
||||
>
|
||||
{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-gray-200 rounded-md h-7 px-2 py-1"
|
||||
className="bg-neutral rounded-md h-7 px-2 py-1"
|
||||
>
|
||||
<Text numberOfLines={1}>{tag.name}</Text>
|
||||
<Text numberOfLines={1} className="text-base-100">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-gray-500">No tags</Text>
|
||||
<Text className="text-neutral">No tags</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={"gray"} />
|
||||
</Button> */}
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
multiline
|
||||
@@ -112,23 +124,15 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
/>
|
||||
|
||||
<Button
|
||||
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}
|
||||
onPress={() => {
|
||||
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
}}
|
||||
isLoading={updateLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
@@ -150,7 +154,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<
|
||||
@@ -175,13 +179,11 @@ 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 });
|
||||
};
|
||||
@@ -216,16 +218,32 @@ const Collections = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5 max-h-[80vh]">
|
||||
<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>
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-100"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredCollections}
|
||||
data={[...filteredCollections]}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
@@ -236,7 +254,106 @@ const Collections = () => {
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
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"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -251,6 +368,10 @@ const routes: Route[] = [
|
||||
name: "collections",
|
||||
component: Collections,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
component: Tags,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EditLinkSheet() {
|
||||
@@ -262,9 +383,8 @@ export default function EditLinkSheet() {
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
display: "none",
|
||||
}}
|
||||
enableRouterBackNavigation={true}
|
||||
routes={routes}
|
||||
initialRoute="main"
|
||||
containerStyle={{
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function NewCollectionSheet() {
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
@@ -34,6 +34,10 @@ 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"
|
||||
|
||||
@@ -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";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
registerSheet("support-sheet", SupportSheet);
|
||||
registerSheet("add-link-sheet", AddLinkSheet);
|
||||
@@ -29,6 +29,9 @@ declare module "react-native-actions-sheet" {
|
||||
collections: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
tags: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
"new-collection-sheet": SheetDefinition;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
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);
|
||||
const deleteCollection = useDeleteCollection({ auth, Alert });
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
@@ -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";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
import { Image, Platform, ScrollView } from "react-native";
|
||||
|
||||
@@ -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";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import Pdf from "react-native-pdf";
|
||||
|
||||
|
||||
@@ -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";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -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";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
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);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { data } = useDataStore();
|
||||
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
@@ -57,7 +57,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link]);
|
||||
}, [link.url]);
|
||||
|
||||
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 || ""}
|
||||
color={link.collection.color || ""}
|
||||
fill={link.collection.color || "#0ea5e9"}
|
||||
color={link.collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
@@ -215,11 +215,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
|
||||
<ContextMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={async () => {
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
||||
|
||||
await updateLink.mutateAsync({
|
||||
updateLink.mutateAsync({
|
||||
...link,
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
@@ -319,12 +319,10 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteLink.mutate(link.id as number, {
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
});
|
||||
onPress: async () => {
|
||||
deleteLink.mutate(link.id as number);
|
||||
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "react-native";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
|
||||
@@ -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";
|
||||
import { MobileAuth } from "@linkwarden/types/global";
|
||||
import { Alert } from "react-native";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
@@ -52,13 +52,20 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||
console.log("Signing into", instance);
|
||||
|
||||
if (token) {
|
||||
// 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) => {
|
||||
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)
|
||||
),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
await SecureStore.setItemAsync("TOKEN", token);
|
||||
@@ -73,7 +80,19 @@ 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([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { MobileData } from "@linkwarden/types";
|
||||
import { MobileData } from "@linkwarden/types/global";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { colorScheme } from "nativewind";
|
||||
|
||||
@@ -17,6 +17,7 @@ const useDataStore = create<DataStore>((set, get) => ({
|
||||
},
|
||||
theme: "system",
|
||||
preferredBrowser: "app",
|
||||
preferredCollection: null,
|
||||
},
|
||||
setData: async () => {
|
||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { User } from "@linkwarden/prisma/client";
|
||||
|
||||
type Tmp = {
|
||||
|
||||
125
apps/web/components/AdminSidebar.tsx
Normal file
125
apps/web/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ProfilePhoto from "./ProfilePhoto";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
|
||||
@@ -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";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
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";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { Active, useDndContext } from "@dnd-kit/core";
|
||||
|
||||
interface ExtendedTreeItem extends TreeItem {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
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-sm text-neutral mb-1">
|
||||
<p className="text-xs font-bold text-neutral mb-1">
|
||||
{t("display_on_dashboard")}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
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, useLinks } from "@linkwarden/router/links";
|
||||
import { useGetLink } 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";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
export function DashboardLinks({
|
||||
@@ -82,8 +82,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
@@ -102,7 +100,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
}, [collections, link]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
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();
|
||||
const updateLink = useUpdateLink({ toast, t });
|
||||
const pinLink = usePinLink();
|
||||
const { data: user } = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -104,25 +104,7 @@ export default function DragNDrop({
|
||||
updatedLink: LinkIncludingShortenedCollectionAndTags,
|
||||
opts?: { invalidateDashboardOnError?: boolean }
|
||||
) => {
|
||||
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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
updateLink.mutateAsync(updatedLink);
|
||||
};
|
||||
|
||||
// DROP ON TAG
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import importBookmarks from "@/lib/client/importBookmarks";
|
||||
import { MigrationFormat } from "@linkwarden/types";
|
||||
import { MigrationFormat } from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
@@ -113,7 +113,7 @@ export default function LinkDetails({
|
||||
);
|
||||
};
|
||||
|
||||
const updateLink = useUpdateLink();
|
||||
const updateLink = useUpdateLink({ toast, t });
|
||||
const updateFile = useUpdateFile();
|
||||
|
||||
const submit = async (e?: any) => {
|
||||
@@ -126,21 +126,9 @@ export default function LinkDetails({
|
||||
return;
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
updateLink.mutateAsync(link);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
setMode && setMode("view");
|
||||
};
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useArchiveAction, useBulkDeleteLinks } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
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();
|
||||
const deleteLink = useDeleteLink({ toast, t });
|
||||
|
||||
const updateArchive = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
@@ -131,13 +131,7 @@ export default function LinkActions({
|
||||
onClick={async (e) => {
|
||||
if (e.shiftKey) {
|
||||
const load = toast.loading(t("deleting"));
|
||||
await deleteLink.mutateAsync(link.id as number, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) toast.error(error.message);
|
||||
else toast.success(t("deleted"));
|
||||
},
|
||||
});
|
||||
await deleteLink.mutateAsync(link.id as number);
|
||||
} else {
|
||||
setDeleteLinkModal(true);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
|
||||
@@ -2,7 +2,7 @@ import Icon from "@/components/Icon";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import React from "react";
|
||||
|
||||
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { formatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Image from "next/image";
|
||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
@@ -30,6 +30,10 @@ function LinkIcon({
|
||||
|
||||
const [faviconLoaded, setFaviconLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFaviconLoaded(false);
|
||||
}, [link.url]);
|
||||
|
||||
return (
|
||||
<div onClick={() => onClick && onClick()}>
|
||||
{link.icon ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import React, { useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
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";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||
|
||||
@@ -66,7 +66,13 @@ 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)} />
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import Modal from "../Modal";
|
||||
@@ -22,7 +21,6 @@ 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);
|
||||
|
||||
@@ -30,32 +28,15 @@ export default function DeleteCollectionModal({
|
||||
setCollection(activeCollection);
|
||||
}, []);
|
||||
|
||||
const deleteCollection = useDeleteCollection();
|
||||
const deleteCollection = useDeleteCollection({ toast, t });
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
if (!collection) return null;
|
||||
if (!collection) return null;
|
||||
|
||||
setSubmitLoader(true);
|
||||
deleteCollection.mutateAsync(collection.id as number);
|
||||
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
router.push("/collections");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
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();
|
||||
const deleteLink = useDeleteLink({ toast, t });
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,26 +26,15 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
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 {
|
||||
if (
|
||||
router.pathname.startsWith("/links/[id]") ||
|
||||
router.pathname.startsWith("/preserved/[id]")
|
||||
) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
toast.success(t("deleted"));
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
});
|
||||
if (
|
||||
router.pathname.startsWith("/links/[id]") ||
|
||||
router.pathname.startsWith("/preserved/[id]")
|
||||
) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import Modal from "../Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isAdmin = data?.user?.id === config?.ADMIN;
|
||||
const isAdmin = data?.user?.id === (config?.ADMIN || 1);
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUpdateCollection } from "@linkwarden/router/collections";
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
Member,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import ProfilePhoto from "../ProfilePhoto";
|
||||
@@ -41,6 +41,9 @@ export default function EditCollectionSharingModal({
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
|
||||
const [propagateToSubcollections, setPropagateToSubcollections] =
|
||||
useState(false);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const updateCollection = useUpdateCollection();
|
||||
|
||||
@@ -53,19 +56,22 @@ export default function EditCollectionSharingModal({
|
||||
|
||||
const load = toast.loading(t("updating_collection"));
|
||||
|
||||
await updateCollection.mutateAsync(collection, {
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
await updateCollection.mutateAsync(
|
||||
{ ...collection, propagateToSubcollections },
|
||||
{
|
||||
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"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,6 +371,27 @@ 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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
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();
|
||||
const deleteLink = useDeleteLink({ toast, t });
|
||||
|
||||
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
|
||||
|
||||
@@ -51,13 +51,8 @@ export default function LinkModal({
|
||||
setTimeout(() => (document.body.style.pointerEvents = ""), 0);
|
||||
|
||||
if (e.shiftKey && 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"));
|
||||
},
|
||||
});
|
||||
deleteLink.mutateAsync(link.id);
|
||||
|
||||
onClose();
|
||||
} else {
|
||||
onDelete();
|
||||
|
||||
@@ -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";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCreateCollection } from "@linkwarden/router/collections";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -7,14 +7,17 @@ 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 { PostLinkSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import {
|
||||
PostLinkSchema,
|
||||
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: Function;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function NewLinkModal({ onClose }: Props) {
|
||||
@@ -31,10 +34,13 @@ 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();
|
||||
@@ -80,22 +86,17 @@ export default function NewLinkModal({ onClose }: Props) {
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
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"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { TokenExpiry } from "@linkwarden/types";
|
||||
import { TokenExpiry } from "@linkwarden/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -26,12 +26,20 @@ 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() {
|
||||
export default function Navbar({
|
||||
settings,
|
||||
admin,
|
||||
}: {
|
||||
settings?: boolean;
|
||||
admin?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data: user } = useUser();
|
||||
@@ -162,13 +170,23 @@ 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">
|
||||
<Sidebar />
|
||||
{admin ? (
|
||||
<AdminSidebar />
|
||||
) : settings ? (
|
||||
<SettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
</div>
|
||||
</ClickAwayHandler>
|
||||
</div>
|
||||
)}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
<NewLinkModal
|
||||
onClose={() => {
|
||||
setNewLinkModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
|
||||
@@ -40,7 +40,13 @@ export default function NoLinksFound({ text }: Props) {
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal
|
||||
onClose={() => {
|
||||
setNewLinkModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,8 @@ import { PreservationSkeleton } from "../Skeletons";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@linkwarden/types";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
formatAvailable,
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
} from "@linkwarden/types/global";
|
||||
import { formatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import getLinkTypeFromFormat from "@linkwarden/lib/getLinkTypeFromFormat";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -11,7 +11,7 @@ import usePermissions from "@/hooks/usePermissions";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import {
|
||||
useGetLinkHighlights,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ProfileDropdown() {
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
href="/admin/user-administration"
|
||||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
|
||||
@@ -1,16 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
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
|
||||
@@ -18,6 +84,15 @@ 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
|
||||
@@ -30,8 +105,15 @@ 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"));
|
||||
@@ -57,9 +139,75 @@ export default function SearchBar({ placeholder }: Props) {
|
||||
}
|
||||
}
|
||||
}}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@ import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useConfig } from "@linkwarden/router/config";
|
||||
import Image from "next/image";
|
||||
|
||||
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("");
|
||||
@@ -22,21 +20,46 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
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={`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="/settings/account">
|
||||
<div
|
||||
className={`${
|
||||
active === "/settings/account"
|
||||
? "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`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-person text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("account")}</p>
|
||||
<i className="bi-person text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("account")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -46,10 +69,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/preference"
|
||||
? "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`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-sliders text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("preference")}</p>
|
||||
<i className="bi-sliders text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("preference")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -59,10 +84,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/rss-subscriptions"
|
||||
? "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`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-rss text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">RSS Subscriptions</p>
|
||||
<i className="bi-rss text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
RSS Subscriptions
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -72,10 +99,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/access-tokens"
|
||||
? "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`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-key text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("access_tokens")}</p>
|
||||
<i className="bi-key text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("access_tokens")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -85,28 +114,15 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/password"
|
||||
? "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`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-lock text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("password")}</p>
|
||||
<i className="bi-lock text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{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
|
||||
@@ -114,10 +130,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/billing"
|
||||
? "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`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-credit-card text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("billing")}</p>
|
||||
<i className="bi-credit-card text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("billing")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
@@ -133,34 +151,40 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
</Link>
|
||||
<Link href="https://docs.linkwarden.app" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
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"></i>
|
||||
<p className="truncate w-full pr-7">{t("help")}</p>
|
||||
<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-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
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"></i>
|
||||
<p className="truncate w-full pr-7">{t("github")}</p>
|
||||
<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-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
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"></i>
|
||||
<p className="truncate w-full pr-7">{t("twitter")}</p>
|
||||
<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-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
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"></i>
|
||||
<p className="truncate w-full pr-7">{t("mastodon")}</p>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { Sort } from "@linkwarden/types";
|
||||
import { Sort } from "@linkwarden/types/global";
|
||||
import { TFunction } from "i18next";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { resetInfiniteQueryPagination } from "@linkwarden/router/links";
|
||||
|
||||
@@ -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";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import DeleteTagModal from "./ModalContent/DeleteTagModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -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";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { useDndContext } from "@dnd-kit/core";
|
||||
|
||||
interface TagListingProps {
|
||||
@@ -14,9 +14,6 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export type TextInputProps = React.ComponentPropsWithoutRef<"input">;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { ViewMode } from "@linkwarden/types";
|
||||
import { ViewMode } from "@linkwarden/types/global";
|
||||
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-sm text-neutral mb-1">{t("view")}</p>
|
||||
<p className="text-xs font-bold 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-sm text-neutral px-1 mb-1">{t("show")}</p>
|
||||
<p className="text-xs font-bold 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-sm text-neutral mb-1">
|
||||
<p className="text-xs font-bold text-neutral mb-1">
|
||||
{t("columns")}:{" "}
|
||||
{settings.columns === 0 ? t("default") : settings.columns}
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@linkwarden/types/inputSelect";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { isArchivalTag } from "@linkwarden/lib";
|
||||
import { isArchivalTag } from "@linkwarden/lib/isArchivalTag";
|
||||
|
||||
const useArchivalTags = (initialTags: Tag[]) => {
|
||||
const [archivalTags, setArchivalTags] = useState<ArchivalTagOption[]>([]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Member } from "@linkwarden/types";
|
||||
import { Member } from "@linkwarden/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Member } from "@linkwarden/types";
|
||||
import { Member } from "@linkwarden/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { SetStateAction, useEffect } from "react";
|
||||
|
||||
type Props<
|
||||
|
||||
38
apps/web/layouts/AdminLayout.tsx
Normal file
38
apps/web/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,8 @@ 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 { useLinks } from "@linkwarden/router/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,80 +1,38 @@
|
||||
import SettingsSidebar from "@/components/SettingsSidebar";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import React, { ReactNode } from "react";
|
||||
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 router = useRouter();
|
||||
|
||||
const [sidebar, setSidebar] = useState(false);
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
setSidebar(false);
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebar(false);
|
||||
}, [router]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebar(!sidebar);
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex max-w-screen-md mx-auto">
|
||||
<div className="hidden lg:block fixed h-screen">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
<div className="flex" data-testid="settings-wrapper">
|
||||
<div className="hidden lg:block">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<Link href="/dashboard">
|
||||
<i className="bi-chevron-left text-xl" />
|
||||
<i className="bi-chevron-left text-md" />
|
||||
<p>{t("back_to_dashboard")}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import { Link, UsersAndCollections } from "@linkwarden/prisma/client";
|
||||
import { UsersAndCollections } from "@linkwarden/prisma/client";
|
||||
import { removeFolder } from "@linkwarden/filesystem";
|
||||
import { meiliClient } from "@linkwarden/lib";
|
||||
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
|
||||
|
||||
export default async function deleteCollection(
|
||||
userId: number,
|
||||
|
||||
@@ -65,6 +65,61 @@ 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: {
|
||||
|
||||
@@ -4,6 +4,9 @@ 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,
|
||||
@@ -22,24 +25,60 @@ 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) {
|
||||
const findParentCollection = await prisma.collection.findUnique({
|
||||
where: {
|
||||
id: collection.parentId,
|
||||
},
|
||||
select: {
|
||||
ownerId: true,
|
||||
},
|
||||
if (typeof collection.parentId !== "number") {
|
||||
return {
|
||||
response: "Invalid parentId.",
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const permissionCheck = await getPermission({
|
||||
userId,
|
||||
collectionId: collection.parentId,
|
||||
});
|
||||
|
||||
if (
|
||||
findParentCollection?.ownerId !== userId ||
|
||||
typeof collection.parentId !== "number"
|
||||
)
|
||||
const memberHasAccess = permissionCheck?.members.some(
|
||||
(e: UsersAndCollections) =>
|
||||
e.userId === userId && e.canCreate && e.canUpdate && e.canDelete
|
||||
);
|
||||
|
||||
if (!memberHasAccess && permissionCheck?.ownerId !== userId) {
|
||||
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({
|
||||
@@ -49,35 +88,31 @@ 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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -85,13 +120,9 @@ export default async function postCollection(
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
where: { id: userId },
|
||||
data: {
|
||||
collectionOrder: {
|
||||
push: newCollection.id,
|
||||
},
|
||||
collectionOrder: { push: newCollection.id },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types";
|
||||
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types/global";
|
||||
|
||||
export default async function getDashboardData(
|
||||
userId: number,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { Order } from "@linkwarden/types";
|
||||
import { Order } from "@linkwarden/types/global";
|
||||
|
||||
export default async function getDashboardData(userId: number) {
|
||||
const order: Order = { id: "desc" };
|
||||
|
||||
@@ -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";
|
||||
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
|
||||
|
||||
export default async function deleteLinksById(
|
||||
userId: number,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import updateLinkById from "../linkId/updateLinkById";
|
||||
import { UpdateLinkSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types";
|
||||
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types/global";
|
||||
|
||||
export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||
if (process.env.DISABLE_DEPRECATED_ROUTES === "true")
|
||||
|
||||
@@ -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";
|
||||
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
|
||||
|
||||
export default async function deleteLink(userId: number, linkId: number) {
|
||||
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
PostLinkSchema,
|
||||
PostLinkSchemaType,
|
||||
} from "@linkwarden/lib/schemaValidation";
|
||||
import { hasPassedLimit } from "@linkwarden/lib";
|
||||
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
|
||||
|
||||
export default async function postLink(
|
||||
body: PostLinkSchemaType,
|
||||
|
||||
@@ -23,7 +23,6 @@ export default async function exportData(userId: number) {
|
||||
},
|
||||
},
|
||||
pinnedLinks: true,
|
||||
whitelistedUsers: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
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&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);
|
||||
// });
|
||||
});
|
||||
@@ -3,12 +3,14 @@ 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";
|
||||
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
|
||||
|
||||
export default async function importFromHTMLFile(
|
||||
userId: number,
|
||||
rawData: string
|
||||
) {
|
||||
// const importStartMs = Date.now();
|
||||
|
||||
const dom = new JSDOM(rawData);
|
||||
const document = dom.window.document;
|
||||
|
||||
@@ -33,6 +35,8 @@ export default async function importFromHTMLFile(
|
||||
|
||||
const processedArray = processNodes(jsonData);
|
||||
|
||||
// sortBookmarksTreeByEffectiveDate(processedArray, importStartMs, "asc");
|
||||
|
||||
for (const item of processedArray) {
|
||||
await processBookmarks(userId, item as Element);
|
||||
}
|
||||
@@ -49,7 +53,6 @@ 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"
|
||||
@@ -73,6 +76,7 @@ async function processBookmarks(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await processBookmarks(
|
||||
userId,
|
||||
item,
|
||||
@@ -80,14 +84,16 @@ 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(",")
|
||||
@@ -122,7 +128,7 @@ async function processBookmarks(
|
||||
linkDate
|
||||
);
|
||||
} else if (linkUrl) {
|
||||
// create a collection named "Imported Bookmarks" and add the link to it
|
||||
// create a collection named "Imports" and add the link to it
|
||||
const collectionId = await createCollection(userId, "Imports");
|
||||
|
||||
await createLink(
|
||||
@@ -138,7 +144,6 @@ async function processBookmarks(
|
||||
|
||||
await processBookmarks(userId, item, parentCollectionId);
|
||||
} else {
|
||||
// process anything else
|
||||
await processBookmarks(userId, item, parentCollectionId);
|
||||
}
|
||||
}
|
||||
@@ -207,9 +212,11 @@ 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) {
|
||||
@@ -282,7 +289,6 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,3 +302,87 @@ 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);
|
||||
// }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { Backup } from "@linkwarden/types";
|
||||
import { Backup } from "@linkwarden/types/global";
|
||||
import { createFolder } from "@linkwarden/filesystem";
|
||||
import { hasPassedLimit } from "@linkwarden/lib";
|
||||
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
|
||||
|
||||
export default async function importFromLinkwarden(
|
||||
userId: number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { createFolder } from "@linkwarden/filesystem";
|
||||
import { hasPassedLimit } from "@linkwarden/lib";
|
||||
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
|
||||
|
||||
type OmnivoreItem = {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { createFolder } from "@linkwarden/filesystem";
|
||||
import { hasPassedLimit } from "@linkwarden/lib";
|
||||
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
|
||||
import Papa from "papaparse";
|
||||
|
||||
type PocketBackup = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { createFolder } from "@linkwarden/filesystem";
|
||||
import { hasPassedLimit } from "@linkwarden/lib";
|
||||
import { hasPassedLimit } from "@linkwarden/lib/verifyCapacity";
|
||||
|
||||
type WallabagBackup = {
|
||||
is_archived: number;
|
||||
|
||||
@@ -2,8 +2,7 @@ import { prisma } from "@linkwarden/prisma";
|
||||
|
||||
export default async function getPublicUser(
|
||||
targetId: number | string,
|
||||
isId: boolean,
|
||||
requestingId?: number
|
||||
isId: boolean
|
||||
) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: isId
|
||||
@@ -20,59 +19,9 @@ export default async function getPublicUser(
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
whitelistedUsers: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
if (!user || !user.id) return { response: "User not found.", status: 404 };
|
||||
|
||||
const { password, ...lessSensitiveInfo } = user;
|
||||
|
||||
@@ -80,7 +29,6 @@ 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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types";
|
||||
import { meiliClient } from "@linkwarden/lib";
|
||||
import { LinkRequestQuery, Order, Sort } from "@linkwarden/types/global";
|
||||
import { meiliClient } from "@linkwarden/lib/meilisearchClient";
|
||||
import {
|
||||
buildMeiliFilters,
|
||||
buildMeiliQuery,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user