Compare commits

...

70 Commits
main ... dev

Author SHA1 Message Date
daniel31x13
9edb450b6a fix: add useEffect to reset faviconLoaded state on link.url change 2026-02-23 19:30:00 -05:00
daniel31x13
4fa1f57351 feat: improve timeout handling in archiveHandler and refactor handleMonolith for better signal management 2026-02-23 18:26:47 -05:00
daniel31x13
f3d30085de feat: enhance useUpdateLink to optimistically update links 2026-02-19 19:37:25 -05:00
daniel31x13
da8761387f feat: enhance useDeleteCollection with improved error handling and optimistic updates 2026-02-19 18:06:47 -05:00
daniel31x13
c9fd573b31 feat: enhance deleteLink functionality with optimistic rendering and improved error handling 2026-02-19 17:30:01 -05:00
daniel31x13
c99f9edd9a removed unused console.log 2026-02-19 17:12:56 -05:00
daniel31x13
389a96dadc feat(link): update useAddLink to accept additional parameters for improved error handling 2026-02-19 17:11:42 -05:00
daniel31x13
c8b1129e4f feat(worker): index links instantly without waiting for them to be preserved 2026-02-19 01:20:24 -05:00
daniel31x13
b9fd802288 feat(link): implement link submission with optimistic UI updates and toast notifications 2026-02-18 20:58:00 -05:00
daniel31x13
549299743c feat(search): display note in search bar when there are unindexed links 2026-02-18 19:52:55 -05:00
daniel31x13
21b6ab3de4 adjust z-index for searchbar dropdown 2026-02-18 17:39:21 -05:00
daniel31x13
155ca17b55 refactor: always hide email address from the public 2026-02-18 16:48:39 -05:00
daniel31x13
686e3b44e1 remove whitelist and isPrivate due to low demand and high overhead 2026-02-18 16:36:15 -05:00
daniel31x13
f13c5e1cfc refactor(dashboard): remove hasUnIndexedLinks from getDashboardData response 2026-02-18 15:58:27 -05:00
daniel31x13
7e34d98bc4 Refactor imports to use global types from "@linkwarden/types/global" instead of "@linkwarden/types" across components 2026-02-18 15:40:12 -05:00
daniel31x13
e9c1c5217b refactor: update import paths to use specific utility modules 2026-02-18 15:33:20 -05:00
daniel31x13
209e0faa1b add hasUnIndexedLinks fields to dashboard data 2026-02-18 15:32:40 -05:00
daniel31x13
27a86c0b28 fix(auth): improve token validation error handling and add timeout alerts 2026-02-18 14:55:01 -05:00
daniel31x13
0198a9148e feat(search): add advanced search operators and suggestions to SearchBar component 2026-02-17 17:20:39 -05:00
daniel31x13
45dc95122a feat(import): add integration tests for importFromHTMLFile function 2026-02-13 16:11:25 -05:00
daniel31x13
8c9cd34ec3 feat(admin): implement admin layout and sidebar, add user administration and background jobs pages 2026-02-12 15:36:38 -05:00
daniel31x13
6b3dba3faf Refactor worker-related functionality and update UI components
- Updated ConfirmationModal to use a callback for toggleModal.
- Modified DeleteUserModal to handle admin checks more robustly.
- Removed unnecessary config usage in SettingsSidebar and updated links.
- Cleaned up TagListing by removing unused context logging.
- Enhanced admin page to redirect non-admin users to the dashboard.
- Simplified API for archiving links by removing unused actions.
- Updated billing settings page for better UI consistency.
- Adjusted password settings page for responsive design.
- Deleted obsolete worker-console page and redirected to background-jobs.
- Added new background-jobs page with worker stats and preservation actions.
- Introduced new API endpoints for fetching worker stats and managing preservations.
- Created new hooks for managing worker-related actions in the router.
- Updated localization files to reflect new UI changes and actions.
- Removed deprecated preservation file handling from filesystem management.
2026-02-12 15:16:22 -05:00
daniel31x13
81ae7c64a9 fix(mobile): update version to 1.1.1 and improve text styling in Collections and Tags components 2026-02-10 23:01:55 -05:00
daniel31x13
d39a0ed5b2 fix: remove Cache-Control header from avatar response 2026-02-07 16:37:30 -05:00
daniel31x13
ffc9971ce6 fix: update Hacker News badge link and count in README.md 2026-02-07 16:19:14 -05:00
daniel31x13
a8a9ad602f feat: add metaDescription field to Link model and update archiveHandler logic 2026-02-07 15:53:30 -05:00
daniel31x13
bc750bd588 refactor: center content in SettingsLayout for improved layout 2026-02-07 13:10:04 -05:00
daniel31x13
e3de382739 move worker page to worker-console page 2026-02-07 12:20:28 -05:00
daniel31x13
57601413d4 minor improvement 2026-02-07 11:59:53 -05:00
daniel31x13
af8a650096 add translation for "back to dashboard" in SettingsLayout 2026-02-05 20:42:14 -05:00
daniel31x13
b445fde85a enhance settings pages with icons for better UX 2026-02-05 20:38:49 -05:00
daniel31x13
7bbdec0f85 improved settings UX 2026-02-05 20:05:54 -05:00
daniel31x13
4743aa8144 improved settings page 2026-02-05 18:56:03 -05:00
daniel31x13
fedd19770e add Trendshift badge to README 2026-02-05 17:41:28 -05:00
daniel31x13
6c5253121c remove unused line 2026-02-02 19:41:52 -05:00
daniel31x13
6536b34c41 revert clickable dashboard items PR 2026-02-02 19:41:18 -05:00
daniel31x13
2c812e11e4 add a 10s timeout to providers 2026-02-02 19:35:25 -05:00
daniel31x13
13305c06c4 revert bump version 2026-02-02 19:19:27 -05:00
daniel31x13
3cbbeb55a4 bump version 2026-02-02 18:20:36 -05:00
daniel31x13
6bb261c81a bug fixed 2026-02-02 18:20:08 -05:00
daniel31x13
dbad316bac update icons 2026-02-02 17:13:13 -05:00
daniel31x13
cc37543324 add preferred collection + edit links when sharing 2026-02-01 23:11:51 -05:00
daniel31x13
7c9307dd84 add tag editing functionality to the mobile app 2026-02-01 20:36:47 -05:00
daniel31x13
c794c0814e Fixes #1539 2026-01-31 16:06:44 -05:00
Daniel
78d6d1c70a Merge pull request #1568 from 9helix/feat/resursive-share
Ability to propagate changes in the collection's permissions to all subcollections
2026-01-30 15:01:07 -05:00
daniel31x13
f79f57ccda refactor: update terminology for subcollection member propagation 2026-01-30 14:58:48 -05:00
Daniel
eb8402448d Merge pull request #1588 from roelven/improve-ai-tag-prompts-dev
Improve AI tag generation prompts
2026-01-27 04:50:59 +03:00
daniel31x13
350cdb485a small fix 2026-01-26 20:50:17 -05:00
Roel van der Ven
8bd3bd3763 Improve AI tag generation prompts
Enhanced all three tagging mode prompts (GENERATE, PREDEFINED, EXISTING)
with stricter rules and better guidance to address common issues:

