Merge pull request #1417 from linkwarden/dev

v2.13.0
This commit is contained in:
Daniel
2025-09-25 09:39:51 -04:00
committed by GitHub
84 changed files with 3043 additions and 570 deletions

View File

@@ -10,14 +10,14 @@
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.linkwarden"
"bundleIdentifier": "app.linkwarden"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.anonymous.linkwarden"
"package": "app.linkwarden"
},
"web": {
"bundler": "metro",
@@ -32,7 +32,12 @@
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#FFFFFF",
"dark": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#171717"
}
}
],
"expo-secure-store",

View File

@@ -6,6 +6,9 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect, useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import { useCollections } from "@linkwarden/router/collections";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
@@ -20,6 +23,7 @@ export default function LinksScreen() {
section?: "pinned-links" | "recent-links" | "collection";
collectionId?: string;
}>();
const { colorScheme } = useColorScheme();
const navigation = useNavigation();
const collections = useCollections(auth);
@@ -59,8 +63,16 @@ export default function LinksScreen() {
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
onRefresh={() => data.refetch()}
refreshing={data.isRefetching}
refreshControl={
<Spinner
refreshing={data.isRefetching}
onRefresh={() => data.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (

View File

@@ -1,7 +1,6 @@
import {
FlatList,
Platform,
RefreshControl,
ScrollView,
StyleSheet,
Text,
@@ -31,6 +30,7 @@ import {
Hash,
Link,
} from "lucide-react-native";
import Spinner from "@/components/ui/Spinner";
export default function DashboardScreen() {
const { auth } = useAuthStore();
@@ -240,7 +240,6 @@ export default function DashboardScreen() {
data={
links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []
}
// onRefresh={() => data.refetch()}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
@@ -306,7 +305,6 @@ export default function DashboardScreen() {
horizontal
showsHorizontalScrollIndicator={false}
data={collectionLinks || []}
// onRefresh={() => data.refetch()}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
@@ -348,12 +346,16 @@ export default function DashboardScreen() {
>
<ScrollView
refreshControl={
<RefreshControl
<Spinner
refreshing={dashboardData.isLoading || userData.isLoading}
onRefresh={() => {
dashboardData.refetch();
userData.refetch();
}}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
contentContainerStyle={{

View File

@@ -5,6 +5,9 @@ import LinkListing from "@/components/LinkListing";
import { useLocalSearchParams } from "expo-router";
import React from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
@@ -13,6 +16,7 @@ const RenderItem = React.memo(
);
export default function LinksScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
@@ -35,7 +39,16 @@ export default function LinksScreen() {
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
onRefresh={() => data.refetch()}
refreshControl={
<Spinner
refreshing={data.isRefetching}
onRefresh={() => data.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={data.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}

View File

@@ -18,12 +18,14 @@ import {
FileText,
Globe,
LogOut,
Mail,
Moon,
Smartphone,
Sun,
} from "lucide-react-native";
import useDataStore from "@/store/data";
import { ArchivedFormat } from "@/types/global";
import * as Clipboard from "expo-clipboard";
export default function SettingsScreen() {
const { signOut, auth } = useAuthStore();
@@ -195,6 +197,29 @@ export default function SettingsScreen() {
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Contact Us</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={async () => {
await Clipboard.setStringAsync("support@linkwarden.app");
Alert.alert("Copied to clipboard", "support@linkwarden.app");
}}
>
<View className="flex-row items-center gap-2">
<Mail
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">
support@linkwarden.app
</Text>
</View>
</TouchableOpacity>
</View>
</View>
<Text className="mx-auto text-sm text-neutral">
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
{nativeApplicationVersion}

View File

@@ -169,6 +169,21 @@ export default function RootLayout() {
}),
}}
/>
<Stack.Screen
name="index"
options={{
navigationBarColor:
rawTheme[colorScheme as ThemeName]["base-100"],
...Platform.select({
android: {
statusBarStyle:
colorScheme === "light" ? "light" : "dark",
statusBarBackgroundColor:
rawTheme[colorScheme as ThemeName]["primary"],
},
}),
}}
/>
<Stack.Screen
name="incoming"
options={{

View File

@@ -46,7 +46,7 @@ export default function IncomingScreen() {
);
}, [auth, data.shareIntent.url]);
if (auth.status === "unauthenticated") return <Redirect href="/login" />;
if (auth.status === "unauthenticated") return <Redirect href="/" />;
return (
<SafeAreaView className="flex-1 bg-base-100">

View File

@@ -1,12 +1,71 @@
import { Button } from "@/components/ui/Button";
import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect } from "expo-router";
import { Redirect, router } from "expo-router";
import { useColorScheme } from "nativewind";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import Animated, { SlideInDown } from "react-native-reanimated";
export default function HomeScreen() {
const { auth } = useAuthStore();
const { colorScheme } = useColorScheme();
if (auth.session) {
return <Redirect href="/(tabs)/dashboard" />;
} else {
return <Redirect href="/login" />;
return <Redirect href="/dashboard" />;
}
return (
<Animated.View
entering={SlideInDown.springify().damping(100).stiffness(300)}
className="flex-col justify-end h-full bg-primary relative"
>
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
<Text className="text-base-100 text-4xl font-semibold mt-7 mx-auto">
Linkwarden
</Text>
</View>
<View>
<Text className="text-base-100 text-xl text-center font-semibold mx-4 mt-3">
Welcome to the official mobile app for Linkwarden!
</Text>
<Text className="text-base-100 text-xl text-center mx-4 mt-3">
Expect regular improvements and new features as we continue refining
the experience.
</Text>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
height={100}
>
<Path
fill={rawTheme[colorScheme as ThemeName]["base-100"]}
fill-opacity="1"
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<View className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
<Button
variant="accent"
size="lg"
onPress={() => router.push("/login")}
>
<Text className="text-white text-xl">Get Started</Text>
</Button>
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</View>
</Animated.View>
);
}

View File

@@ -4,8 +4,9 @@ import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect } from "expo-router";
import { useColorScheme } from "nativewind";
import { useState } from "react";
import { View, Text, Dimensions, TouchableOpacity } from "react-native";
import { useEffect, useState } from "react";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
export default function HomeScreen() {
@@ -17,16 +18,67 @@ export default function HomeScreen() {
user: "",
password: "",
token: "",
instance: "",
instance: auth.instance || "https://cloud.linkwarden.app",
});
const [showInstanceField, setShowInstanceField] = useState(
form.instance !== "https://cloud.linkwarden.app"
);
useEffect(() => {
setForm((prev) => ({
...prev,
instance: auth.instance || "https://cloud.linkwarden.app",
}));
}, [auth.instance]);
useEffect(() => {
setShowInstanceField(form.instance !== "https://cloud.linkwarden.app");
}, [form.instance]);
useEffect(() => {
setForm((prev) => ({
...prev,
token: "",
user: "",
password: "",
}));
}, [method]);
if (auth.status === "authenticated") {
return <Redirect href="/dashboard" />;
}
return (
<View className="flex-col justify-end h-full bg-primary">
<View className="flex-col justify-end h-full bg-primary relative">
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
</View>
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
<View>
<Text className="text-base-100 text-2xl mx-8 mt-3" numberOfLines={1}>
Login to{" "}
{form.instance === "https://cloud.linkwarden.app"
? "cloud.linkwarden.app"
: form.instance}
</Text>
<TouchableOpacity
onPress={() => {
if (showInstanceField) {
setForm({ ...form, instance: "https://cloud.linkwarden.app" });
}
setShowInstanceField(!showInstanceField);
}}
className="mx-8 mt-1 self-start"
>
<Text className="text-neutral-content text-sm">
{!showInstanceField ? "Change server" : "Use official server"}
</Text>
</TouchableOpacity>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
@@ -38,19 +90,28 @@ export default function HomeScreen() {
d="M0,256L48,234.7C96,213,192,171,288,176C384,181,480,235,576,266.7C672,299,768,309,864,277.3C960,245,1056,171,1152,122.7C1248,75,1344,53,1392,42.7L1440,32L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z"
/>
</Svg>
<View className="flex-col justify-end h-1/3 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
<View className="flex-col justify-end h-auto duration-100 pt-10 bg-base-100 -mt-2 pb-10 gap-4 w-full px-4">
{showInstanceField && (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Instance URL"
selectTextOnFocus={false}
value={form.instance}
onChangeText={(text) => setForm({ ...form, instance: text })}
/>
)}
{method === "password" ? (
<>
<Input
className="w-full text-xl p-3 leading-tight"
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Email or Username"
value={form.user}
onChangeText={(text) => setForm({ ...form, user: text })}
/>
<Input
className="w-full text-xl p-3 leading-tight"
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Password"
secureTextEntry
@@ -60,7 +121,7 @@ export default function HomeScreen() {
</>
) : (
<Input
className="w-full text-xl p-3 leading-tight"
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Access Token"
secureTextEntry
@@ -77,25 +138,26 @@ export default function HomeScreen() {
>
<Text className="text-primary w-fit text-center">
{method === "password"
? "Login with Access Token instead"
: "Login with Username/Password instead"}
? "Login with Access Token"
: "Login with Username/Password"}
</Text>
</TouchableOpacity>
<Button
variant="accent"
size="lg"
onPress={() =>
signIn(
form.user,
form.password,
form.instance ? form.instance : undefined
)
}
onPress={() => {
if (((form.user && form.password) || form.token) && form.instance) {
signIn(form.user, form.password, form.instance, form.token);
}
}}
>
<Text className="text-white">Login</Text>
<Text className="text-white text-xl">Login</Text>
</Button>
<TouchableOpacity className="w-fit mx-auto">
<TouchableOpacity
className="w-fit mx-auto"
onPress={() => SheetManager.show("support-sheet")}
>
<Text className="text-neutral text-center w-fit">Need help?</Text>
</TouchableOpacity>
</View>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 82 B

View File

@@ -3,15 +3,18 @@ import {
RouteDefinition,
SheetDefinition,
} from "react-native-actions-sheet";
import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
registerSheet("support-sheet", SupportSheet);
registerSheet("add-link-sheet", AddLinkSheet);
registerSheet("edit-link-sheet", EditLinkSheet);
declare module "react-native-actions-sheet" {
interface Sheets {
"support-sheet": SheetDefinition;
"add-link-sheet": SheetDefinition;
"edit-link-sheet": SheetDefinition<{
payload: {

View File

@@ -0,0 +1,45 @@
import { Text, View } from "react-native";
import { useState } from "react";
import ActionSheet from "react-native-actions-sheet";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import * as Clipboard from "expo-clipboard";
import { Button } from "../ui/Button";
export default function SupportSheet() {
const { colorScheme } = useColorScheme();
const [copied, setCopied] = useState(false);
async function handleEmailPress() {
await Clipboard.setStringAsync("support@linkwarden.app");
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
}
return (
<ActionSheet
gestureEnabled
indicatorStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
}}
>
<View className="px-8 py-5 flex-col gap-4">
<Text className="text-2xl font-bold text-base-content">Need help?</Text>
<Text className="text-base-content">
Whether you have a question or need assistance, feel free to reach out
to us at support@linkwarden.app
</Text>
<Button onPress={handleEmailPress} variant="outline">
<Text className="text-base-content">
{copied ? "Copied!" : "Copy Support Email"}
</Text>
</Button>
</View>
</ActionSheet>
);
}

View File

@@ -0,0 +1,10 @@
import React, { forwardRef } from "react";
import { RefreshControl, RefreshControlProps } from "react-native";
const Spinner = forwardRef<RefreshControl, RefreshControlProps>(
(props, ref) => {
return <RefreshControl ref={ref} {...props} />;
}
);
export default Spinner;

View File

@@ -2,10 +2,16 @@ import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types";
import { Alert } from "react-native";
type AuthStore = {
auth: MobileAuth;
signIn: (username: string, password: string, instance?: string) => void;
signIn: (
username: string,
password: string,
instance: string,
token: string
) => void;
signOut: () => void;
setAuth: () => void;
};
@@ -14,7 +20,7 @@ const useAuthStore = create<AuthStore>((set) => ({
auth: {
instance: "",
session: null,
status: "loading",
status: "loading" as const,
},
setAuth: async () => {
const session = await SecureStore.getItemAsync("TOKEN");
@@ -31,57 +37,72 @@ const useAuthStore = create<AuthStore>((set) => ({
} else {
set({
auth: {
instance: "",
instance: instance || "https://cloud.linkwarden.app",
session: null,
status: "unauthenticated",
},
});
}
},
signIn: async (
username,
password,
instance = process.env.NODE_ENV === "production"
? "https://cloud.linkwarden.app"
: (process.env.EXPO_PUBLIC_LINKWARDEN_URL as string)
) => {
signIn: async (username, password, instance, token) => {
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
console.log("Signing into", instance);
await fetch(instance + "/api/v1/session", {
method: "POST",
body: JSON.stringify({ username, password }),
headers: {
"Content-Type": "application/json",
},
}).then(async (res) => {
if (res.ok) {
const data = await res.json();
const session = (data as any).response.token;
await SecureStore.setItemAsync("TOKEN", session);
await SecureStore.setItemAsync("INSTANCE", instance);
set({
auth: {
session,
instance,
status: "authenticated",
},
});
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) => {
if (res.ok) {
await SecureStore.setItemAsync("INSTANCE", instance);
await SecureStore.setItemAsync("TOKEN", token);
set({
auth: {
session: token,
instance,
status: "authenticated",
},
});
router.replace("/(tabs)/dashboard");
} else {
Alert.alert("Error", "Invalid token");
}
});
} else {
await fetch(instance + "/api/v1/session", {
method: "POST",
body: JSON.stringify({ username, password }),
headers: {
"Content-Type": "application/json",
},
}).then(async (res) => {
if (res.ok) {
const data = await res.json();
const session = (data as any).response.token;
await SecureStore.setItemAsync("TOKEN", session);
await SecureStore.setItemAsync("INSTANCE", instance);
set({
auth: {
session,
instance,
status: "authenticated",
},
});
router.replace("/(tabs)/dashboard");
} else {
set({
auth: {
instance,
session: null,
status: "unauthenticated",
},
});
}
});
router.replace("/(tabs)/dashboard");
} else {
Alert.alert("Error", "Invalid credentials");
}
});
}
},
signOut: async () => {
await SecureStore.deleteItemAsync("TOKEN");
await SecureStore.deleteItemAsync("INSTANCE");
set({
auth: {
instance: "",
@@ -90,7 +111,7 @@ const useAuthStore = create<AuthStore>((set) => ({
},
});
router.replace("/login");
router.replace("/");
},
}));

View File

@@ -35,9 +35,9 @@ export type LinkIncludingShortenedCollectionAndTags = {
};
export enum ArchivedFormat {
png,
jpeg,
pdf,
readability,
monolith,
png = 0,
jpeg = 1,
pdf = 2,
readability = 3,
monolith = 4,
}

View File

@@ -79,7 +79,7 @@ export default function CollectionCard({
size="icon"
className="absolute top-3 right-3 z-20"
>
<i title="More" className="bi-three-dots text-xl" />
<i title="More" className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>

View File

@@ -268,7 +268,7 @@ export default function DashboardLayoutDropdown() {
<TextInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="py-0"
className="py-0 bg-base-100"
placeholder={t("search")}
/>
</div>

View File

@@ -39,7 +39,7 @@ export function DashboardLinks({
}) {
return (
<div
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-72`}
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-fit`}
>
{isLoading ? (
<div className="flex flex-col gap-4 min-w-60 w-60">
@@ -198,7 +198,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
</div>
)}
<div className="flex flex-col justify-between h-full min-h-24">
<div className="flex flex-col justify-between h-full min-h-11">
<div className="p-3 flex flex-col justify-between h-full gap-2">
{show.name && (
<p className="line-clamp-2 w-full text-primary text-sm">

View File

@@ -0,0 +1,73 @@
import React from "react";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import toast from "react-hot-toast";
import { Separator } from "../ui/separator";
import { useBulkTagDeletion } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
selectedTags: number[];
setSelectedTags: (tags: number[]) => void;
};
export default function BulkDeleteTagsModal({
onClose,
selectedTags,
setSelectedTags,
}: Props) {
const { t } = useTranslation();
const deleteTagsById = useBulkTagDeletion();
const deleteTag = async () => {
const load = toast.loading(t("deleting"));
await deleteTagsById.mutateAsync(
{
tagIds: selectedTags,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedTags([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{selectedTags.length === 1
? t("delete_tag")
: t("delete_tags", { count: selectedTags.length })}
</p>
<Separator className="my-3" />
<div className="flex flex-col gap-3">
<p>
{selectedTags.length === 1
? t("tag_deletion_confirmation_message")
: t("tags_deletion_confirmation_message", {
count: selectedTags.length,
})}
</p>
<Button className="ml-auto" variant="destructive" onClick={deleteTag}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,58 @@
import React, { useEffect, useState } from "react";
import { TagIncludingLinkCount } from "@linkwarden/types";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import toast from "react-hot-toast";
import { Separator } from "../ui/separator";
import { useRemoveTag } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
activeTag: TagIncludingLinkCount;
};
export default function DeleteTagModal({ onClose, activeTag }: Props) {
const { t } = useTranslation();
const [tag, setTag] = useState<TagIncludingLinkCount>(activeTag);
const deleteTag = useRemoveTag();
useEffect(() => {
setTag(activeTag);
}, []);
const submit = async () => {
const load = toast.loading(t("deleting"));
await deleteTag.mutateAsync(tag.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
onClose();
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_tag")}</p>
<Separator className="my-3" />
<div className="flex flex-col gap-3">
<p>{t("tag_deletion_confirmation_message")}</p>
<Button className="ml-auto" variant="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,75 @@
import React, { useState } from "react";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import toast from "react-hot-toast";
import { Separator } from "../ui/separator";
import { useMergeTags } from "@linkwarden/router/tags";
import TextInput from "../TextInput";
type Props = {
onClose: Function;
selectedTags: number[];
setSelectedTags: (tags: number[]) => void;
};
export default function MergeTagsModal({
onClose,
selectedTags,
setSelectedTags,
}: Props) {
const { t } = useTranslation();
const [newTagName, setNewTagName] = useState("");
const mergeTags = useMergeTags();
const merge = async () => {
const load = toast.loading(t("merging"));
await mergeTags.mutateAsync(
{
tagIds: selectedTags,
newTagName,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedTags([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{t("merge_count_tags", { count: selectedTags.length })}
</p>
<Separator className="my-3" />
<div className="flex flex-col gap-3">
<p>{t("rename_tag_instruction")}</p>
<TextInput
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder={t("tag_name_placeholder")}
/>
<Button className="ml-auto" variant="accent" onClick={merge}>
<i className="bi-intersect text-xl" />
{t("merge_tags")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,77 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import { Separator } from "../ui/separator";
import { useUpsertTags } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
};
export default function NewTagModal({ onClose }: Props) {
const { t } = useTranslation();
const upsertTags = useUpsertTags();
const initial = {
label: "",
};
const [tag, setTag] = useState(initial);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating"));
await upsertTags.mutateAsync([tag], {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
} else {
onClose();
toast.success(t("created"));
}
},
});
}
};
const inputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
inputRef.current?.focus();
}, []);
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("create_new_tag")}</p>
<Separator className="my-3" />
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
ref={inputRef}
value={tag.label}
onChange={(e) => setTag({ ...tag, label: e.target.value })}
className="bg-base-200"
placeholder={t("tag_name_placeholder")}
/>
</div>
<div className="flex justify-end items-center mt-5">
<Button variant="accent" onClick={submit} disabled={!tag.label.trim()}>
{t("create_new_tag")}
</Button>
</div>
</Modal>
);
}

View File

@@ -34,8 +34,8 @@ export default function NoLinksFound({ text }: Props) {
}}
variant="accent"
>
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
<span className="group-hover:opacity-0 text-right w-full duration-100">
<i className="bi-plus-lg text-xl duration-100"></i>
<span className="group-hover:opacity-0 w-full duration-100">
{t("create_new_link")}
</span>
</Button>

View File

@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
@@ -8,8 +7,26 @@ import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import { useTags } from "@linkwarden/router/tags";
import { TagListing } from "./TagListing";
import { Button } from "./ui/button";
import { useUser } from "@linkwarden/router/user";
import Image from "next/image";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export default function Sidebar({ className }: { className?: string }) {
export default function Sidebar({
className,
toggleSidebar,
sidebarIsCollapsed,
}: {
className?: string;
toggleSidebar?: () => void;
sidebarIsCollapsed?: boolean;
}) {
const { t } = useTranslation();
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
const storedValue = localStorage.getItem("tagDisclosure");
@@ -30,6 +47,8 @@ export default function Sidebar({ className }: { className?: string }) {
const router = useRouter();
const { data: user } = useUser();
useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]);
@@ -48,99 +67,201 @@ export default function Sidebar({ className }: { className?: string }) {
return (
<div
id="sidebar"
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
className || ""
}`}
className={cn(
"bg-base-200 h-screen overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20",
className,
sidebarIsCollapsed ? "w-14" : "w-80"
)}
>
<div className="grid grid-cols-2 gap-2">
<SidebarHighlightLink
title={t("dashboard")}
href={`/dashboard`}
icon={"bi-house"}
active={active === `/dashboard`}
/>
<SidebarHighlightLink
title={t("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={t("all_links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={t("all_collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
/>
<div
className={cn(
"flex flex-col",
sidebarIsCollapsed
? "my-auto h-full justify-between items-center gap-3"
: "gap-1"
)}
>
<div className="flex items-center justify-between mb-4">
{sidebarIsCollapsed ? (
<Image
src={"/icon.png"}
width={640}
height={136}
alt="Linkwarden Icon"
className="h-8 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
/>
) : 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")}
/>
) : (
<Image
src={"/linkwarden_dark.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
/>
)}
{!sidebarIsCollapsed && (
<div className="hidden lg:block">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={toggleSidebar}
size={"icon"}
>
<i className={`bi-layout-sidebar`} />
</Button>
</TooltipTrigger>
<TooltipContent>
{sidebarIsCollapsed
? t("expand_sidebar")
: t("shrink_sidebar")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
<div
className={cn(
"flex flex-col",
sidebarIsCollapsed ? "my-auto justify-center gap-3" : "gap-1"
)}
>
<SidebarHighlightLink
title={t("dashboard")}
href={`/dashboard`}
icon={"bi-house"}
active={active === `/dashboard`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
<SidebarHighlightLink
title={t("tags")}
href={`/tags`}
icon={"bi-hash"}
active={active === `/tags`}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
</div>
{sidebarIsCollapsed && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={toggleSidebar} size={"icon"}>
<i className={`bi-layout-sidebar`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{sidebarIsCollapsed ? t("expand_sidebar") : t("shrink_sidebar")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<Disclosure defaultOpen={collectionDisclosure}>
<Disclosure.Button
onClick={() => {
setCollectionDisclosure(!collectionDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("collections")}</p>
<i
className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel>
<CollectionListing />
</Disclosure.Panel>
</Transition>
</Disclosure>
<Disclosure defaultOpen={tagDisclosure}>
<Disclosure.Button
onClick={() => {
setTagDisclosure(!tagDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("tags")}</p>
<i
className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : (
<TagListing tags={tags} active={active} />
)}
</Disclosure.Panel>
</Transition>
</Disclosure>
{sidebarIsCollapsed ? (
<></>
) : (
<>
<Disclosure defaultOpen={collectionDisclosure}>
<Disclosure.Button
onClick={() => {
setCollectionDisclosure(!collectionDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("collections")}</p>
<i
className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel>
<CollectionListing />
</Disclosure.Panel>
</Transition>
</Disclosure>
<Disclosure defaultOpen={tagDisclosure}>
<Disclosure.Button
onClick={() => {
setTagDisclosure(!tagDisclosure);
}}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
>
<p className="text-sm">{t("tags")}</p>
<i
className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate"
}`}
></i>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-1">
{isLoading ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : (
<TagListing tags={tags} active={active} />
)}
</Disclosure.Panel>
</Transition>
</Disclosure>
</>
)}
</div>
);
}

View File

@@ -1,37 +1,56 @@
import Link from "next/link";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export default function SidebarHighlightLink({
title,
href,
icon,
active,
sidebarIsCollapsed,
}: {
title: string;
href: string;
icon: string;
active?: boolean;
sidebarIsCollapsed?: boolean;
}) {
return (
<Link href={href}>
<div
title={title}
className={`${
active || false
? "bg-primary/20"
: "bg-neutral-content/20 hover:bg-neutral/20"
} duration-200 px-3 py-2 cursor-pointer gap-2 w-full rounded-lg capitalize`}
>
<div
className={
"w-10 h-10 inline-flex items-center justify-center bg-black/10 dark:bg-white/5 rounded-full"
}
>
<i className={`${icon} text-primary text-xl drop-shadow`}></i>
</div>
<div className={"mt-1"}>
<p className="truncate w-full font-semibold text-xs">{title}</p>
</div>
</div>
</Link>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link href={href} title={title}>
<div
className={cn(
active ? "bg-primary/20" : "hover:bg-neutral/20",
"duration-200 cursor-pointer flex items-center gap-2 capitalize",
sidebarIsCollapsed
? "rounded-md h-8 w-8"
: "rounded-lg px-3 py-1"
)}
>
<i
className={cn(
icon,
"text-primary text-xl drop-shadow",
sidebarIsCollapsed && "w-full text-center"
)}
></i>
{!sidebarIsCollapsed && (
<p className="truncate w-full font-semibold text-sm">{title}</p>
)}
</div>
</Link>
</TooltipTrigger>
{sidebarIsCollapsed && (
<TooltipContent side="right">{title}</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,118 @@
import React, { useState } from "react";
import { useTranslation } from "next-i18next";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { TagIncludingLinkCount } from "@linkwarden/types";
import DeleteTagModal from "./ModalContent/DeleteTagModal";
import { cn } from "@/lib/utils";
import { useRouter } from "next/router";
export default function TagCard({
tag,
editMode,
selected,
onSelect,
}: {
tag: TagIncludingLinkCount;
editMode: boolean;
selected: boolean;
onSelect: (tagId: number) => void;
}) {
const { t } = useTranslation();
const formattedDate = new Date(tag.createdAt).toLocaleString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
});
const [deleteTagModal, setDeleteTagModal] = useState(false);
const router = useRouter();
return (
<div
className={cn(
"relative rounded-xl p-2 shadow-md cursor-pointer hover:shadow-none hover:bg-opacity-70 duration-200 border border-neutral-content",
editMode ? "bg-base-300" : "bg-base-200",
selected && "border-primary"
)}
>
{editMode ? (
<Checkbox
checked={selected}
className="absolute top-3 right-3 z-20 pointer-events-none"
/>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 z-20"
onClick={(e) => e.stopPropagation()}
>
<i title="More" className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={4}
side="bottom"
align="end"
className="z-[30]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onSelect={() => setDeleteTagModal(true)}
className="text-error"
>
{t("delete_tag")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<div
className="flex gap-2 flex-col"
onClick={() =>
editMode ? onSelect(tag.id) : router.push(`/tags/${tag.id}`)
}
>
<h2 className="truncate leading-tight py-1 pr-8" title={tag.name}>
{tag.name}
</h2>
<div className="flex justify-between items-center mt-auto">
<div className="text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title={t("collection_publicly_shared")}
></i>
{formattedDate}
</div>
<div className="text-xs flex gap-1 items-center">
<i
className="bi-link-45deg text-lg leading-none text-neutral"
title={t("collection_publicly_shared")}
></i>
{tag._count?.links}
</div>
</div>
</div>
{deleteTagModal && (
<DeleteTagModal
onClose={() => setDeleteTagModal(false)}
activeTag={tag}
/>
)}
</div>
);
}

View File

@@ -1,59 +1,24 @@
import React, {
forwardRef,
ChangeEventHandler,
KeyboardEventHandler,
} from "react";
import { cn } from "@linkwarden/lib";
import React, { forwardRef } from "react";
type Props = {
autoFocus?: boolean;
value?: string;
type?: string;
placeholder?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
className?: string;
spellCheck?: boolean;
"data-testid"?: string;
};
export type TextInputProps = React.ComponentPropsWithoutRef<"input">;
const TextInput = forwardRef<HTMLInputElement, Props>(
(
{
autoFocus,
value,
type,
placeholder,
onChange,
onKeyDown,
className,
spellCheck,
"data-testid": dataTestId,
},
ref
) => {
const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ className, type = "text", ...rest }, ref) => {
return (
<input
ref={ref}
data-testid={dataTestId}
spellCheck={spellCheck}
autoFocus={autoFocus}
type={type ?? "text"}
placeholder={placeholder}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
className={`
w-full rounded-md p-2
border-neutral-content border-solid border
outline-none focus:border-primary duration-100
${className ?? ""}
`}
type={type}
className={cn(
"w-full rounded-md p-2 border-neutral-content border-solid border outline-none focus:border-primary duration-100",
className
)}
{...rest}
/>
);
}
);
// Give it a display name for easier debugging
TextInput.displayName = "TextInput";
export default TextInput;

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -8,7 +8,9 @@ interface Props {
children: ReactNode;
}
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default function AuthRedirect({ children }: Props) {
const router = useRouter();
@@ -22,11 +24,19 @@ export default function AuthRedirect({ children }: Props) {
const isLoggedIn = status === "authenticated";
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const trialEndTime =
new Date(user?.createdAt || 0).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
const hasInactiveSubscription =
user?.id &&
!user?.subscription?.active &&
!user.parentSubscription?.active &&
stripeEnabled;
STRIPE_ENABLED &&
(REQUIRE_CC || daysLeft <= 0);
// There are better ways of doing this... but this one works for now
const routes = [

View File

@@ -30,7 +30,7 @@ export default function CenteredForm({
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
className="h-12 w-auto mx-auto"
/>
) : (
<Image
@@ -38,7 +38,7 @@ export default function CenteredForm({
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
className="h-12 w-auto mx-auto"
/>
)}
{text && (

View File

@@ -10,27 +10,35 @@ interface Props {
export default function MainLayout({ children }: Props) {
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
const sidebarState = localStorage.getItem("sidebarIsCollapsed");
const [showAnnouncement, setShowAnnouncement] = useState(
showAnnouncementBar ? showAnnouncementBar === "true" : true
);
const [sidebarIsCollapsed, setSidebarIsCollapsed] = useState(
sidebarState ? sidebarState === "true" : false
);
useEffect(() => {
getLatestVersion(setShowAnnouncement);
}, []);
useEffect(() => {
if (showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "true");
setShowAnnouncement(true);
} else if (!showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "false");
setShowAnnouncement(false);
}
localStorage.setItem(
"showAnnouncementBar",
showAnnouncement ? "true" : "false"
);
}, [showAnnouncement]);
const toggleAnnouncementBar = () => {
setShowAnnouncement(!showAnnouncement);
};
useEffect(() => {
localStorage.setItem(
"sidebarIsCollapsed",
sidebarIsCollapsed ? "true" : "false"
);
}, [sidebarIsCollapsed]);
const toggleAnnouncementBar = () => setShowAnnouncement(!showAnnouncement);
const toggleSidebar = () => setSidebarIsCollapsed(!sidebarIsCollapsed);
return (
<div className="flex" data-testid="dashboard-wrapper">
@@ -38,11 +46,19 @@ export default function MainLayout({ children }: Props) {
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
)}
<div className="hidden lg:block">
<Sidebar className={`fixed top-0`} />
<Sidebar
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
toggleSidebar={toggleSidebar}
sidebarIsCollapsed={sidebarIsCollapsed}
/>
</div>
<div
className={`lg:w-[calc(100%-320px)] w-full sm:pb-0 pb-20 flex flex-col min-h-screen lg:ml-80`}
className={`${
sidebarIsCollapsed
? "lg:w-[calc(100%-56px)]"
: "lg:w-[calc(100%-320px)]"
} w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto`}
>
<Navbar />
{children}

View File

@@ -8,6 +8,14 @@ export default async function exportData(userId: number) {
include: {
rssSubscriptions: true,
links: {
omit: {
textContent: true, // Exclude to reduce payload size
preview: true,
image: true,
readable: true,
monolith: true,
pdf: true,
},
include: {
tags: true,
},

View File

@@ -0,0 +1,65 @@
import {
TagBulkDeletionSchema,
TagBulkDeletionSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { prisma } from "@linkwarden/prisma";
export default async function bulkTagDelete(
userId: number,
body: TagBulkDeletionSchemaType
) {
const dataValidation = TagBulkDeletionSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { tagIds } = dataValidation.data;
let deletedTag: number;
let affectedLinks: number[];
affectedLinks = (
await prisma.link.findMany({
where: {
tags: {
some: {
ownerId: userId,
},
},
},
select: {
id: true,
},
})
).map((link) => link.id);
deletedTag = (
await prisma.tag.deleteMany({
where: {
ownerId: userId,
id: {
in: tagIds,
},
},
})
).count;
await prisma.link.updateMany({
where: {
id: {
in: affectedLinks,
},
},
data: {
indexVersion: null,
},
});
return { response: deletedTag, status: 200 };
}

View File

@@ -0,0 +1,79 @@
import {
MergeTagsSchema,
MergeTagsSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { prisma } from "@linkwarden/prisma";
export default async function mergeTags(
userId: number,
body: MergeTagsSchemaType
) {
const dataValidation = MergeTagsSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { tagIds, newTagName } = dataValidation.data;
let affectedLinks: number[];
affectedLinks = (
await prisma.link.findMany({
where: {
tags: {
some: {
id: {
in: tagIds,
},
ownerId: userId,
},
},
},
select: {
id: true,
},
})
).map((link) => link.id);
const { newTag } = await prisma.$transaction(async (tx) => {
await tx.tag.deleteMany({
where: {
ownerId: userId,
id: {
in: tagIds,
},
},
});
const newTag = await tx.tag.create({
data: {
name: newTagName,
ownerId: userId,
links: {
connect: affectedLinks.map((id) => ({ id })),
},
},
});
await tx.link.updateMany({
where: {
id: {
in: affectedLinks,
},
},
data: {
indexVersion: null,
},
});
return { newTag };
});
return { response: newTag, status: 200 };
}

View File

@@ -37,7 +37,8 @@ export default async function postUser(
};
}
const { name, email, password, invite } = dataValidation.data;
const { name, email, password, invite, acceptPromotionalEmails } =
dataValidation.data;
let { username } = dataValidation.data;
if (invite && (!stripeEnabled || !emailEnabled)) {
@@ -109,6 +110,7 @@ export default async function postUser(
},
}
: undefined,
acceptPromotionalEmails: acceptPromotionalEmails || false,
dashboardSections: {
createMany: {
data: [

View File

@@ -2,6 +2,11 @@ import verifySubscription from "./stripe/verifySubscription";
import { prisma } from "@linkwarden/prisma";
import stripeSDK from "./stripe/stripeSDK";
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
const MANAGED_PAYMENTS_ENABLED =
process.env.MANAGED_PAYMENTS_ENABLED === "true";
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
export default async function paymentCheckout(email: string, priceId: string) {
const stripe = stripeSDK();
@@ -15,9 +20,22 @@ export default async function paymentCheckout(email: string, priceId: string) {
},
});
if (!user) {
return { response: "User not found", status: 404 };
}
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
const subscription = await verifySubscription(user);
if (subscription) {
if (
subscription?.subscriptions?.active ||
subscription?.parentSubscription?.active
) {
// To prevent users from creating multiple subscriptions
return { response: "/dashboard", status: 200 };
}
@@ -44,12 +62,22 @@ export default async function paymentCheckout(email: string, priceId: string) {
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}/dashboard`,
cancel_url: `${process.env.BASE_URL}/login`,
subscription_data: {
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
},
...(process.env.MANAGED_PAYMENTS_ENABLED === "true"
...(REQUIRE_CC
? {
subscription_data: {
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
},
}
: daysLeft > 0
? {
subscription_data: {
trial_period_days: daysLeft,
},
}
: {}),
...(MANAGED_PAYMENTS_ENABLED
? {
managed_payments: {
enabled: true,

View File

@@ -7,10 +7,25 @@ interface UserIncludingSubscription extends User {
parentSubscription: Subscription | null;
}
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default async function verifySubscription(
user?: UserIncludingSubscription | null
) {
if (!user || (!user.subscriptions && !user.parentSubscription)) {
if (!user) return null;
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (
!user.subscriptions &&
!user.parentSubscription &&
(REQUIRE_CC || daysLeft <= 0)
) {
return null;
}
@@ -19,8 +34,9 @@ export default async function verifySubscription(
}
if (
!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd
(!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd) &&
(REQUIRE_CC || daysLeft <= 0)
) {
const subscription = await checkSubscriptionByEmail(user.email as string);

View File

@@ -46,6 +46,17 @@ export default async function verifyUser({
return null;
}
if (
!user.emailVerified &&
process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"
) {
res.status(401).json({
response:
"Email not verified, please verify your email to continue using Linkwarden.",
});
return null;
}
if (STRIPE_SECRET_KEY) {
const subscribedUser = await verifySubscription(user);

View File

@@ -1,6 +1,6 @@
{
"name": "@linkwarden/web",
"version": "v2.12.2",
"version": "v2.13.0",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -30,6 +30,7 @@
"@linkwarden/types": "*",
"@phosphor-icons/core": "^2.1.1",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@@ -73,7 +74,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3",
"papaparse": "^5.5.3",
"playwright": "^1.45.0",
"playwright": "^1.55.0",
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "18.2.0",
@@ -97,7 +98,7 @@
"zustand": "^4.3.8"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.55.0",
"@types/bcrypt": "^5.0.0",
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3",

View File

@@ -10,6 +10,7 @@ import { UploadFileSchema } from "@linkwarden/lib/schemaValidation";
import isDemoMode from "@/lib/api/isDemoMode";
import getSuffixFromFormat from "@/lib/shared/getSuffixFromFormat";
import setCollection from "@/lib/api/setCollection";
import fetchTitleAndHeaders from "@/lib/shared/fetchTitleAndHeaders";
export const config = {
api: {
@@ -145,9 +146,11 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
if (!collection) {
throw new Error("Collection not found.");
}
const { title = "" } = url ? await fetchTitleAndHeaders(url) : {};
const link = await prisma.link.create({
data: {
name: title,
createdBy: {
connect: {
id: user.id,

View File

@@ -3,6 +3,7 @@ import getTags from "@/lib/api/controllers/tags/getTags";
import verifyUser from "@/lib/api/verifyUser";
import { PostTagSchema } from "@linkwarden/lib/schemaValidation";
import createOrUpdateTags from "@/lib/api/controllers/tags/createOrUpdateTags";
import bulkTagDelete from "@/lib/api/controllers/tags/bulkTagDelete";
export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
@@ -39,4 +40,15 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ response: newOrUpdatedTags });
}
if (req.method === "DELETE") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const tags = await bulkTagDelete(user.id, req.body);
return res.status(tags.status).json({ response: tags.response });
}
}

View File

@@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import mergeTags from "@/lib/api/controllers/tags/mergeTags";
export default async function merge(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "PUT") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const tags = await mergeTags(user.id, req.body);
return res.status(tags.status).json({ response: tags.response });
}
}

View File

@@ -1,34 +1,54 @@
import CollectionCard from "@/components/CollectionCard";
import { useState } from "react";
import { useMemo, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import { useSession } from "next-auth/react";
import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@linkwarden/types";
import useSort from "@/hooks/useSort";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import { useCollections } from "@linkwarden/router/collections";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function Collections() {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortedCollections, setSortedCollections] = useState(collections);
const { data: collections = [], isLoading } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const { data } = useSession();
useSort({ sortBy, setData: setSortedCollections, data: collections });
const sortKey: Sort =
typeof sortBy === "string" ? (Number(sortBy) as Sort) : sortBy;
const compare = useMemo(() => {
switch (sortKey) {
case Sort.NameAZ:
return (a: any, b: any) => a.name.localeCompare(b.name);
case Sort.NameZA:
return (a: any, b: any) => b.name.localeCompare(a.name);
case Sort.DateOldestFirst:
return (a: any, b: any) =>
new Date(a.createdAt as string).getTime() -
new Date(b.createdAt as string).getTime();
case Sort.DateNewestFirst:
default:
return (a: any, b: any) =>
new Date(b.createdAt as string).getTime() -
new Date(a.createdAt as string).getTime();
}
}, [sortKey]);
const sortedCollections = useMemo(
() => [...collections].sort(compare),
[collections, compare]
);
const [newCollectionModal, setNewCollectionModal] = useState(false);
@@ -42,23 +62,22 @@ export default function Collections() {
title={t("collections")}
description={t("collections_you_own")}
/>
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="text-neutral" variant="ghost" size="icon">
<i className={"bi-three-dots text-neutral text-xl"}></i>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem
onSelect={() => setNewCollectionModal(true)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setNewCollectionModal(true)}
>
<i className="bi-folder"></i>
{t("new_collection")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<i className="bi-plus-lg text-xl text-neutral"></i>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("new_collection")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex gap-3 justify-end">
<div className="relative mt-2">
@@ -67,23 +86,45 @@ export default function Collections() {
</div>
</div>
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
.map((e, i) => {
return <CollectionCard key={i} collection={e} />;
})}
{!isLoading && collections && !collections[0] ? (
<div
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
onClick={() => setNewCollectionModal(true)}
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
>
<p className="group-hover:opacity-0 duration-100">
{t("new_collection")}
<p className="text-center text-xl">
{t("create_your_first_collection")}
</p>
<i className="bi-plus-lg text-5xl group-hover:text-7xl group-hover:-mt-10 text-primary drop-shadow duration-100"></i>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
{t("create_your_first_collection_desc")}
</p>
<Button
className="mx-auto mt-5"
variant={"accent"}
onClick={() => setNewCollectionModal(true)}
>
<i className="bi-plus-lg text-xl mr-2" />
{t("new_collection")}
</Button>
</div>
</div>
) : (
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
.map((e) => (
<CollectionCard key={e.id} collection={e} />
))}
<div
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
onClick={() => setNewCollectionModal(true)}
>
<p className="group-hover:opacity-0 duration-100">
{t("new_collection")}
</p>
<i className="bi-plus-lg text-5xl group-hover:text-7xl group-hover:-mt-10 text-primary drop-shadow duration-100"></i>
</div>
</div>
)}
{sortedCollections.filter((e) => e.ownerId !== data?.user.id)[0] && (
<>
@@ -96,9 +137,9 @@ export default function Collections() {
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId !== data?.user.id)
.map((e, i) => {
return <CollectionCard key={i} collection={e} />;
})}
.map((e) => (
<CollectionCard key={e.id} collection={e} />
))}
</div>
</>
)}

View File

@@ -288,7 +288,7 @@ export default function Dashboard() {
setActiveLink={setActiveLink}
>
<MainLayout>
<div className="p-5 flex flex-col gap-4 h-full">
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<i className="bi-house-fill text-primary" />

View File

@@ -15,6 +15,7 @@ import { i18n } from "next-i18next.config";
import { Trans, useTranslation } from "next-i18next";
import { useConfig } from "@linkwarden/router/config";
import { Separator } from "@/components/ui/separator";
import Checkbox from "@/components/Checkbox";
type FormData = {
name: string;
@@ -22,6 +23,7 @@ type FormData = {
email?: string;
password: string;
passwordConfirmation: string;
acceptPromotionalEmails: boolean;
};
export default function Register({
@@ -39,6 +41,7 @@ export default function Register({
email: config?.EMAIL_PROVIDER ? "" : undefined,
password: "",
passwordConfirmation: "",
acceptPromotionalEmails: false,
});
async function registerUser(event: FormEvent<HTMLFormElement>) {
@@ -251,27 +254,41 @@ export default function Register({
</div>
{process.env.NEXT_PUBLIC_STRIPE && (
<div className="text-xs text-neutral mb-3">
<p>
<Trans
i18nKey="sign_up_agreement"
components={[
<Link
href="https://linkwarden.app/tos"
className="font-semibold"
data-testid="terms-of-service-link"
key={0}
/>,
<Link
href="https://linkwarden.app/privacy-policy"
className="font-semibold"
data-testid="privacy-policy-link"
key={1}
/>,
]}
/>
</p>
</div>
<>
<Checkbox
className="p-0"
label={t("accept_promotional_emails")}
state={form.acceptPromotionalEmails}
onClick={(e) =>
setForm({
...form,
acceptPromotionalEmails: e.target.checked,
})
}
/>
<div className="text-xs text-neutral mb-3">
<p>
<Trans
i18nKey="sign_up_agreement"
components={[
<Link
href="https://linkwarden.app/tos"
className="font-semibold"
data-testid="terms-of-service-link"
key={0}
/>,
<Link
href="https://linkwarden.app/privacy-policy"
className="font-semibold"
data-testid="privacy-policy-link"
key={1}
/>,
]}
/>
</p>
</div>
</>
)}
<Button

View File

@@ -32,6 +32,8 @@ type UserModal = {
userId: number | null;
};
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
export default function Billing() {
const router = useRouter();
const { t } = useTranslation();
@@ -40,9 +42,20 @@ export default function Billing() {
const { data: users = [] } = useUsers();
useEffect(() => {
if (!process.env.NEXT_PUBLIC_STRIPE || account?.parentSubscriptionId)
if (!process.env.NEXT_PUBLIC_STRIPE || account?.parentSubscriptionId) {
router.push("/settings/account");
}, []);
} else if (account?.createdAt) {
const trialEndTime =
new Date(account.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (daysLeft > 0 && !account.subscription?.active) {
router.push("/subscribe");
}
}
}, [account]);
const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>();

View File

@@ -12,7 +12,7 @@ import {
useUser,
} from "@linkwarden/router/user";
import { useConfig } from "@linkwarden/router/config";
import { useTags, useUpdateArchivalTags } from "@linkwarden/router/tags";
import { useTags, useUpsertTags } from "@linkwarden/router/tags";
import TagSelection from "@/components/InputSelect/TagSelection";
import { useArchivalTags } from "@/hooks/useArchivalTags";
import { isArchivalTag } from "@linkwarden/lib";
@@ -32,7 +32,7 @@ export default function Preference() {
const [submitLoader, setSubmitLoader] = useState(false);
const { data: account } = useUser() as any;
const { data: tags } = useTags();
const updateArchivalTags = useUpdateArchivalTags();
const upsertTags = useUpsertTags();
const {
ARCHIVAL_OPTIONS,
archivalTags,
@@ -172,8 +172,7 @@ export default function Preference() {
const promises = [];
if (hasAccountChanges) promises.push(updateUser.mutateAsync({ ...user }));
if (hasTagChanges)
promises.push(updateArchivalTags.mutateAsync(archivalTags));
if (hasTagChanges) promises.push(upsertTags.mutateAsync(archivalTags));
if (promises.length > 0) {
await Promise.all(promises);

View File

@@ -9,6 +9,12 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
import { Trans, useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { cn } from "@/lib/utils";
const TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default function Subscribe() {
const { t } = useTranslation();
@@ -21,6 +27,18 @@ export default function Subscribe() {
const { data: user } = useUser();
const [daysLeft, setDaysLeft] = useState<number>(0);
useEffect(() => {
if (user?.createdAt) {
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
setDaysLeft(Math.floor((trialEndTime - Date.now()) / 86400000));
}
}, [user]);
useEffect(() => {
if (
session.status === "authenticated" &&
@@ -45,9 +63,13 @@ export default function Subscribe() {
return (
<CenteredForm
text={`Start with a ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial, cancel anytime!`}
text={
daysLeft <= 0
? "Your free trial has ended, subscribe to continue."
: `You have ${REQUIRE_CC ? 14 : daysLeft || 0} ${
!REQUIRE_CC && daysLeft === 1 ? "day" : "days"
} left in your free trial.`
}
>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-xl shadow-md border border-neutral-content">
<p className="sm:text-3xl text-xl text-center font-extralight">
@@ -116,11 +138,11 @@ export default function Subscribe() {
<p className="text-sm">
{plan === Plan.monthly
? t("total_monthly_desc", {
count: Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS),
count: REQUIRE_CC ? 14 : daysLeft,
monthlyPrice: "4",
})
: t("total_annual_desc", {
count: Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS),
count: REQUIRE_CC ? 14 : daysLeft,
annualPrice: "36",
})}
</p>
@@ -128,21 +150,39 @@ export default function Subscribe() {
</fieldset>
</div>
<Button
type="button"
variant="accent"
size="full"
onClick={submit}
disabled={submitLoader}
>
{t("complete_subscription")}
</Button>
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
className={cn(
"flex gap-3 flex-col",
REQUIRE_CC || daysLeft <= 0 ? "" : "sm:flex-row-reverse"
)}
>
{t("sign_out")}
<Button
type="button"
variant="accent"
size="full"
onClick={submit}
disabled={submitLoader}
>
{t("complete_subscription")}
</Button>
{REQUIRE_CC || daysLeft <= 0 ? (
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
>
{t("sign_out")}
</div>
) : (
<Button
className=""
variant="metal"
size="full"
onClick={() => router.push("/dashboard")}
>
{t("subscribe_later")}
</Button>
)}
</div>
</div>
</CenteredForm>

View File

@@ -151,7 +151,7 @@ export default function Index() {
setActiveLink={setActiveLink}
>
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
<div className="p-5 flex flex-col gap-5 w-full h-full">
<LinkListOptions
t={t}
viewMode={viewMode}
@@ -192,12 +192,7 @@ export default function Index() {
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
title={t("more")}
>
<Button variant="ghost" size="icon" title={t("more")}>
<i className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
@@ -241,6 +236,20 @@ export default function Index() {
placeholderCount={1}
useData={data}
/>
{!data.isLoading && links && !links[0] && (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
>
<p className="text-center text-xl">
{t("this_tag_has_no_links")}
</p>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
{t("this_tag_has_no_links_desc")}
</p>
</div>
)}
</div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal

View File

@@ -0,0 +1,288 @@
import MainLayout from "@/layouts/MainLayout";
import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import { useTags } from "@linkwarden/router/tags";
import TagCard from "@/components/TagCard";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useMemo, useState } from "react";
import NewTagModal from "@/components/ModalContent/NewTagModal";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import BulkDeleteTagsModal from "@/components/ModalContent/BulkDeleteTagsModal";
import MergeTagsModal from "@/components/ModalContent/MergeTagsModal";
enum TagSort {
DateNewestFirst = 0,
DateOldestFirst = 1,
NameAZ = 2,
NameZA = 3,
LinkCountHighLow = 4,
LinkCountLowHigh = 5,
}
export default function Tags() {
const { t } = useTranslation();
const { data: tags = [], isLoading } = useTags();
const [sortBy, setSortBy] = useState<TagSort>(TagSort.DateNewestFirst);
const [newTagModal, setNewTagModal] = useState(false);
const [bulkDeleteModal, setBulkDeleteModal] = useState(false);
const [mergeTagsModal, setMergeTagsModal] = useState(false);
const tagTime = (tag: any) => {
if (tag?.createdAt) return new Date(tag.createdAt as string).getTime();
return typeof tag?.id === "number" ? tag.id : 0;
};
const linkCount = (tag: any) =>
tag?.linkCount ?? tag?.linksCount ?? tag?._count?.links ?? 0;
const compare = useMemo(() => {
switch (sortBy) {
case TagSort.NameAZ:
return (a: any, b: any) => (a?.name ?? "").localeCompare(b?.name ?? "");
case TagSort.NameZA:
return (a: any, b: any) => (b?.name ?? "").localeCompare(a?.name ?? "");
case TagSort.DateOldestFirst:
return (a: any, b: any) => tagTime(a) - tagTime(b);
case TagSort.LinkCountHighLow:
return (a: any, b: any) => linkCount(b) - linkCount(a);
case TagSort.LinkCountLowHigh:
return (a: any, b: any) => linkCount(a) - linkCount(b);
case TagSort.DateNewestFirst:
default:
return (a: any, b: any) => tagTime(b) - tagTime(a);
}
}, [sortBy]);
const sortedTags = useMemo(() => tags.slice().sort(compare), [tags, compare]);
const [editMode, setEditMode] = useState(false);
const [selectedTags, setSelectedTags] = useState<number[]>([]);
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<PageHeader icon={"bi-hash"} title={t("tags")} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setNewTagModal(true)}
>
<i className="bi-plus-lg text-xl text-neutral"></i>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{t("new_tag")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditMode(!editMode);
setSelectedTags([]);
}}
className={editMode ? "bg-primary/20 hover:bg-primary/20" : ""}
>
<i className="bi-pencil-fill text-neutral text-xl" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<i className="bi-chevron-expand text-neutral text-xl"></i>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={4} align="end">
<DropdownMenuRadioGroup
value={sortBy.toString()}
onValueChange={(v) => setSortBy(Number(v) as TagSort)}
>
<DropdownMenuRadioItem
value={TagSort.DateNewestFirst.toString()}
>
{t("date_newest_first")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={TagSort.DateOldestFirst.toString()}
>
{t("date_oldest_first")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={TagSort.NameAZ.toString()}>
{t("name_az")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={TagSort.NameZA.toString()}>
{t("name_za")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={TagSort.LinkCountHighLow.toString()}
>
{t("link_count_high_low")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={TagSort.LinkCountLowHigh.toString()}
>
{t("link_count_low_high")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{tags && editMode && tags.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
<div className="flex gap-3 ml-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
onChange={() => {
if (selectedTags.length === tags.length) setSelectedTags([]);
else setSelectedTags(tags.map((t) => t.id));
}}
checked={selectedTags.length === tags.length && tags.length > 0}
/>
{selectedTags.length > 0 ? (
<span>
{selectedTags.length === 1
? t("tag_selected")
: t("tags_selected", { count: selectedTags.length })}
</span>
) : (
<span>{t("nothing_selected")}</span>
)}
</div>
<div className="flex gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
setMergeTagsModal(true);
}}
variant="ghost"
size="icon"
disabled={selectedTags.length < 2}
>
<i className="bi-intersect" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("merge_tags")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={(e) => {
setBulkDeleteModal(true);
}}
variant="ghost"
size="icon"
disabled={selectedTags.length === 0}
>
<i className="bi-trash text-error" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p> {t("delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)}
<div className="grid 2xl:grid-cols-6 xl:grid-cols-5 sm:grid-cols-3 grid-cols-2 gap-5">
{sortedTags.map((tag: any) => (
<TagCard
key={tag.id}
tag={tag}
selected={selectedTags.includes(tag.id)}
editMode={editMode}
onSelect={(id: number) => {
console.log(id);
if (selectedTags.includes(id))
setSelectedTags((prev) => prev.filter((t) => t !== id));
else setSelectedTags((prev) => [...prev, id]);
}}
/>
))}
</div>
{!isLoading && tags && !tags[0] && (
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
>
<p className="text-center text-xl">{t("create_your_first_tag")}</p>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
{t("create_your_first_tag_desc")}
</p>
<Button
className="mx-auto mt-5"
variant={"accent"}
onClick={() => setNewTagModal(true)}
>
<i className="bi-plus-lg text-xl mr-2" />
{t("new_tag")}
</Button>
</div>
)}
</div>
{newTagModal && <NewTagModal onClose={() => setNewTagModal(false)} />}
{bulkDeleteModal && (
<BulkDeleteTagsModal
onClose={() => {
setBulkDeleteModal(false);
setEditMode(false);
}}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
)}
{mergeTagsModal && (
<MergeTagsModal
onClose={() => {
setMergeTagsModal(false);
setEditMode(false);
}}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
)}
</MainLayout>
);
}
export { getServerSideProps };

View File

@@ -1,5 +1,5 @@
{
"user_administration": "Users Administration",
"user_administration": "Benutzerverwaltung",
"search_users": "Suche nach Benutzern",
"no_users_found": "Keine Benutzer gefunden.",
"no_user_found_in_search": "Keine Benutzer mit den angegebenen Suchparametern gefunden.",
@@ -42,8 +42,8 @@
"from_linkwarden": "Von Linkwarden",
"from_html": "Von Lesezeichen HTML-Datei",
"from_wallabag": "Von Wallabag (JSON-Datei)",
"from_omnivore": "From Omnivore (ZIP file)",
"from_pocket": "From Pocket (CSV file)",
"from_omnivore": "Von Omnivore (ZIP-Datei)",
"from_pocket": "Von Pocket (CSV-Datei)",
"pinned": "Angepinnt",
"pinned_links_desc": "Deine angepinnten Links",
"pin_favorite_links_here": "Pin deine Favoriten-Links hier!",
@@ -208,7 +208,7 @@
"password_successfully_updated": "Dein Passwort wurde erfolgreich aktualisiert.",
"user_already_member": "Benutzer existiert bereits.",
"you_are_already_collection_owner": "Du bist bereits der Eigentümer der Sammlung.",
"link_already_in_collection": "This link is already in this collection.",
"link_already_in_collection": "Dieser Link ist bereits in der Sammlung vorhanden.",
"date_newest_first": "Datum (Neueste zuerst)",
"date_oldest_first": "Datum (Älteste zuerst)",
"name_az": "Name (A-Z)",
@@ -242,7 +242,7 @@
"new_version_announcement": "Schau, was neu ist in <0>Linkwarden {{version}}</0>",
"creating": "Erstellen...",
"upload_file": "Datei hochladen",
"content": "Content",
"content": "Inhalt",
"file_types": "PDF, PNG, JPG (Maximalgröße {{size}} MB)",
"description": "Beschreibung",
"auto_generated": "Wird automatisch generiert, wenn nichts angegeben wird.",
@@ -343,11 +343,11 @@
"link_deletion_confirmation_message": "Bist du sicher, dass du diesen Link entfernen möchtest?",
"warning": "Warnung",
"irreversible_warning": "Diese Aktion ist nicht umkehrbar!",
"tip": "Tip",
"tip": "Hinweis",
"shift_key_tip": "Halte die Umschalttaste gedrückt, während du auf „Löschen“ klickst, um diese Bestätigung in Zukunft zu umgehen.",
"deleting_collection": "Lösche...",
"collection_deleted": "Sammlung gelöscht.",
"collection_deletion_prompt": "Are you sure you want to delete this Collection?",
"collection_deletion_prompt": "Bist du dir sicher, dass du diese Sammlung löschen möchtest?",
"type_name_placeholder": "Tippe „{{name}}“ hier.",
"deletion_warning": "Wenn Du diese Sammlung löschst, wird ihr gesamter Inhalt unwiderruflich gelöscht und sie wird für jeden unzugänglich, auch für Mitglieder mit vorherigem Zugriff.",
"leave_prompt": "Klicke auf die Schaltfläche unten, um die aktuelle Sammlung zu verlassen.",
@@ -395,7 +395,7 @@
"no_tags": "Keine tags.",
"no_description_provided": "Keine Beschreibung vorhanden.",
"change_icon": "Symbol ändern",
"upload_banner": "Upload Banner",
"upload_banner": "Banner hochladen",
"columns": "Spalten",
"default": "Standard",
"invalid_url_guide": "Bitte geben Sie eine gültige Adresse für den Link ein. (Sie sollte mit http/https beginnen)",
@@ -406,7 +406,7 @@
"invite_users": "Benutzer einladen",
"invite_user_desc": "Um jemanden in Ihr Team einzuladen, geben Sie bitte unten die E-Mail-Adresse ein:",
"invite_user_note": "Bitte beachten Sie, dass mit der Annahme der Einladung ein zusätzlicher Platz erworben wird und dieser automatisch Ihrem Konto in Rechnung gestellt wird.",
"invite_user_price": "The cost of each seat is ${{price}} per month or ${{priceAnnual}} per year, depending on your current subscription plan.",
"invite_user_price": "Der Preis eines Accounts ist ${{price}} pro Monat oder ${{priceAnnual}} pro Jahr, abhängig von deinem aktuellen Abonnement.",
"send_invitation": "Einladung senden",
"learn_more": "Mehr erfahren",
"invitation_desc": "{{owner}} hat Sie eingeladen, Linkwarden beizutreten. \nUm fortzufahren, schließen Sie bitte die Einrichtung Ihres Kontos ab.",
@@ -416,82 +416,102 @@
"active": "Aktiv",
"manage_seats": "Plätze verwalten",
"seats_purchased": "{{count}} gekaufte Plätze",
"seat_purchased": "{{count}} seat purchased",
"seat_purchased": "{{count}} Abonnements gekauft",
"date_added": "Datum hinzugefügt",
"resend_invite": "Einladung erneut senden",
"resend_invite_success": "Einladung verschickt!",
"remove_user": "Benutzer entfernen",
"continue_to_dashboard": "Weiter zum Dashboard",
"confirm_user_removal_desc": "Sie müssen ein Abonnement haben, um wieder Zugriff auf Linkwarden zu haben.",
"click_out_to_apply": "Click outside to apply",
"submit": "Submit",
"thanks_for_feedback": "Thanks for your feedback!",
"quick_survey": "Quick Survey",
"how_did_you_discover_linkwarden": "How did you discover Linkwarden?",
"rather_not_say": "Rather not say",
"search_engine": "Search Engine (Google, Bing, etc.)",
"click_out_to_apply": "Zum Anwenden außerhalb klicken",
"submit": "Absenden",
"thanks_for_feedback": "Danke für dein Feedback!",
"quick_survey": "Schnelle Umfrage",
"how_did_you_discover_linkwarden": "Wie sind Sie auf Linkwarden aufmerksam geworden?",
"rather_not_say": "Möchte ich nicht angeben",
"search_engine": "Suchmaschine (Google, Bing, usw.)",
"reddit": "Reddit",
"lemmy": "Lemmy",
"people_recommendation": "Recommendation (Friend, Family, etc.)",
"open_all_links": "Open all Links",
"ai_settings": "AI Settings",
"generate_tags_for_existing_links": "Generate tags for existing Links",
"ai_tagging_method": "AI Tagging Method:",
"based_on_predefined_tags": "Based on predefined Tags",
"based_on_predefined_tags_desc": "Auto-categorize links to predefined tags based on the content of each link.",
"based_on_existing_tags": "Based on existing Tags",
"based_on_existing_tags_desc": "Auto-categorize links to existing tags based on the content of each link.",
"auto_generate_tags": "Auto-generate Tags",
"auto_generate_tags_desc": "Auto-generate relevant tags based on the content of each link.",
"disabled": "Disabled",
"ai_tagging_disabled_desc": "AI tagging is disabled.",
"tag_selection_placeholder": "Choose or add custom tags…",
"people_recommendation": "Empfehlung (Freund, Familie, etc.)",
"open_all_links": "Alle Links öffnen",
"ai_settings": "KI Einstellungen",
"generate_tags_for_existing_links": "Tags für bestehende Links generieren",
"ai_tagging_method": "KI-Tagging-Methode:",
"based_on_predefined_tags": "Basierend auf vordefinierten Tags",
"based_on_predefined_tags_desc": "Links zu vordefinierten Tags automatisch kategorisieren, basierend auf dem Inhalt jedes Links.",
"based_on_existing_tags": "Basierend auf vorhandenen Tags",
"based_on_existing_tags_desc": "Links zu vordefinierten Tags automatisch kategorisieren, basierend auf dem Inhalt jedes Links.",
"auto_generate_tags": "Tags automatisch generieren",
"auto_generate_tags_desc": "Links zu vordefinierten Tags automatisch kategorisieren, basierend auf dem Inhalt jedes Links.",
"disabled": "Deaktiviert",
"ai_tagging_disabled_desc": "KI-Tagging ist deaktiviert.",
"tag_selection_placeholder": "Eigene Tags auswählen oder hinzufügen…",
"rss_subscriptions": "RSS Abos",
"rss_subscriptions_desc": "RSS Subscriptions are a way to keep up with your favorite websites and blogs. Linkwarden will automatically fetch the latest articles every {{number}} minutes from the feeds you provide.",
"rss_deletion_confirmation": "Are you sure you want to delete this RSS Subscription?",
"new_rss_subscription": "New RSS Subscription",
"rss_subscription_deleted": "RSS Subscription deleted!",
"create_rss_subscription": "Create RSS Subscription",
"rss_feed": "RSS Feed",
"pinned_links": "Pinned Links",
"recent_links": "Recent Links",
"search_results": "Search Results",
"linkwarden_icon": "Linkwarden Icon",
"permanent_session": "This is a permanent session",
"rss_subscriptions_desc": "RSS-Abonnements sind eine Möglichkeit, um Ihren bevorzugten Websites und Blogs zu folgen. Linkwarden ruft automatisch die neuesten Artikel alle {{number}} Minuten aus den von Ihnen bereitgestellten Feeds ab.",
"rss_deletion_confirmation": "Sind Sie sicher, dass Sie dieses RSS-Abonnement löschen möchten?",
"new_rss_subscription": "Neues RSS-Abonnement",
"rss_subscription_deleted": "RSS-Abonnement entfernt!",
"create_rss_subscription": "RSS-Abonnement erstellen",
"rss_feed": "RSS-Feed",
"pinned_links": "Angepinnte Links",
"recent_links": "Neueste Links",
"search_results": "Suchergebnisse",
"linkwarden_icon": "Linkwarden-Icon",
"permanent_session": "Dies ist eine dauerhafte Sitzung",
"locale": "en-US",
"not_found_404": "404 - Nicht gefunden",
"collection_publicly_shared": "This collection is being shared publicly.",
"search_for_links": "Search for Links",
"search_query_invalid_symbol": "The search query should not contain '%'.",
"open_modal_new_tab": "Open this modal in a new tab",
"collection_publicly_shared": "Diese Sammlung wird öffentlich geteilt.",
"search_for_links": "Nach Links suchen",
"search_query_invalid_symbol": "Die Suchabfrage sollte nicht '%' enthalten.",
"open_modal_new_tab": "Modal in einem neuen Tab öffnen",
"file": "Datei",
"tag_preservation_rule_label": "Preservation rules by tag (override global settings):",
"ai_tagging": "AI Tagging",
"tag_preservation_rule_label": "Regeln zur Erhaltung nach Tag (globale Einstellungen überschreiben):",
"ai_tagging": "AI-Tagging",
"worker": "Worker",
"regenerate_broken_preservations": "Regenerate all broken or missing preservations for all users.",
"delete_all_preservations": "Delete preservation for all webpages, applies to all users.",
"delete_all_preservations_and_regenerate": "Delete and re-preserve all webpages, applies to all users.",
"delete_all_preservation_warning": "This action will delete all existing preservations on the server.",
"no_broken_preservations": "No broken preservations.",
"links_are_being_represerved": "Links are being re-preserved...",
"cancel": "Cancel",
"preservation_rules": "Preservation rules:",
"links_being_archived": "Links are being archived...",
"display_on_dashboard": "Display on Dashboard",
"dashboard_stats": "Dashboard Stats",
"no_results_found": "No results found.",
"no_link_in_collection": "No Link in this Collection",
"no_link_in_collection_desc": "This Collection has no Links yet.",
"theme": "Theme",
"font_style": "Font Style",
"font_size": "Font Size",
"line_height": "Line Height",
"line_width": "Line Width",
"notes_highlights": "Notes & Highlights",
"no_notes_highlights": "No notes or highlights found for this link.",
"save": "Save",
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"regenerate_broken_preservations": "Alle defekten oder fehlenden Speicherungen für alle Benutzer neu erzeugen.",
"delete_all_preservations": "Löschen der Archivierung für alle Webseiten, gilt für alle Benutzer.",
"delete_all_preservations_and_regenerate": "Löschen und Neuarchivierung aller Webseiten, gilt für alle Benutzer.",
"delete_all_preservation_warning": "Diese Aktion löscht alle bestehenden Archivierungen auf dem Server.",
"no_broken_preservations": "Keine beschädigten Archivierungen.",
"links_are_being_represerved": "Links werden wiederhergestellt...",
"cancel": "Abbrechen",
"preservation_rules": "Archivierungsregeln:",
"links_being_archived": "Links werden archiviert...",
"display_on_dashboard": "Im Dashboard anzeigen",
"dashboard_stats": "Dashboard-Statistiken",
"no_results_found": "Keine Suchergebnisse gefunden.",
"no_link_in_collection": "Kein Link in dieser Sammlung",
"no_link_in_collection_desc": "Diese Sammlung enthält noch keine Links.",
"theme": "Thema",
"font_style": "Schriftart",
"font_size": "Schriftgröße",
"line_height": "Zeilenabstand",
"line_width": "Linienbreite",
"notes_highlights": "Notizen & Markierungen",
"no_notes_highlights": "Keine Notizen oder Markierungen für diesen Link gefunden.",
"save": "Speichern",
"edit_layout": "Layout bearbeiten",
"refresh_multiple_preserved_formats_confirmation_desc": "Dies löscht die aktuell archivierten Formate und archiviert {{count}} Links neu.",
"refresh_preserved_formats_confirmation_desc": "Dies löscht die aktuell archivierten Formate und archiviert diesen Link neu.",
"tag_already_added": "Dieses Tag wurde bereits hinzugefügt.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,35 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links.",
"subscribe_later": "Subscribe Later?",
"create_your_first_tag": "Create Your First Tag!",
"create_your_first_tag_desc": "Tags help you categorize and find your Links easily. You can create Tags based on topics, projects, or any system that works for you.",
"create_your_first_collection": "Create Your First Collection!",
"create_your_first_collection_desc": "Collections are like folders for your Links which can then be shared with others.",
"this_tag_has_no_links": "This Tag Has No Links",
"this_tag_has_no_links_desc": "Use this Tag while creating or editing Links!",
"accept_promotional_emails": "Get notified about new features and offers via email.",
"expand_sidebar": "Expand Sidebar",
"shrink_sidebar": "Shrink Sidebar"
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "Editar Disposición",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "Esta etiqueta ya ha sido añadida.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -208,7 +208,7 @@
"password_successfully_updated": "Votre mot de passe a été mis à jour avec succès.",
"user_already_member": "Lutilisateur existe déjà.",
"you_are_already_collection_owner": "Vous êtes déjà le propriétaire de la collection.",
"link_already_in_collection": "This link is already in this collection.",
"link_already_in_collection": "Ce lien est déjà dans cette collection.",
"date_newest_first": "Date (la plus récente en premier)",
"date_oldest_first": "Date (la plus ancienne en premier)",
"name_az": "Nom (A-Z)",
@@ -491,7 +491,27 @@
"no_notes_highlights": "Aucune note ou surlignement trouvé pour ce lien.",
"save": "Sauvegarder",
"edit_layout": "Modifier la mise en page",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"refresh_multiple_preserved_formats_confirmation_desc": "Ceci supprimera les formats actuellement conservés et conservera de nouveau {{count}} liens.",
"refresh_preserved_formats_confirmation_desc": "Ceci supprimera les formats actuellement conservés et conservera de nouveau ce lien.",
"tag_already_added": "Cette étiquette a déjà été ajoutée.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -374,7 +374,7 @@
"dashboard": "Dashboard",
"demo_title": "Demo Only",
"demo_desc": "This is only a demo instance of Linkwarden and uploads are disabled.",
"demo_desc_2": "If you want to try out the full version, you can sign up for a free trial at:",
"demo_desc_2": "Se si desidera provare la versione completa, è possibile iscriversi a una prova gratuita a:",
"demo_button": "Login as demo user",
"regular": "Regular",
"thin": "Thin",
@@ -383,7 +383,7 @@
"duotone": "Duotone",
"light_icon": "Light",
"search": "Search",
"set_custom_icon": "Set Custom Icon",
"set_custom_icon": "Imposta Icona Personalizzata",
"view": "View",
"show": "Show",
"image": "Image",
@@ -397,7 +397,7 @@
"change_icon": "Change Icon",
"upload_banner": "Upload Banner",
"columns": "Columns",
"default": "Default",
"default": "Predefinito",
"invalid_url_guide": "Please enter a valid Address for the Link. (It should start with http/https)",
"email_invalid": "Please enter a valid email address.",
"username_invalid_guide": "Username has to be at least 3 characters, no spaces and special characters are allowed.",
@@ -409,13 +409,13 @@
"invite_user_price": "The cost of each seat is ${{price}} per month or ${{priceAnnual}} per year, depending on your current subscription plan.",
"send_invitation": "Send Invitation",
"learn_more": "Learn more",
"invitation_desc": "{{owner}} invited you to join Linkwarden. \nTo continue, please finish setting up your account.",
"invitation_desc": "{{owner}} ti ha invitato a unirti a Linkwarden. \nPer continuare, per favore completa la configurazione del tuo account.",
"invitation_accepted": "Invitation Accepted!",
"status": "Status",
"pending": "Pending",
"active": "Active",
"manage_seats": "Manage Seats",
"seats_purchased": "{{count}} seats purchased",
"seats_purchased": "{{count}} posti acquistati",
"seat_purchased": "{{count}} seat purchased",
"date_added": "Date Added",
"resend_invite": "Resend Invitation",
@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -1,5 +1,5 @@
{
"user_administration": "Users Administration",
"user_administration": "ユーザー管理",
"search_users": "ユーザーを検索",
"no_users_found": "ユーザーが見つかりません",
"no_user_found_in_search": "指定された検索クエリでユーザーが見つかりませんでした。",
@@ -43,7 +43,7 @@
"from_html": "ブックマークHTMLファイルから",
"from_wallabag": "Wallabag (JSON file)から",
"from_omnivore": "Omnivore (Zip file)から",
"from_pocket": "From Pocket (CSV file)",
"from_pocket": "Pocket (CSV ファイル) から",
"pinned": "ピン留め",
"pinned_links_desc": "ピン留めされたリンク",
"pin_favorite_links_here": "お気に入りのリンクをここにピン留め!",
@@ -208,7 +208,7 @@
"password_successfully_updated": "パスワードが正常に更新されました。",
"user_already_member": "ユーザーは既に存在します。",
"you_are_already_collection_owner": "あなたは既にコレクションの所有者です。",
"link_already_in_collection": "This link is already in this collection.",
"link_already_in_collection": "このリンクは既にこのコレクションにあります。",
"date_newest_first": "日付 (新しい順)",
"date_oldest_first": "日付 (古い順)",
"name_az": "名前 (A-Z)",
@@ -242,7 +242,7 @@
"new_version_announcement": "新しいバージョンの <0>Linkwarden {{version}}</0> をご覧ください",
"creating": "作成中...",
"upload_file": "ファイルをアップロード",
"content": "Content",
"content": "コンテンツ",
"file_types": "PDF, PNG, JPG (最大 {{size}} MB)",
"description": "説明",
"auto_generated": "何も入力されない場合、自動生成されます。",
@@ -318,10 +318,10 @@
"make_collection_public": "コレクションを公開",
"make_collection_public_checkbox": "このコレクションを公開する",
"make_collection_public_desc": "これにより、誰でもこのコレクションとそのユーザーを閲覧できるようになります。",
"sharable_link": "Sharable Link",
"sharable_link": "共有リンク",
"copied": "コピーされました!",
"members": "メンバー",
"add_member_placeholder": "Add members by email or username",
"add_member_placeholder": "メールまたはユーザー名でメンバーを追加",
"owner": "所有者",
"admin": "管理者",
"contributor": "コントリビューター",
@@ -343,11 +343,11 @@
"link_deletion_confirmation_message": "このリンクを削除してもよろしいですか?",
"warning": "警告",
"irreversible_warning": "この操作は取り消せません!",
"tip": "Tip",
"tip": "ヒント",
"shift_key_tip": "Shiftキーを押しながら「削除」をクリックすると、今後この確認をスキップできます。",
"deleting_collection": "削除中...",
"collection_deleted": "コレクションが削除されました。",
"collection_deletion_prompt": "Are you sure you want to delete this Collection?",
"collection_deletion_prompt": "本当にこのコレクションを削除しますか?",
"type_name_placeholder": "ここに \"{{name}}\" を入力してください。",
"deletion_warning": "このコレクションを削除すると、そのすべての内容が永久に消去され、以前アクセス可能だったメンバーを含め、誰もアクセスできなくなります。",
"leave_prompt": "現在のコレクションから離れるには、以下のボタンをクリックしてください。",
@@ -395,7 +395,7 @@
"no_tags": "タグなし",
"no_description_provided": "説明が提供されていません",
"change_icon": "アイコンを変更",
"upload_banner": "Upload Banner",
"upload_banner": "バナーをアップロード",
"columns": "列",
"default": "デフォルト",
"invalid_url_guide": "リンクには有効なアドレスを入力してください。(http/https で始まる必要があります)",
@@ -435,12 +435,12 @@
"people_recommendation": "知人や家族から",
"open_all_links": "すべてのリンクを開く",
"ai_settings": "AI設定",
"generate_tags_for_existing_links": "Generate tags for existing Links",
"generate_tags_for_existing_links": "既存のリンクのタグを生成する",
"ai_tagging_method": "AIによるタグ付け:",
"based_on_predefined_tags": "定義済みのタグ",
"based_on_predefined_tags_desc": "各リンクの内容に基づいて、リンクを定義済みのタグに自動的に分類します。",
"based_on_existing_tags": "Based on existing Tags",
"based_on_existing_tags_desc": "Auto-categorize links to existing tags based on the content of each link.",
"based_on_existing_tags": "既存のタグに基づきます",
"based_on_existing_tags_desc": "各リンクの内容に基づいて、既存のタグへのリンクを自動的に分類します。",
"auto_generate_tags": "自動生成されたタグ",
"auto_generate_tags_desc": "各リンクの内容に基づいて関連するタグを自動生成します。",
"disabled": "無効",
@@ -463,35 +463,55 @@
"collection_publicly_shared": "このコレクションは全体に公開されています。",
"search_for_links": "リンクを検索",
"search_query_invalid_symbol": "検索クエリに'%'を含めることはできません",
"open_modal_new_tab": "Open this modal in a new tab",
"open_modal_new_tab": "新しいタブでこのモーダルを開く",
"file": "ファイル",
"tag_preservation_rule_label": "Preservation rules by tag (override global settings):",
"ai_tagging": "AI Tagging",
"worker": "Worker",
"regenerate_broken_preservations": "Regenerate all broken or missing preservations for all users.",
"delete_all_preservations": "Delete preservation for all webpages, applies to all users.",
"delete_all_preservations_and_regenerate": "Delete and re-preserve all webpages, applies to all users.",
"delete_all_preservation_warning": "This action will delete all existing preservations on the server.",
"no_broken_preservations": "No broken preservations.",
"links_are_being_represerved": "Links are being re-preserved...",
"cancel": "Cancel",
"preservation_rules": "Preservation rules:",
"links_being_archived": "Links are being archived...",
"display_on_dashboard": "Display on Dashboard",
"dashboard_stats": "Dashboard Stats",
"no_results_found": "No results found.",
"no_link_in_collection": "No Link in this Collection",
"no_link_in_collection_desc": "This Collection has no Links yet.",
"theme": "Theme",
"font_style": "Font Style",
"font_size": "Font Size",
"line_height": "Line Height",
"line_width": "Line Width",
"notes_highlights": "Notes & Highlights",
"no_notes_highlights": "No notes or highlights found for this link.",
"save": "Save",
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_preservation_rule_label": "タグごとの保存ルール(グローバル設定を上書き):",
"ai_tagging": "AIタグ付け",
"worker": "ワーカー",
"regenerate_broken_preservations": "すべてのユーザーの壊れた保存または欠落している保存を再生成します。",
"delete_all_preservations": "すべての Web ページの保存を削除し、すべてのユーザーに適用されます。",
"delete_all_preservations_and_regenerate": "すべての Web ページを削除して再保存し、すべてのユーザーに適用されます。",
"delete_all_preservation_warning": "この操作は、サーバー上の既存のすべての保存を削除します。",
"no_broken_preservations": "壊れた保存はありません。",
"links_are_being_represerved": "リンクを再保存しています…",
"cancel": "キャンセル",
"preservation_rules": "保存ルール:",
"links_being_archived": "リンクをアーカイブしています…",
"display_on_dashboard": "ダッシュボードに表示",
"dashboard_stats": "ダッシュボードのステータス",
"no_results_found": "該当する結果が見つかりませんでした。",
"no_link_in_collection": "このコレクションにリンクがありません",
"no_link_in_collection_desc": "このコレクションにはまだリンクがありません。",
"theme": "テーマ",
"font_style": "フォントスタイル",
"font_size": "フォントサイズ",
"line_height": "行の高さ",
"line_width": "線の太さ",
"notes_highlights": "メモとハイライト",
"no_notes_highlights": "このリンクのメモやハイライトは見つかりませんでした。",
"save": "保存",
"edit_layout": "レイアウトを編集",
"refresh_multiple_preserved_formats_confirmation_desc": "現在保存されている形式を削除し、 {{count}} リンクを再保存します。",
"refresh_preserved_formats_confirmation_desc": "現在保存されている形式を削除し、このリンクを再保存します。",
"tag_already_added": "このタグは既に追加されています。",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -1,5 +1,5 @@
{
"user_administration": "Users Administration",
"user_administration": "Gebruikersbeheer",
"search_users": "Zoeken naar Gebruikers",
"no_users_found": "Geen gebruikers gevonden.",
"no_user_found_in_search": "Geen gebruikers gevonden met de gegeven zoekopdracht.",
@@ -42,8 +42,8 @@
"from_linkwarden": "Van Linkwarden",
"from_html": "Van Bladwijzers HTML-bestand",
"from_wallabag": "Van Wallabag (JSON-bestand)",
"from_omnivore": "From Omnivore (ZIP file)",
"from_pocket": "From Pocket (CSV file)",
"from_omnivore": "Van Omnivore (ZIP bestand)",
"from_pocket": "Van Pocket (CSV bestand)",
"pinned": "Vastgemaakt",
"pinned_links_desc": "Uw Vastgemaakte Links",
"pin_favorite_links_here": "Maak Hier Uw Favoriete Links Vast!",
@@ -242,7 +242,7 @@
"new_version_announcement": "Zie wat er nieuw is in <0>Linkwarden {{version}}</0>",
"creating": "Aanmaken...",
"upload_file": "Bestand Uploaden",
"content": "Content",
"content": "Inhoud",
"file_types": "PDF, PNG, JPG (tot {{size}} MB)",
"description": "Beschrijving",
"auto_generated": "Wordt automatisch gegenereerd als er niets wordt opgegeven.",
@@ -300,7 +300,7 @@
"for_collection": "Voor {{name}}",
"create_new_collection": "Nieuwe Collectie Aanmaken",
"color": "Kleur",
"reset_defaults": "Reset to Defaults",
"reset_defaults": "Standaardinstellingen herstellen",
"updating_collection": "Collectie Bijwerken...",
"collection_name_placeholder": "bijv. Voorbeeld Collectie",
"collection_description_placeholder": "Het doel van deze Collectie...",
@@ -318,10 +318,10 @@
"make_collection_public": "Maak Collectie Openbaar",
"make_collection_public_checkbox": "Maak dit een openbare collectie",
"make_collection_public_desc": "Hierdoor kan iedereen deze collectie en zijn gebruikers bekijken.",
"sharable_link": "Sharable Link",
"sharable_link": "Deelbare link",
"copied": "Gekopieerd!",
"members": "Leden",
"add_member_placeholder": "Add members by email or username",
"add_member_placeholder": "Voeg deelnemers toe via e-mail of gebruikersnaam",
"owner": "Eigenaar",
"admin": "Beheerder",
"contributor": "Bijdrager",
@@ -347,7 +347,7 @@
"shift_key_tip": "Houd de Shift-toets ingedrukt terwijl u op 'Verwijderen' klikt om deze bevestiging in de toekomst te omzeilen.",
"deleting_collection": "Verwijderen...",
"collection_deleted": "Collectie Verwijderd.",
"collection_deletion_prompt": "Are you sure you want to delete this Collection?",
"collection_deletion_prompt": "Weet u zeker dat u deze verzameling wilt verwijderen?",
"type_name_placeholder": "Typ Hier \"{{name}}\".",
"deletion_warning": "Als u deze collectie verwijdert, worden alle inhoud permanent gewist, en wordt deze ontoegankelijk voor iedereen, inclusief leden met eerdere toegang.",
"leave_prompt": "Klik op de onderstaande knop om de huidige collectie te verlaten.",
@@ -386,36 +386,36 @@
"set_custom_icon": "Set Custom Icon",
"view": "View",
"show": "Show",
"image": "Image",
"icon": "Icon",
"date": "Date",
"preview_unavailable": "Preview Unavailable",
"saved": "Saved",
"untitled": "Untitled",
"image": "Afbeelding",
"icon": "Icoon",
"date": "Datum",
"preview_unavailable": "Voorbeeld niet beschikbaar",
"saved": "Opgeslagen",
"untitled": "Naamloos",
"no_tags": "No tags.",
"no_description_provided": "No description provided.",
"change_icon": "Change Icon",
"upload_banner": "Upload Banner",
"columns": "Columns",
"columns": "Kolommen",
"default": "Default",
"invalid_url_guide": "Please enter a valid Address for the Link. (It should start with http/https)",
"email_invalid": "Please enter a valid email address.",
"username_invalid_guide": "Username has to be at least 3 characters, no spaces and special characters are allowed.",
"team_management": "Team Management",
"invite_user": "Invite User",
"invite_users": "Invite Users",
"invite_user": "Gebruiker uitnodigen",
"invite_users": "Gebruikers uitnodigen",
"invite_user_desc": "To invite someone to your team, please enter their email address below:",
"invite_user_note": "Please note that once the invitation is accepted, an additional seat will be purchased and your account will automatically be billed for this addition.",
"invite_user_price": "The cost of each seat is ${{price}} per month or ${{priceAnnual}} per year, depending on your current subscription plan.",
"send_invitation": "Send Invitation",
"send_invitation": "Stuur uitnodiging",
"learn_more": "Learn more",
"invitation_desc": "{{owner}} invited you to join Linkwarden. \nTo continue, please finish setting up your account.",
"invitation_accepted": "Invitation Accepted!",
"invitation_accepted": "Uitnodiging geaccepteerd!",
"status": "Status",
"pending": "Pending",
"active": "Active",
"manage_seats": "Manage Seats",
"seats_purchased": "{{count}} seats purchased",
"pending": "In behandeling",
"active": "Actief",
"manage_seats": "Licenties beheren",
"seats_purchased": "{{count}} licenties gekocht",
"seat_purchased": "{{count}} seat purchased",
"date_added": "Date Added",
"resend_invite": "Resend Invitation",
@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -1,5 +1,5 @@
{
"user_administration": "Users Administration",
"user_administration": "Zarządzanie użytkownikami",
"search_users": "Wyszukaj użytkowników",
"no_users_found": "Nie znaleziono użytkowników.",
"no_user_found_in_search": "Nie znaleziono użytkowników z podanym zapytaniem.",
@@ -42,8 +42,8 @@
"from_linkwarden": "Z Linkwarden",
"from_html": "Z pliku zakładek HTML",
"from_wallabag": "Z Wallabag (plik JSON)",
"from_omnivore": "From Omnivore (ZIP file)",
"from_pocket": "From Pocket (CSV file)",
"from_omnivore": "Z Omnivore (plik ZIP)",
"from_pocket": "Z Pocket (plik CSV)",
"pinned": "Przypięte",
"pinned_links_desc": "Twoje przypięte linki",
"pin_favorite_links_here": "Przypnij swoje ulubione linki tutaj!",
@@ -208,7 +208,7 @@
"password_successfully_updated": "Twoje hasło zostało pomyślnie zaktualizowane.",
"user_already_member": "Użytkownik już istnieje.",
"you_are_already_collection_owner": "Jesteś już właścicielem kolekcji.",
"link_already_in_collection": "This link is already in this collection.",
"link_already_in_collection": "Ten link już znajduje się w tej kolekcji.",
"date_newest_first": "Data (od najnowszych)",
"date_oldest_first": "Data (od najstarszych)",
"name_az": "Nazwa (A-Z)",
@@ -242,7 +242,7 @@
"new_version_announcement": "Zobacz, co nowego w <0>Linkwarden {{version}}</0>",
"creating": "Tworzenie...",
"upload_file": "Prześlij plik",
"content": "Content",
"content": "Treść",
"file_types": "PDF, PNG, JPG (do {{size}} MB)",
"description": "Opis",
"auto_generated": "Zostanie automatycznie wygenerowany, jeśli nic nie podasz.",
@@ -343,11 +343,11 @@
"link_deletion_confirmation_message": "Czy na pewno chcesz usunąć ten link?",
"warning": "Ostrzeżenie",
"irreversible_warning": "Ta akcja jest nieodwracalna!",
"tip": "Tip",
"tip": "Wskazówka",
"shift_key_tip": "Przytrzymaj klawisz Shift podczas klikania 'Usuń', aby w przyszłości pominąć to potwierdzenie.",
"deleting_collection": "Usuwanie...",
"collection_deleted": "Kolekcja usunięta.",
"collection_deletion_prompt": "Are you sure you want to delete this Collection?",
"collection_deletion_prompt": "Czy na pewno chcesz usunąć tę kolekcję?",
"type_name_placeholder": "Wpisz \"{{name}}\" tutaj.",
"deletion_warning": "Usunięcie tej kolekcji spowoduje trwałe usunięcie wszystkich jej zawartości i stanie się ona niedostępna dla wszystkich, w tym dla członków z poprzednim dostępem.",
"leave_prompt": "Kliknij przycisk poniżej, aby opuścić obecną kolekcję.",
@@ -434,11 +434,11 @@
"lemmy": "Lemmy",
"people_recommendation": "Polecenie (przyjaciel, rodzina, itd.)",
"open_all_links": "Otwórz wszystkie linki",
"ai_settings": "AI Settings",
"generate_tags_for_existing_links": "Generate tags for existing Links",
"ai_tagging_method": "AI Tagging Method:",
"based_on_predefined_tags": "Based on predefined Tags",
"based_on_predefined_tags_desc": "Auto-categorize links to predefined tags based on the content of each link.",
"ai_settings": "Ustawienia AI",
"generate_tags_for_existing_links": "Generuj tagi dla istniejących linków",
"ai_tagging_method": "Metoda znakowania AI:",
"based_on_predefined_tags": "Na podstawie predefiniowanych tagów",
"based_on_predefined_tags_desc": ".",
"based_on_existing_tags": "Based on existing Tags",
"based_on_existing_tags_desc": "Auto-categorize links to existing tags based on the content of each link.",
"auto_generate_tags": "Auto-generate Tags",
@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "Editar Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "Isto irá apagar os atuais formatos preservados e re-preservar os links {{count}}.",
"refresh_preserved_formats_confirmation_desc": "Isto irá excluir os atuais formatos preservados e preservar este link novamente.",
"tag_already_added": "Esta tag já está adicionada."
"tag_already_added": "Esta tag já está adicionada.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -0,0 +1,517 @@
{
"user_administration": "Administrare Utilizatori",
"search_users": "Caută utilizatori",
"no_users_found": "Nu s-a găsit niciun utilizator.",
"no_user_found_in_search": "Niciun utilizator găsit care să îndeplinească cerințele menționate.",
"username": "Nume utilizator",
"email": "E-mail",
"subscribed": "Abonat",
"created_at": "Creat la",
"not_available": "Indisponibil",
"check_your_email": "Te rugăm să verifici e-mailul",
"authenticating": "Se autentifică...",
"verification_email_sent": "E-mail de verificare trimis.",
"verification_email_sent_desc": "Un link de autentificare a fost trimis la adresa dvs. de e-mail. Dacă nu vedeți e-mailul, verificați folderul de spam.",
"resend_email": "Retrimitere e-mail",
"invalid_credentials": "Datele de autentificare sunt invalide.",
"fill_all_fields": "Vă rugăm completați toate câmpurile.",
"enter_credentials": "Introduceți datele de autentificare",
"username_or_email": "Nume utilizator sau e-mail",
"password": "Parolă",
"confirm_password": "Confirmați parola",
"forgot_password": "Ai uitat parola?",
"login": "Autentificare",
"or_continue_with": "Sau, continuă cu",
"new_here": "Nou aici?",
"sign_up": "Înregistrați-vă",
"sign_in_to_your_account": "Conectați-vă la contul dumneavoastră",
"dashboard_desc": "O scurtă prezentare a datelor dumneavoastră",
"link": "Link",
"links": "Linkuri",
"collection": "Colecție",
"collections": "Colecții",
"tag": "Etichetă",
"tags": "Etichete",
"recent": "Recente",
"recent_links_desc": "Linkuri adăugate recent",
"view_all": "Vizualizează tot",
"view_added_links_here": "Vizualizați linkurile adăugate recent aici!",
"view_added_links_here_desc": "Această secțiune va prezenta cele mai recente linkuri adăugate pentru fiecare colecție la care aveți acces.",
"add_link": "Adaugă un link nou",
"import_links": "Importă linkuri",
"from_linkwarden": "De la Linkwarden",
"from_html": "Dintr-un fișier HTML",
"from_wallabag": "Din Wallabag (fișier JSON)",
"from_omnivore": "Din Omnivore (fișier ZIP)",
"from_pocket": "Din Pocket (fișier CSV)",
"pinned": "Fixate",
"pinned_links_desc": "Linkurile tale fixate",
"pin_favorite_links_here": "Fixează linkurile tale favorite aici!",
"pin_favorite_links_here_desc": "Poți fixa linkurile tale preferate dând click pe cele trei puncte ale fiecărui link și dând clic pe fixează la tabloul de control.",
"sending_password_link": "Se trimite link-ul de recuperare a parolei...",
"password_email_prompt": "Introduceți adresa dvs. de e-mail să vă putem trimite un link pentru a crea o parolă nouă.",
"send_reset_link": "Trimite un link de resetare a parolei",
"reset_email_sent_desc": "Verifică-ți adresa de e-mail pentru un link de resetare a parolei. Dacă nu apare în câteva minute, verifică secțiunea de spam.",
"back_to_login": "Înapoi la autentificare",
"email_sent": "E-mail trimis!",
"passwords_mismatch": "Parolele nu se potrivesc.",
"password_too_short": "Parola trebuie să aibă cel puțin 8 caractere.",
"creating_account": "Se creează contul...",
"account_created": "Cont creat!",
"trial_offer_desc": "Deblochează {{count}} zile de serviciu Premium fără cost!",
"register_desc": "Creează un cont nou",
"registration_disabled_desc": "Înregistrarea este dezactivată în acest caz, vă rugăm să contactați administratorul în caz de probleme.",
"enter_details": "Introduceți detaliile dumneavoastră",
"display_name": "Numele afișat",
"sign_up_agreement": "Înregistrându-te, ești de acord cu Termenii noștri de utilizare </0> și <1>Politica de confidențialitate</1>.",
"need_help": "Ai nevoie de ajutor?",
"get_in_touch": "Contactează-ne",
"already_registered": "Aveți deja un cont?",
"deleting_selections": "Se șterg selecțiile...",
"links_deleted": "{{count}} Linkuri șterse.",
"link_deleted": "1 link șters.",
"links_selected": "{{count}} Linkuri selectate",
"link_selected": "1 link selectat",
"nothing_selected": "Nimic selectat",
"edit": "Editare",
"delete": "Șterge",
"nothing_found": "Nimic găsit.",
"redirecting_to_stripe": "Redirecționare către Stripe...",
"subscribe_title": "Abonează-te la Linkwarden!",
"subscribe_desc": "Vei fi redirecționat către Stripe, nu ezita să ne contactezi la <0>support@linkwarden.app</0> în caz de probleme.",
"monthly": "Lunar",
"yearly": "Anual",
"discount_percent": "{{percent}}% Reducere",
"billed_monthly": "Facturare lunară",
"billed_yearly": "Facturare anuală",
"total": "Total",
"total_annual_desc": "{{count}} zile de testare gratuită, apoi ${{annualPrice}} \n anual",
"total_monthly_desc": "{{count}} zile de testare gratuită, apoi ${{monthlyPrice}} pe lună",
"plus_tax": "+ TVA dacă este cazul",
"complete_subscription": "Completare abonament",
"sign_out": "Deconectare",
"access_tokens": "Chei de Acces",
"access_tokens_description": "Cheile de acces pot fi utilizate pentru a accesa Linkwarden din alte aplicații și servicii fără a oferi numele de utilizator și parola.",
"new_token": "Cheie de acces nouă",
"name": "Nume",
"created_success": "Creat!",
"created": "Creat",
"expires": "Expiră",
"accountSettings": "Setări cont",
"language": "Limbă",
"profile_photo": "Fotografie de profil",
"upload_new_photo": "Încărcați o fotografie nouă...",
"remove_photo": "Elimină fotografia",
"make_profile_private": "Faceți profilul privat",
"profile_privacy_info": "Aici se va determina cine te poate găsi și adăuga la Colecții noi.",
"whitelisted_users": "Utilizatori pe lista cu permisiuni",
"whitelisted_users_info": "Vă rugăm să furnizați numele de utilizator al utilizatorilor care vor avea acces la vizibilitate profilului dvs. Nume separate prin virgulă.",
"whitelisted_users_placeholder": "Profilul dvs. nu este vizibil pentru nimeni...",
"save_changes": "Salvează modificările",
"import_export": "Importă și Exportă",
"import_data": "Importă datele dvs. de pe alte platforme.",
"download_data": "Descarcă datele dvs. instant.",
"export_data": "Exportă date",
"delete_account": "Ștergere cont",
"delete_account_warning": "Acest lucru va șterge permanent TOATE linkurile, colecțiile, etichetele și datele arhivate pe care le dețineți.",
"cancel_subscription_notice": "De asemenea, se va anula abonamentul dvs.",
"account_deletion_page": "Pagina de ștergere a contului",
"applying_settings": "Se aplică setările...",
"settings_applied": "Setări salvate!",
"email_change_request": "Cererea de modificare a e-mailului a fost trimisă. Verificați noua adresă de e-mail.",
"image_upload_no_file_error": "Nici-un fișier selectat. Vă rugăm să alegeți o imagine pentru a o încărca.",
"image_upload_size_error": "Vă rugăm să selectați un fișier PNG sau JPEG mai mic de 1MB.",
"image_upload_format_error": "Formatul fișierului este invalid.",
"importing_bookmarks": "Se importă marcajele...",
"import_success": "Marcaje importate! Se reîncarcă pagina...",
"more_coming_soon": "Mai multe în curând!",
"billing_settings": "Setări de facturare",
"manage_subscription_intro": "Pentru a gestiona/anula abonamentul, vizitați",
"billing_portal": "Portal de facturare",
"help_contact_intro": "Dacă încă aveți nevoie de ajutor sau ați întâmpinat orice probleme, nu ezitați să ne contactați la adresa:",
"fill_required_fields": "Vă rugăm completați câmpurile necesare.",
"deleting_message": "Se șterge tot, vă rugăm așteptați...",
"delete_warning": "Această acțiune va șterge permanent toate linkurile, colecțiile, etichetele și datele arhivate pe care le dețineți. De asemenea, vei fi deconectat. Această acțiune este ireversibilă!",
"optional": "Opțional",
"feedback_help": "(dar chiar ne ajută să îmbunătățim aplicația!)",
"reason_for_cancellation": "Motivul anulării",
"please_specify": "Te rugăm să specifici",
"customer_service": "Serviciul de relații cu clienții",
"low_quality": "Calitate scăzută",
"missing_features": "Caracteristici lipsă",
"switched_service": "Servicii schimbate",
"too_complex": "Prea complex",
"too_expensive": "Prea scump",
"unused": "Neutilizat",
"other": "Altceva",
"more_information": "Mai multe informații (informațiile mai detaliate ajută)",
"feedback_placeholder": "ex: Am avut nevoie de o funcție care...",
"delete_your_account": "Ștergeți-vă Contul",
"change_password": "Modificare parola",
"password_length_error": "Parola trebuie să aibă cel puțin 8 caractere.",
"applying_changes": "Se aplică...",
"password_change_instructions": "Pentru a vă schimba parola, vă rugăm să completați următoarele. Parola dvs. trebuie să fie de cel puțin 8 caractere.",
"old_password": "Parola veche",
"new_password": "Noua parolă",
"preference": "Preferințe",
"select_theme": "Selectați tema",
"dark": "Întunecată",
"light": "Luminos",
"archive_settings": "Setări Arhivă",
"formats_to_archive": "Formate pentru arhivare/păstrare pagini web:",
"screenshot": "Captură de ecran",
"pdf": "PDF",
"archive_org_snapshot": "Snapshot Archive.org",
"link_settings": "Setări Linkuri",
"prevent_duplicate_links": "Previne linkuri duplicate",
"clicking_on_links_should": "Făcând clic pe linkuri ar trebui:",
"open_original_content": "Să se deschidă conținutul original",
"open_pdf_if_available": "Să se deschidă PDF-ul, dacă disponibil",
"open_readable_if_available": "Să se deschidă modul de citit, dacă disponibil",
"open_screenshot_if_available": "Să se deschidă captura de ecran, dacă disponibilă",
"open_webpage_if_available": "Să se deschidă pagina web, dacă disponibilă",
"tag_renamed": "Etichetă redenumită!",
"tag_deleted": "Etichetă ștearsă!",
"rename_tag": "Redenumire etichetă",
"delete_tag": "Ștergere etichetă",
"list_created_with_linkwarden": "Listă creată de Linkwarden",
"by_author": "De {{author}}.",
"by_author_and_other": "De {{author}} și {{count}} alți oameni.",
"by_author_and_others": "De {{author}} și {{count}} alți oameni.",
"search_count_link": "Căutare {{count}} Linkuri",
"search_count_links": "Căutare {{count}} Linkuri",
"collection_is_empty": "Această colecție este goală...",
"all_links": "Toate linkurile",
"all_links_desc": "Linkuri din toate colecțiile",
"you_have_not_added_any_links": "Nu ați creat încă niciun link",
"collections_you_own": "Colecții deținute de tine",
"new_collection": "Colecție nouă",
"other_collections": "Alte colecții",
"other_collections_desc": "Colecții partajate la care sunteți membru",
"showing_count_results": "Se afișează {{count}} rezultate",
"showing_count_result": "Se afișează {{count}} rezultat",
"edit_collection_info": "Editare informații colecție",
"share_and_collaborate": "Partajează și colaborează",
"view_team": "Vezi echipa",
"team": "Echipă",
"create_subcollection": "Creează sub-Colecție",
"delete_collection": "Șterge colecția",
"leave_collection": "Părăsește colecția",
"email_verified_signing_out": "E-mail verificat. Se deconectează...",
"invalid_token": "Cheie invalidă.",
"sending_password_recovery_link": "Se trimite linkul de recuperare a parolei...",
"please_fill_all_fields": "Vă rugăm completați toate câmpurile.",
"password_updated": "Parolă actualizată!",
"reset_password": "Resetare parolă",
"enter_email_for_new_password": "Introduceți adresa dvs. de e-mail să vă putem trimite un link pentru a crea o parolă nouă.",
"update_password": "Actualizează parola",
"password_successfully_updated": "Parola dvs. a fost actualizată cu succes.",
"user_already_member": "Utilizator deja existent.",
"you_are_already_collection_owner": "Sunteți deja proprietarul colecției.",
"link_already_in_collection": "Acest link este deja în această colecție.",
"date_newest_first": "Data (începând cu cele mai noi)",
"date_oldest_first": "Data (începând cu cele mai vechi)",
"name_az": "Nume (A-Z)",
"name_za": "Nume (Z-A)",
"description_az": "Descriere (A-Z)",
"description_za": "Descriere (Z-A)",
"all_rights_reserved": "©️ {{date}} <0>Linkwarden</0>. Toate drepturile rezervate.",
"you_have_no_collections": "Nu aveți colecții...",
"you_have_no_tags": "Nu aveți etichete...",
"cant_change_collection_you_dont_own": "Nu puteți face modificări la o colecție pe care nu o dețineți.",
"account": "Cont",
"billing": "Facturare",
"linkwarden_version": "Linkwarden {{version}}",
"help": "Ajutor",
"github": "GitHub",
"twitter": "Twitter",
"mastodon": "Mastodon",
"link_preservation_in_queue": "Prezervarea linkului este în realizare",
"check_back_later": "Vă rugăm să reveniți mai târziu pentru a vedea rezultatul",
"there_are_more_formats": "Există mai multe formate de prezervare în așteptare",
"settings": "Setări",
"switch_to": "Schimbați la {{theme}}",
"logout": "Deconectare",
"start_journey": "Începe călătoria dvs. creând un nou link!",
"create_new_link": "Creează un link nou",
"new_link": "Link nou",
"create_new": "Creează nou...",
"pwa_install_prompt": "Instalează Linkwarden pe ecranul tău de pornire pentru un acces mai rapid și o experiență îmbunătățită. <0>Află mai multe</0>",
"full_content": "Conținut întreg",
"slower": "Mai lent",
"new_version_announcement": "Vezi ce este nou în <0>Linkwarden {{version}}</0>",
"creating": "Se creează...",
"upload_file": "Încărcă un fișier",
"content": "Conținut",
"file_types": "PDF, PNG, JPG (până la {{size}} MB)",
"description": "Descriere",
"auto_generated": "Va fi generat automat dacă nu este furnizat nimic.",
"example_link": "ex: Exemplu de link",
"hide": "Ascundeți",
"more": "Mai multe",
"options": "Opțiuni",
"description_placeholder": "Observații, gânduri etc.",
"deleting": "În curs de eliminare...",
"token_revoked": "Cheie de acces revocată.",
"revoke_token": "Revocați cheia de acces",
"revoke_confirmation": "Sunteți sigur că doriți să revocați acestă cheie de acces? Aplicațiile sau serviciile care o folosesc nu vor mai putea accesa Linkwarden.",
"revoke": "Revocă",
"sending_request": "Se trimite cererea...",
"link_being_archived": "Link-ul este în curs de arhivare...",
"preserved_formats": "Formate prezervate",
"available_formats": "Următoarele formate sunt disponibile pentru acest link",
"readable": "Format citibil",
"preservation_in_queue": "Prezervarea linkurilor este în progres",
"view_latest_snapshot": "Vezi ultimul snapshot pe archive.org",
"refresh_preserved_formats": "Împrospătează formatele conservate",
"this_deletes_current_preservations": "Se vor șterge prezervările curente",
"create_new_user": "Creează un utilizator nou",
"placeholder_johnny": "Andrei",
"placeholder_email": "andrei@exemplu.com",
"placeholder_john": "dan",
"user_created": "Utilizator creat!",
"fill_all_fields_error": "Vă rugăm completați toate câmpurile.",
"password_change_note": "<0>Notă:</0> Vă rugăm să vă asigurați că informați utilizatorul că trebuie să își schimbe parola.",
"create_user": "Creează utilizator",
"creating_token": "Se creează cheia de acces...",
"token_created": "Cheie creată!",
"access_token_created": "Cheie de acces creată",
"token_creation_notice": "Noua ta cheie a fost creată. Te rugăm să o copiezi și să o depozitezi undeva sigur. Nu o vei mai putea vedea din nou.",
"copied_to_clipboard": "Copiat în clipboard!",
"copy_to_clipboard": "Copiați în clipboard",
"create_access_token": "Creează o cheie de acces",
"expires_in": "Expiră în",
"token_name_placeholder": "ex: Pentru aplicația de IOS",
"create_token": "Creează cheia de acces",
"7_days": "7 zile",
"30_days": "30 de zile",
"60_days": "60 de zile",
"90_days": "90 de zile",
"no_expiration": "Fără expirare",
"creating_link": "Se creează linkul...",
"link_created": "Link creat!",
"link_name_placeholder": "Va fi generat automat dacă este lăsat gol.",
"link_url_placeholder": "ex: http://exemplu.com/",
"link_description_placeholder": "Observații, gânduri etc.",
"more_options": "Mai multe opțiuni",
"hide_options": "Ascunde opțiuni",
"create_link": "Creeați Link",
"new_sub_collection": "Sub-Colecție nouă",
"for_collection": "Pentru {{name}}",
"create_new_collection": "Creează o Colecție nouă",
"color": "Culoare",
"reset_defaults": "Revenire la valorile implicite",
"updating_collection": "Se actualizează Colecția...",
"collection_name_placeholder": "Ex: Colecție de exemplu",
"collection_description_placeholder": "Scopul acestei Colecții...",
"create_collection_button": "Creează Colecția",
"password_change_warning": "Vă rugăm să confirmați parola înainte de a vă schimba adresa de e-mail.",
"stripe_update_note": " Actualizarea acestui câmp vă va schimba și e-mailul de facturare pe Stripe.",
"sso_will_be_removed_warning": "Dacă vă schimbați adresa de e-mail, orice conexiuni existente {{service}} SSO vor fi eliminate.",
"old_email": "E-mailul vechi",
"new_email": "E-mail nou",
"confirm": "Confirmă",
"edit_link": "Editează Link",
"updating": "Se actualizează...",
"updated": "Actualizat!",
"placeholder_example_link": "Ex: Exemplu de link",
"make_collection_public": "Faceți colecția publică",
"make_collection_public_checkbox": "Faceți această colecție publică",
"make_collection_public_desc": "Acest lucru va permite oricui să vadă conținutul și utilizatorii acestei colecții.",
"sharable_link": "Link partajabil",
"copied": "Copiat!",
"members": "Membri",
"add_member_placeholder": "Adaugă membri prin e-mail sau nume de utilizator",
"owner": "Deținător",
"admin": "Administrator",
"contributor": "Colaborator",
"viewer": "Vizualizator",
"viewer_desc": "Acces numai pentru citire",
"contributor_desc": "Pot vizualiza și crea linkuri",
"admin_desc": "Acces complet la toate linkurile",
"remove_member": "Elimină membrul",
"placeholder_example_collection": "ex: Exemplu de Colecție",
"placeholder_collection_purpose": "Scopul acestei Colecții...",
"deleting_user": "În curs de eliminare...",
"user_deleted": "Utilizator șters.",
"delete_user": "Șterge utilizator",
"confirm_user_deletion": "Sigur doriți să ștergeți acest utilizator?",
"irreversible_action_warning": "Această acțiune este ireversibilă!",
"delete_confirmation": "Șterge, știu ce fac",
"delete_link": "Șterge linkul",
"deleted": "Șters.",
"link_deletion_confirmation_message": "Ești sigur că vrei să ștergi acest link?",
"warning": "Avertizare",
"irreversible_warning": "Această acțiune este ireversibilă!",
"tip": "Sfat",
"shift_key_tip": "Țineți apăsat tasta Shift în timp ce faceți clic pe 'Șterge' pentru a ocoli această confirmare în viitor.",
"deleting_collection": "În curs de ștergere...",
"collection_deleted": "Colecție ștearsă.",
"collection_deletion_prompt": "Sigur doriți să ștergeți această colecție?",
"type_name_placeholder": "Scrieți \"{{name}}\" aici.",
"deletion_warning": "Ștergerea acestei colecții va șterge permanent întregul conținut, și va deveni inaccesibil tuturor, inclusiv membrilor cu acces anterior.",
"leave_prompt": "Faceți clic pe butonul de mai jos pentru a părăsi colecția curentă.",
"leave": "Părăsiți",
"edit_links": "Editare {{count}} Linkuri",
"move_to_collection": "Mută în colecție",
"add_tags": "Adaugă etichete",
"remove_previous_tags": "Elimină etichetele anterioare",
"delete_links": "Șterge {{count}} Linkuri",
"links_deletion_confirmation_message": "Sunteți sigur că vreți să ștergeți {{count}} linkuri? ",
"warning_irreversible": "Atenție: Această acțiune este ireversibilă!",
"shift_key_instruction": "Țineți apăsată tasta „Shift” în timp ce faceți clic pe „Șterge” pentru a ocoli această confirmare în viitor.",
"link_selection_error": "Nu aveți permisiunea de a edita sau șterge acest element.",
"no_description": "Nu a fost furnizată o descriere.",
"applying": "Se aplică...",
"unpin": "Anulați fixarea",
"pin_to_dashboard": "Fixează la panoul de control",
"show_link_details": "Arată detaliile linkului",
"link_pinned": "Link fixat!",
"link_unpinned": "Link desprins!",
"webpage": "Pagină web",
"server_administration": "Administrare Server",
"all_collections": "Toate Colecțiile",
"dashboard": "Panou de control",
"demo_title": "Doar Demo",
"demo_desc": "Aceasta este doar o instanță demonstrativă a Linkwarden așadar încărcările sunt dezactivate.",
"demo_desc_2": "Dacă doriți să încercați versiunea completă, vă puteți înscrie pentru o încercare gratuită la:",
"demo_button": "Autentificare ca utilizator demo",
"regular": "Normal",
"thin": "Subțire",
"bold": "Îngroșat",
"fill": "Completare",
"duotone": "Duotone",
"light_icon": "Luminos",
"search": "Caută",
"set_custom_icon": "Setare pictogramă personalizată",
"view": "Vezi",
"show": "Arată",
"image": "Imagine",
"icon": "Pictogramă",
"date": "Dată",
"preview_unavailable": "Previzualizare indisponibilă",
"saved": "Salvat",
"untitled": "Fără nume",
"no_tags": "Fără etichete.",
"no_description_provided": "Nu a fost furnizată o descriere.",
"change_icon": "Schimbă Pictograma",
"upload_banner": "Încarcă un banner",
"columns": "Coloane",
"default": "Implicit",
"invalid_url_guide": "Vă rugăm să introduceți o adresă validă pentru link. (Ar trebui să înceapă cu http/https)",
"email_invalid": "Vă rugăm să introduceți o adresă de e-mail validă.",
"username_invalid_guide": "Numele de utilizator trebuie să aibă cel puțin 3 caractere, nu sunt permise spațiile și caracterele speciale.",
"team_management": "Gestionare echipă",
"invite_user": "Invită utilizator",
"invite_users": "Invită utilizatori",
"invite_user_desc": "Pentru a invita pe cineva în echipa ta, vă rugăm să introduceți mai jos adresa de e-mail:",
"invite_user_note": "Vă rugăm să rețineți că, odată ce invitația este acceptată, un loc suplimentar va fi cumpărat și contul dumneavoastră va fi facturat automat pentru această adăugare.",
"invite_user_price": "Costul fiecărui loc este de ${{price}} pe lună sau ${{priceAnnual}} pe an, în funcție de abonamentul dvs. curent.",
"send_invitation": "Trimite invitația",
"learn_more": "Aflați mai multe",
"invitation_desc": "{{owner}} v-a invitat să vă alăturați Linkwarden. \nPentru a continua, vă rugăm să finalizați configurarea contului.",
"invitation_accepted": "Invitație acceptată!",
"status": "Stare",
"pending": "În așteptare",
"active": "Active",
"manage_seats": "Gestionare locuri",
"seats_purchased": "{{count}} locuri achiziționate",
"seat_purchased": "{{count}} loc achiziționat",
"date_added": "Data adăugării",
"resend_invite": "Trimiteți din nou invitația",
"resend_invite_success": "Invitație trimisă din nou!",
"remove_user": "Eliminați utilizatorul",
"continue_to_dashboard": "Continuați la Panou de control",
"confirm_user_removal_desc": "Vor avea nevoie de un abonament pentru a accesa Linkwarden din nou.",
"click_out_to_apply": "Faceți clic în exterior pentru a aplica",
"submit": "Trimitere",
"thanks_for_feedback": "Vă mulțumim pentru feedback!",
"quick_survey": "Sondaj rapid",
"how_did_you_discover_linkwarden": "Cum ai descoperit Linkwarden?",
"rather_not_say": "Prefer să nu spun",
"search_engine": "Motor de căutare (Google, Bing, etc.)",
"reddit": "Reddit",
"lemmy": "Lemmy",
"people_recommendation": "O recomandare (familie, etc.)",
"open_all_links": "Deschide toate linkurile",
"ai_settings": "Setări AI",
"generate_tags_for_existing_links": "Generează etichete pentru toate linkurile existente",
"ai_tagging_method": "Metoda etichetării de către AI:",
"based_on_predefined_tags": "Pe baza etichetelor predefinite",
"based_on_predefined_tags_desc": "Categorizare automată a linkurilor, cu etichete predefinite, bazată pe conținutul fiecărui link.",
"based_on_existing_tags": "Pe baza etichetelor existente",
"based_on_existing_tags_desc": "Auto-categorizare linkuri cu etichetele existente bazate pe conținutul fiecărui link.",
"auto_generate_tags": "Generare automată etichete",
"auto_generate_tags_desc": "Generează automat etichetele relevante pe baza conținutului fiecărui link.",
"disabled": "Dezactivat",
"ai_tagging_disabled_desc": "Etichetarea AI este dezactivată.",
"tag_selection_placeholder": "Alege sau adaugă etichete personalizate…",
"rss_subscriptions": "Abonamente RSS",
"rss_subscriptions_desc": "Abonamentele RSS sunt un mijloc prin care poți ține pasul cu site-urile si blogurile tale preferate. Linkwarden va obține automate cele mai curente articole odată la {{number}} minute din fluxurile pe care le-ai furnizat.",
"rss_deletion_confirmation": "Sunteți sigur că doriți să ștergeți acest abonament RSS?",
"new_rss_subscription": "Abonament RSS nou",
"rss_subscription_deleted": "Abonament RSS șters!",
"create_rss_subscription": "Creați un abonament RSS",
"rss_feed": "Flux RSS",
"pinned_links": "Linkuri fixate",
"recent_links": "Linkuri recente",
"search_results": "Rezultatele căutării",
"linkwarden_icon": "Iconiță Linkwarden",
"permanent_session": "Aceasta este o sesiune permanentă",
"locale": "en-US",
"not_found_404": "404 - Nu a fost găsit",
"collection_publicly_shared": "Această colecție este partajată public.",
"search_for_links": "Caută linkuri",
"search_query_invalid_symbol": "Căutarea nu trebuie să conțină '%'.",
"open_modal_new_tab": "Deschide această fereastră într-o filă nouă",
"file": "Fișier",
"tag_preservation_rule_label": "Reguli de prezervare după etichetă (suprascrie setările globale):",
"ai_tagging": "Etichetare AI",
"worker": "Worker",
"regenerate_broken_preservations": "Regenerează toate prezervările stricate sau lipsite pentru toți utilizatorii.",
"delete_all_preservations": "Ștergeți prezervarea pentru toate paginile web, se aplică tuturor utilizatorilor.",
"delete_all_preservations_and_regenerate": "Ștergeți și re-prezervați toate paginile web, se aplică tuturor utilizatorilor.",
"delete_all_preservation_warning": "Această acțiune va șterge toate prezervările existente de pe server.",
"no_broken_preservations": "Nu există prezervări stricate.",
"links_are_being_represerved": "Linkurile sunt în curs de re-prezervare...",
"cancel": "Anulează",
"preservation_rules": "Reguli de prezervare:",
"links_being_archived": "Linkurile sunt în curs de arhivare...",
"display_on_dashboard": "Afișare pe Panoul de control",
"dashboard_stats": "Statistici panou de control",
"no_results_found": "Nu s-a găsit niciun rezultat.",
"no_link_in_collection": "Niciun link în această colecție",
"no_link_in_collection_desc": "Această colecție nu are încă linkuri.",
"theme": "Temă",
"font_style": "Stil de font",
"font_size": "Mărime font",
"line_height": "Înălțime linie",
"line_width": "Lățime linie",
"notes_highlights": "Note și evidențe",
"no_notes_highlights": "Nu au fost găsite note sau evidențe pentru acest link.",
"save": "Salvați",
"edit_layout": "Editare Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "Această acțiune va șterge actualele formate prezervate și va re-prezerva {{count}} linkuri.",
"refresh_preserved_formats_confirmation_desc": "Această acțiune va șterge acest format prezervat și va re-prezerva linkul.",
"tag_already_added": "Această etichetă este deja adăugată.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "Редактировать макет",
"refresh_multiple_preserved_formats_confirmation_desc": "Это приведет к удалению сохраненных форматов и восстановлению ссылок (кол-во: {{count}}).",
"refresh_preserved_formats_confirmation_desc": "Это приведет к удалению сохраненных форматов и сохранению ссылки повторно.",
"tag_already_added": "Этот тег уже добавлен."
"tag_already_added": "Этот тег уже добавлен.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -493,5 +493,25 @@
"edit_layout": "编辑仪表盘",
"refresh_multiple_preserved_formats_confirmation_desc": "此操作将删除当前已保存的格式,并重新保存 {{count}} 个链接。",
"refresh_preserved_formats_confirmation_desc": "此操作将删除当前已保存的格式,并重新保存此链接。",
"tag_already_added": "此标签已添加。"
"tag_already_added": "此标签已添加。",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "删除所有标签",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -170,6 +170,7 @@ export default async function autoTagLink(
id: user.id,
},
},
aiGenerated: true,
},
})),
},

View File

@@ -0,0 +1,29 @@
import { prisma } from "@linkwarden/prisma";
export async function countUnprocessedBillableLinks() {
const billedOwnerIds = process.env.STRIPE_SECRET_KEY
? (
await prisma.user.findMany({
where: {
OR: [
{ subscriptions: { is: { active: true } } },
{ parentSubscription: { is: { active: true } } },
],
},
select: { id: true },
})
).map((u) => u.id)
: undefined;
const count = await prisma.link.count({
where: {
lastPreserved: null,
NOT: { url: null },
...(billedOwnerIds && billedOwnerIds.length
? { collection: { ownerId: { in: billedOwnerIds } } }
: {}),
},
});
return count;
}

View File

@@ -5,6 +5,9 @@ type PickLinksOptions = {
maxBatchLinks: number;
};
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default async function getLinkBatchFairly({
maxBatchLinks,
}: PickLinksOptions) {
@@ -38,9 +41,26 @@ export default async function getLinkBatchFairly({
OR: [
{ subscriptions: { is: { active: true } } },
{ parentSubscription: { is: { active: true } } },
...(REQUIRE_CC
? []
: [
{
createdAt: {
gte: new Date(
new Date().getTime() -
Number(TRIAL_PERIOD_DAYS) * 86400000
),
},
},
]),
],
}
: {}),
...(process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"
? {
emailVerified: { not: null },
}
: {}),
},
orderBy: [{ lastPickedAt: { sort: "asc", nulls: "first" } }, { id: "asc" }],
select: { id: true, lastPickedAt: true },

View File

@@ -26,14 +26,14 @@
"meilisearch": "^0.48.2",
"node-fetch": "^2.7.0",
"ollama-ai-provider": "^1.2.0",
"playwright": "^1.45.0",
"playwright": "^1.55.0",
"rss-parser": "^3.13.0",
"socks-proxy-agent": "^8.0.2",
"tsx": "^4.19.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.55.0",
"@types/node": "^22.14.1",
"nodemon": "^3.1.9",
"typescript": "^5.8.3"

View File

@@ -4,6 +4,7 @@ import { LinkWithCollectionOwnerAndTags } from "@linkwarden/types";
import { delay } from "@linkwarden/lib";
import getLinkBatchFairly from "../lib/getLinkBatchFairly";
import { launchBrowser } from "../lib/browser";
import { countUnprocessedBillableLinks } from "../lib/countUnprocessedBillableLinks";
const ARCHIVE_TAKE_COUNT = Number(process.env.ARCHIVE_TAKE_COUNT || "") || 5;
const BROWSER_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
@@ -70,9 +71,7 @@ export async function linkProcessing(interval = 10) {
const processingPromises = links.map((e) => archiveLink(e));
await Promise.allSettled(processingPromises);
const unprocessedLinkCount = await prisma.link.count({
where: { lastPreserved: null, url: { not: null } },
});
const unprocessedLinkCount = await countUnprocessedBillableLinks();
console.log(
"\x1b[34m%s\x1b[0m",

View File

@@ -5,7 +5,7 @@ import {
DashboardSectionType,
Theme,
} from "@linkwarden/prisma/client";
import { z } from "zod";
import { number, z } from "zod";
// const stringField = z.string({
// errorMap: (e) => ({
@@ -52,7 +52,8 @@ export const PostUserSchema = () => {
.min(3)
.max(50)
.regex(/^[a-z0-9_-]{3,50}$/),
invite: z.boolean().optional(),
invite: z.boolean().default(false),
acceptPromotionalEmails: z.boolean().default(false),
});
};
@@ -268,6 +269,19 @@ export const PostTagSchema = z.object({
export type PostTagSchemaType = z.infer<typeof PostTagSchema>;
export const TagBulkDeletionSchema = z.object({
tagIds: z.array(z.number()).min(1),
});
export type TagBulkDeletionSchemaType = z.infer<typeof TagBulkDeletionSchema>;
export const MergeTagsSchema = z.object({
newTagName: z.string().trim().max(50),
tagIds: z.array(z.number()).min(1),
});
export type MergeTagsSchemaType = z.infer<typeof MergeTagsSchema>;
export const PostHighlightSchema = z.object({
color: z.string().trim().max(50),
comment: z.string().trim().max(2048).nullish(),

View File

@@ -2,6 +2,8 @@ import { prisma } from "@linkwarden/prisma";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
const stripeEnabled = process.env.STRIPE_SECRET_KEY;
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export const hasPassedLimit = async (
userId: number,
@@ -22,6 +24,7 @@ export const hasPassedLimit = async (
select: {
parentSubscriptionId: true,
subscriptions: { select: { id: true, quantity: true } },
createdAt: true,
},
});
@@ -29,6 +32,22 @@ export const hasPassedLimit = async (
return true;
}
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (!REQUIRE_CC && daysLeft > 0) {
const totalLinks = await prisma.link.count({
where: {
createdById: userId,
},
});
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
}
const subscriptionId = user?.parentSubscriptionId ?? user?.subscriptions?.id;
let quantity = user?.subscriptions?.quantity;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "aiGenerated" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "acceptPromotionalEmails" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -68,6 +68,7 @@ model User {
referredBy String?
dashboardSections DashboardSection[]
lastPickedAt DateTime?
acceptPromotionalEmails Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}
@@ -206,6 +207,7 @@ model Tag {
archiveAsReadable Boolean?
archiveAsWaybackMachine Boolean?
aiTag Boolean?
aiGenerated Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

View File

@@ -29,7 +29,9 @@ const useCollections = (auth?: MobileAuth) => {
: undefined
);
const data = await response.json();
return data.response;
if (Array.isArray(data.response)) return data.response;
else return [];
},
enabled: status === "authenticated",
});

View File

@@ -8,6 +8,10 @@ import { MobileAuth, TagIncludingLinkCount } from "@linkwarden/types";
import { useSession } from "next-auth/react";
import { Tag } from "@linkwarden/prisma/client";
import { ArchivalTagOption } from "@linkwarden/types/inputSelect";
import {
MergeTagsSchemaType,
TagBulkDeletionSchemaType,
} from "@linkwarden/lib/schemaValidation";
const useTags = (auth?: MobileAuth): UseQueryResult<Tag[], Error> => {
let status: "loading" | "authenticated" | "unauthenticated";
@@ -69,7 +73,7 @@ const useUpdateTag = () => {
});
};
const useUpdateArchivalTags = () => {
const useUpsertTags = () => {
const queryClient = useQueryClient();
return useMutation({
@@ -129,4 +133,61 @@ const useRemoveTag = () => {
});
};
export { useTags, useUpdateTag, useUpdateArchivalTags, useRemoveTag };
const useBulkTagDeletion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: TagBulkDeletionSchemaType) => {
const response = await fetch(`/api/v1/tags`, {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const responseData = await response.json();
if (!response.ok) throw new Error(responseData.response);
return responseData.response;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["links"] });
},
});
};
const useMergeTags = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: MergeTagsSchemaType) => {
const response = await fetch(`/api/v1/tags/merge`, {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const responseData = await response.json();
if (!response.ok) throw new Error(responseData.response);
return responseData.response;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["links"] });
},
});
};
export {
useTags,
useUpdateTag,
useUpsertTags,
useRemoveTag,
useBulkTagDeletion,
useMergeTags,
};

View File

@@ -3221,12 +3221,12 @@
tiny-glob "^0.2.9"
tslib "^2.4.0"
"@playwright/test@^1.45.0":
version "1.45.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.45.0.tgz#790a66165a46466c0d7099dd260881802f5aba7e"
integrity sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==
"@playwright/test@^1.55.0":
version "1.55.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.0.tgz#080fa6d9ee6d749ff523b1c18259572d0268b963"
integrity sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==
dependencies:
playwright "1.45.0"
playwright "1.55.0"
"@prisma/client@^6.10.1":
version "6.10.1"
@@ -3286,6 +3286,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65"
integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==
"@radix-ui/primitive@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
"@radix-ui/react-arrow@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz#08e263c692b3a56a3f1c4bdc8405b7f73f070963"
@@ -3300,6 +3305,20 @@
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-checkbox@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz#db45ca8a6d5c056a92f74edbb564acee05318b79"
integrity sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-collection@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.4.tgz#45fb4215ca26a84bd61b9b1337105e4d4e01b686"
@@ -3622,6 +3641,14 @@
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-presence@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db"
integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-primitive@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884"
@@ -3797,6 +3824,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
"@radix-ui/react-use-previous@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5"
integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==
"@radix-ui/react-use-rect@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152"
@@ -11156,17 +11188,17 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
playwright-core@1.45.0:
version "1.45.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.45.0.tgz#5741a670b7c9060ce06852c0051d84736fb94edc"
integrity sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==
playwright-core@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.0.tgz#ec8a9f8ef118afb3e86e0f46f1393e3bea32adf4"
integrity sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==
playwright@1.45.0, playwright@^1.45.0:
version "1.45.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.45.0.tgz#400c709c64438690f13705cb9c88ef93089c5c27"
integrity sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==
playwright@1.55.0, playwright@^1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.0.tgz#7aca7ac3ffd9e083a8ad8b2514d6f9ba401cc78b"
integrity sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==
dependencies:
playwright-core "1.45.0"
playwright-core "1.55.0"
optionalDependencies:
fsevents "2.3.2"