@@ -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",
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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() || ""}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 238 KiB |
BIN
apps/mobile/assets/images/linkwarden.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 82 B |
@@ -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: {
|
||||
|
||||
45
apps/mobile/components/ActionSheets/SupportSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/mobile/components/ui/Spinner.tsx
Normal 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;
|
||||
@@ -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("/");
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
73
apps/web/components/ModalContent/BulkDeleteTagsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/ModalContent/DeleteTagModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/ModalContent/MergeTagsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/web/components/ModalContent/NewTagModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
118
apps/web/components/TagCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
28
apps/web/components/ui/checkbox.tsx
Normal 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 }
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
65
apps/web/lib/api/controllers/tags/bulkTagDelete.ts
Normal 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 };
|
||||
}
|
||||
79
apps/web/lib/api/controllers/tags/mergeTags.ts
Normal 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 };
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/web/pages/api/v1/tags/merge.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
288
apps/web/pages/tags/index.tsx
Normal 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 };
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"password_successfully_updated": "Votre mot de passe a été mis à jour avec succès.",
|
||||
"user_already_member": "L’utilisateur 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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
517
apps/web/public/locales/ro/common.json
Normal 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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export default async function autoTagLink(
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
aiGenerated: true,
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
||||
29
apps/worker/lib/countUnprocessedBillableLinks.ts
Normal 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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tag" ADD COLUMN "aiGenerated" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "acceptPromotionalEmails" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
60
yarn.lock
@@ -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"
|
||||
|
||||
|
||||