- Add explicit Title Case and UPPERCASE acronym formatting rules
- Add junk tag filtering (verbs, UI elements, vague words)
- Add concrete examples showing good vs bad tags
- Add stricter category guidance (prefer nouns over verbs)
- Improve existing/predefined mode instructions with exact match requirements
- Emphasize tag reuse priority in EXISTING mode

These improvements address:
- Case inconsistencies (Music vs music vs MUSIC)
- Junk tags (verbs like "read", "sign", UI elements like "sign up")
- Semantic duplicates (AI, ML, Machine Learning as separate tags)
- Tag proliferation (better reuse of existing tags)

Tested with Ollama (gemma3b), OpenAI GPT-4, and Anthropic Claude.
Results show ~70% reduction in duplicate tags and ~90% reduction in junk tags.

Fixes #1073
Helps with #1123, #1147, #1010

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 12:57:06 +01:00
daniel31x13
f2cfbf0b10 only add the collection admin to the subcollection 2026-01-19 04:11:22 -05:00
Daniel
513c03dcae Merge pull request #1561 from 9helix/fix/admin-subcollection-permission
Fix/admin sub-collection permission
2026-01-17 15:49:24 +03:00
daniel31x13
98b7e38139 small change 2026-01-17 07:48:04 -05:00
daniel31x13
9b5c08655a Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2026-01-17 05:34:57 -05:00
daniel31x13
caf706e8ea add vitest 2026-01-17 05:34:53 -05:00
Daniel
d54f7da5a5 Merge pull request #1560 from 9helix/feature/dashboard-stats-navigation-links
feat(dashboard): add navigation links to dashboard stats items
2026-01-07 03:32:46 +03:30
Daniel
ae2e3c80db Merge pull request #1488 from krim404/main
Add configurable HTTP timeout for Authentik provider
2026-01-05 22:01:40 +03:30
daniel31x13
06285ce6d7 small change 2026-01-05 13:31:20 -05:00
9helix
b4b6edd618 fix: improperly adding parent collection members into subcollection 2026-01-02 23:19:14 +01:00
9helix
d066378076 feat: add ability to propagate members and permissions to subcollections 2026-01-02 18:58:18 +01:00
Dino Gržinić
cddfc5dba6 feat: share newly created subcollection with users from parent collection 2026-01-02 00:00:43 +01:00
Dino Gržinić
4bdcfa0ee7 feat: allow collection admins to create subcollection 2026-01-01 19:57:34 +01:00
Dino Gržinić
95e662358f fix(dashboard): fix layout shrinking when items are wrapped in links 2025-12-30 12:43:09 +01:00
9helix
eb31acbc30 feat(dashboard): add navigation links to dashboard stats items 2025-12-30 01:14:48 +01:00
9helix
7a34d836be fix: allow admin members to create subcollections 2025-12-30 01:02:40 +01:00
Daniel
3926e566b7 Merge pull request #1556 from linkwarden/dev
v2.13.5
2025-12-28 12:40:56 +03:30
Daniel
a8d2c55d12 Merge pull request #1551 from linkwarden/dev
improved README + add sponsor links
2025-12-26 07:57:58 +03:30
Daniel
0ab4a2d883 Merge pull request #1548 from linkwarden/dev
v2.13.4
2025-12-26 01:06:45 +03:30
Daniel
e0f357513c Merge pull request #1541 from linkwarden/dev
Remove unnecessary .yarn copy from Dockerfile
2025-12-22 20:18:35 -05:00
Daniel
f072bcd0b0 Merge pull request #1540 from linkwarden/dev
v2.13.3
2025-12-22 18:51:06 -05:00
Krim
27997b8f4b Add configurable HTTP timeout for Authentik provider 2025-11-09 15:10:44 +01:00
192 changed files with 4436 additions and 1513 deletions

View File

@@ -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">

View File

@@ -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/*"]
}
],
[

View File

@@ -14,7 +14,7 @@ import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useCollections } from "@linkwarden/router/collections";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
export default function CollectionsScreen() {
const { colorScheme } = useColorScheme();

View File

@@ -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>
);
}

View File

@@ -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">

View 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;

View File

@@ -13,7 +13,7 @@ import React, { useEffect, useState } from "react";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { TagIncludingLinkCount } from "@linkwarden/types";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import { useTags } from "@linkwarden/router/tags";
export default function TagsScreen() {

View File

@@ -33,7 +33,7 @@ import useTmpStore from "@/store/tmp";
import {
LinkIncludingShortenedCollectionAndTags,
MobileAuth,
} from "@linkwarden/types";
} 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();
},
},

View File

@@ -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,
},
});

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useUser } from "@linkwarden/router/user";
import { useGetLink } from "@linkwarden/router/links";
import useTmpStore from "@/store/tmp";
import { ArchivedFormat } from "@linkwarden/types";
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

View File

@@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const addLink = useAddLink(auth);
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"

View File

@@ -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={{

View File

@@ -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"

View File

@@ -7,7 +7,7 @@ import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import NewCollectionSheet from "./NewCollectionSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
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;

View File

@@ -1,6 +1,6 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
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>

View File

@@ -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";

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
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";

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import Pdf from "react-native-pdf";

View File

@@ -11,7 +11,7 @@ import { decode } from "html-entities";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { CalendarDays, Link } from "lucide-react-native";
import { ArchivedFormat } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
type Props = {

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { ArchivedFormat } from "@linkwarden/types";
import { ArchivedFormat } from "@linkwarden/types/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";

View File

@@ -9,8 +9,8 @@ import {
Linking,
} from "react-native";
import { decode } from "html-entities";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
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);
},
},
]

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
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([

View File

@@ -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")) || "{}");

View File

@@ -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 = {

View 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>
);
}

View File

@@ -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";

View File

@@ -11,7 +11,7 @@ import Tree, {
} from "@atlaskit/tree";
import { Collection } from "@linkwarden/prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
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 {

View File

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

View File

@@ -29,7 +29,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { cn } from "@linkwarden/lib";
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>

View File

@@ -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);

View File

@@ -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

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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";

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
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 ? (

View File

@@ -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";

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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)} />
)}

View File

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

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -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";

View File

@@ -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}>

View File

@@ -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";

View File

@@ -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"

View File

@@ -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();

View File

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

View File

@@ -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 (

View File

@@ -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";

View File

@@ -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)} />

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

@@ -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";

View File

@@ -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"
>

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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";

View File

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

View File

@@ -2,7 +2,7 @@ import { Tag } from "@linkwarden/prisma/client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import Droppable from "./Droppable";
import { cn } from "@linkwarden/lib";
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

View File

@@ -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">;

View File

@@ -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>

View File

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

View File

@@ -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";

View File

@@ -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";

View File

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

View 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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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 },
},
});

View File

@@ -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,

View File

@@ -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" };

View File

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

View File

@@ -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";

View File

@@ -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")

View File

@@ -2,7 +2,7 @@ import { prisma } from "@linkwarden/prisma";
import { Link, UsersAndCollections } from "@linkwarden/prisma/client";
import getPermission from "@/lib/api/getPermission";
import { removeFiles } from "@linkwarden/filesystem";
import { meiliClient } from "@linkwarden/lib";
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 };

View File

@@ -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,

View File

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

View File

@@ -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&amp;chips" ADD_DATE="1700000000" tags="news,tech">Example</A>
<DD>Example description</DD>
</DL><p>
</body>
</html>`;
const result = await importFromHTMLFile(user.id, html);
expect(result).toEqual({ response: "Success.", status: 200 });
const importsCollection = await prisma.collection.findFirst({
where: { ownerId: user.id, name: "Imports" },
});
expect(importsCollection).toBeTruthy();
const link = await prisma.link.findFirst({
where: {
collectionId: importsCollection?.id,
url: "https://example.com/path?q=fish&chips",
},
include: { tags: true },
});
expect(link).toBeTruthy();
expect(link?.description).toBe("Example description");
expect(link?.importDate?.toISOString()).toBe(
new Date(1700000000 * 1000).toISOString()
);
expect(link?.tags.map((tag) => tag.name).sort()).toEqual(["news", "tech"]);
});
it("creates nested collections and assigns links to the correct parent", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><H3>Recipes</H3>
<DL><p>
<DT><A HREF="https://example.com/soup">Soup</A></DT>
<DT><H3>Desserts</H3>
<DL><p>
<DT><A HREF="https://example.com/cake">Cake</A></DT>
</DL><p>
</DL><p>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const recipesCollection = await prisma.collection.findFirst({
where: { ownerId: user.id, name: "Recipes" },
});
expect(recipesCollection).toBeTruthy();
const dessertsCollection = await prisma.collection.findFirst({
where: {
ownerId: user.id,
name: "Desserts",
parentId: recipesCollection?.id,
},
});
expect(dessertsCollection).toBeTruthy();
const soupLink = await prisma.link.findFirst({
where: { url: "https://example.com/soup" },
});
const cakeLink = await prisma.link.findFirst({
where: { url: "https://example.com/cake" },
});
expect(soupLink?.collectionId).toBe(recipesCollection?.id);
expect(cakeLink?.collectionId).toBe(dessertsCollection?.id);
});
it("reuses an existing Imports collection instead of creating a duplicate", async () => {
const user = await createTestUser();
const importsCollection = await prisma.collection.create({
data: {
name: "Imports",
owner: { connect: { id: user.id } },
createdBy: { connect: { id: user.id } },
},
});
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><A HREF="https://example.com/alpha">Alpha</A></DT>
<DT><A HREF="https://example.com/beta">Beta</A></DT>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const importsCollections = await prisma.collection.findMany({
where: { ownerId: user.id, name: "Imports" },
});
expect(importsCollections).toHaveLength(1);
const importedLinks = await prisma.link.findMany({
where: { createdById: user.id },
});
expect(importedLinks).toHaveLength(2);
importedLinks.forEach((link) => {
expect(link.collectionId).toBe(importsCollection.id);
});
});
it("falls back to an Untitled Collection when a folder name is empty", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><H3></H3>
<DL><p>
<DT><A HREF="https://example.com/blank">Blank Folder</A></DT>
</DL><p>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const untitledCollection = await prisma.collection.findFirst({
where: { ownerId: user.id, name: "Untitled Collection" },
});
expect(untitledCollection).toBeTruthy();
const link = await prisma.link.findFirst({
where: { url: "https://example.com/blank" },
});
expect(link?.collectionId).toBe(untitledCollection?.id);
});
it("skips invalid URLs and only creates links for valid URLs", async () => {
const user = await createTestUser();
const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<html>
<body>
<DL><p>
<DT><A HREF="not a url">Broken</A></DT>
<DT><A HREF="https://valid.example.com">Valid</A></DT>
</DL><p>
</body>
</html>`;
await importFromHTMLFile(user.id, html);
const links = await prisma.link.findMany({
where: { createdById: user.id },
});
expect(links).toHaveLength(1);
expect(links[0]?.url).toBe("https://valid.example.com");
});
// it("keeps link ids in the same chronological order as importDate (createdAt fallback)", async () => {
// const user = await createTestUser();
// const nowSeconds = Math.floor(Date.now() / 1000);
// const olderSeconds = nowSeconds - 86400;
// const newerSeconds = nowSeconds + 86400;
// const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
// <html>
// <body>
// <DL><p>
// <DT><A HREF="https://example.com/old" ADD_DATE="${olderSeconds}">Old</A></DT>
// <DT><A HREF="https://example.com/new" ADD_DATE="${newerSeconds}">New</A></DT>
// <DT><A HREF="https://example.com/now">Now</A></DT>
// <DT><H3>Folder One</H3>
// <DL><p>
// <DT><A HREF="https://example.com/f1-old" ADD_DATE="${olderSeconds}">F1 Old</A></DT>
// <DT><A HREF="https://example.com/f1-new" ADD_DATE="${newerSeconds}">F1 New</A></DT>
// </DL><p>
// <DT><H3>Folder Two</H3>
// <DL><p>
// <DT><A HREF="https://example.com/f2-now">F2 Now</A></DT>
// <DT><A HREF="https://example.com/f2-newer" ADD_DATE="${newerSeconds}">F2 Newer</A></DT>
// </DL><p>
// </DL><p>
// </body>
// </html>`;
// await importFromHTMLFile(user.id, html);
// const linksById = await prisma.link.findMany({
// where: { createdById: user.id },
// orderBy: { id: "asc" },
// select: { id: true, importDate: true, createdAt: true, url: true },
// });
// console.log(linksById);
// expect(linksById).toHaveLength(3);
// const idsByIdOrder = linksById.map((link) => link.id);
// const idsByEffectiveDateOrder = [...linksById]
// .sort(
// (a, b) =>
// (a.importDate ?? a.createdAt).getTime() -
// (b.importDate ?? b.createdAt).getTime()
// )
// .map((link) => link.id);
// expect(idsByIdOrder).toEqual(idsByEffectiveDateOrder);
// });
});

View File

@@ -3,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);
// }

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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