mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 03:57:01 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7ab767872 | ||
|
|
fdf48abd29 | ||
|
|
59252759f2 | ||
|
|
dd96d80d42 | ||
|
|
f8efbe95e6 | ||
|
|
cf84474921 | ||
|
|
eccf27425c | ||
|
|
3926e566b7 | ||
|
|
bca333be26 | ||
|
|
02a1e3b455 | ||
|
|
5b0c66b5e2 | ||
|
|
7c0c823c41 | ||
|
|
a8d2c55d12 | ||
|
|
37410fcf97 | ||
|
|
0ab4a2d883 | ||
|
|
756b896fe6 | ||
|
|
e3e3611b54 | ||
|
|
4bf65f8ebd | ||
|
|
6956c71aa2 | ||
|
|
e0f357513c | ||
|
|
daeb859990 | ||
|
|
f072bcd0b0 | ||
|
|
a497dc953a | ||
|
|
dcf6d72c01 | ||
|
|
d749487fb6 | ||
|
|
7079355013 | ||
|
|
46762b0d36 | ||
|
|
389e5df117 | ||
|
|
865bff2214 | ||
|
|
33553e22d5 | ||
|
|
1323787294 | ||
|
|
0a812fa72b | ||
|
|
9faf9d844e | ||
|
|
1558854f78 | ||
|
|
ab19df767c | ||
|
|
218fd504bf | ||
|
|
02158d7621 | ||
|
|
9edb42d181 | ||
|
|
8ba370bf62 | ||
|
|
a32934ee9d | ||
|
|
ff5ba2097d | ||
|
|
703f84403e | ||
|
|
f9ec18c51a | ||
|
|
3d0651d4af | ||
|
|
f30bf63c24 | ||
|
|
e2d89a56d6 | ||
|
|
40c3ccca93 | ||
|
|
1e515d5284 | ||
|
|
cb9cdc92c8 | ||
|
|
639f777b8a | ||
|
|
48b7384490 | ||
|
|
3bff1650c7 | ||
|
|
cadea5c654 | ||
|
|
e9b7c21ea0 | ||
|
|
03ca0c5e3d |
@@ -5,3 +5,11 @@ pgdata
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
README.md
|
||||
.yarn/install-state.gz
|
||||
./apps/mobile
|
||||
**/.next/cache
|
||||
**/.next/cache/**
|
||||
data
|
||||
data.ms
|
||||
.git
|
||||
meili_data
|
||||
14
.github/FUNDING.yml
vendored
14
.github/FUNDING.yml
vendored
@@ -1,13 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: linkwarden
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
github: daniel31x13
|
||||
buy_me_a_coffee: daniel31x13
|
||||
46
.github/pull_request_template.md
vendored
Normal file
46
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
- Fixes #XXXX (GitHub issue number)
|
||||
|
||||
## Visual Demo
|
||||
|
||||
A visual demonstration is strongly recommended, for both the original and new change **(video / image)**.
|
||||
|
||||
#### Video Demo (if applicable):
|
||||
|
||||
- Show screen recordings of the issue or feature.
|
||||
- Demonstrate how to reproduce the issue, the behavior before and after the change.
|
||||
|
||||
#### Image Demo (if applicable):
|
||||
|
||||
- Add side-by-side screenshots of the original and updated change.
|
||||
- Highlight any significant change(s).
|
||||
|
||||
## AI Assistance (Required)
|
||||
|
||||
We allow AI-assisted development, but reviewers need transparency to assess risk, maintainability, and correctness.
|
||||
|
||||
#### AI usage level (check one)
|
||||
|
||||
- [ ] None (no AI used)
|
||||
- [ ] Light (spellcheck/rewording/comments/docs only)
|
||||
- [ ] Medium (AI suggested small code changes/snippets that I adapted)
|
||||
- [ ] Heavy (AI significantly shaped the implementation or architecture)
|
||||
|
||||
#### Which tool(s) where used?
|
||||
|
||||
- e.g., ChatGPT, Copilot, Cursor, etc.
|
||||
|
||||
## What was verified by the author?
|
||||
|
||||
<!-- Add what you personally checked to ensure correctness and safety. -->
|
||||
|
||||
- [ ] I reviewed **and** understood all AI/human generated code
|
||||
- [ ] I validated behavior locally (tests/manual verification)
|
||||
- [ ] I checked edge cases and failure modes
|
||||
|
||||
## Submission Acknowledgement
|
||||
|
||||
- [ ] I acknowledge that a decent size PR without self-review might be rejected
|
||||
14
.github/workflows/playwright-tests.yml
vendored
14
.github/workflows/playwright-tests.yml
vendored
@@ -61,11 +61,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
- name: Use Node.js and Enable Yarn 4
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18"
|
||||
cache: 'yarn'
|
||||
node-version: "20"
|
||||
|
||||
- name: Enable Yarn 4
|
||||
run: |
|
||||
sudo rm -f /usr/bin/yarn /usr/bin/yarnpkg || true
|
||||
corepack enable
|
||||
corepack prepare yarn@4.12.0 --activate
|
||||
yarn --version
|
||||
|
||||
- name: Initialize PostgreSQL
|
||||
run: |
|
||||
@@ -74,7 +80,7 @@ jobs:
|
||||
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
|
||||
|
||||
- name: Install packages
|
||||
run: yarn install -y
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Cache playwright dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@@ -48,9 +49,11 @@ certificates
|
||||
|
||||
# generated files and folders
|
||||
/data
|
||||
/data.ms
|
||||
meilisearch
|
||||
meili_data
|
||||
.idea
|
||||
prisma/dev.db
|
||||
data.ms
|
||||
.turbo
|
||||
|
||||
service-account-file.json
|
||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
17
Dockerfile
17
Dockerfile
@@ -10,7 +10,13 @@ RUN set -eux && cargo install --locked monolith
|
||||
# Purpose: Compiles the frontend and
|
||||
# Notes:
|
||||
# - Nothing extra should be left here. All commands should cleanup
|
||||
FROM node:22.14-bullseye-slim AS main-app
|
||||
FROM node:20.19.6-bullseye-slim AS main-app
|
||||
|
||||
ENV YARN_HTTP_TIMEOUT=10000000
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
ENV PRISMA_HIDE_UPDATE_MESSAGE=1
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -18,6 +24,10 @@ RUN mkdir /data
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY ./.yarnrc.yml ./
|
||||
|
||||
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
|
||||
|
||||
COPY ./apps/worker/package.json ./apps/worker/
|
||||
@@ -28,7 +38,7 @@ COPY ./yarn.lock ./package.json ./
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
|
||||
set -eux && \
|
||||
yarn install --network-timeout 10000000 && \
|
||||
yarn workspaces focus linkwarden @linkwarden/web @linkwarden/worker && \
|
||||
# Install curl for healthcheck, and ca-certificates to prevent monolith from failing to retrieve resources due to invalid certificates
|
||||
apt-get update && \
|
||||
apt-get install -yqq --no-install-recommends curl ca-certificates && \
|
||||
@@ -46,7 +56,8 @@ RUN set -eux && \
|
||||
COPY . .
|
||||
|
||||
RUN yarn prisma:generate && \
|
||||
yarn web:build
|
||||
yarn web:build && \
|
||||
rm -rf apps/web/.next/cache
|
||||
|
||||
HEALTHCHECK --interval=30s \
|
||||
--timeout=5s \
|
||||
|
||||
67
README.md
67
README.md
@@ -38,32 +38,49 @@ Linkwarden is also designed with collaboration in mind, enabling you to share li
|
||||
|
||||
## Features
|
||||
|
||||
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage.
|
||||
- 📖 Reader view of the webpage, with the ability to highlight and annotate text.
|
||||
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
|
||||
- ✨ Local AI Tagging to automatically tag your links based on their content (Optional).
|
||||
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
|
||||
- 👥 Collaborate on gathering links in a collection.
|
||||
- 🎛️ Customize the permissions of each member.
|
||||
- 🌐 Share your collected links and preserved formats with the world.
|
||||
- 📌 Pin your favorite links to dashboard.
|
||||
- 🔍 Full text search, filter and sort for easy retrieval.
|
||||
- 📱 Responsive design and supports most modern browsers.
|
||||
- 🌓 Dark/Light mode support.
|
||||
- 🧩 Browser extension. [Star it here!](https://github.com/linkwarden/browser-extension)
|
||||
- 📸 Auto capture a screenshot, PDF, and single html file of each webpage
|
||||
- 📖 Reader view of the webpage, with the ability to highlight and annotate text
|
||||
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot (optional)
|
||||
- ✨ Local AI Tagging to automatically tag your links based on their content (optional)
|
||||
- 📂 Organize links by collection, sub-collection, name, description and multiple tags
|
||||
- 👥 Collaborate on gathering links in a collection
|
||||
- 🎛️ Customize the permissions of each member
|
||||
- 🌐 Share your collected links and preserved formats with the world
|
||||
- 📱 Native iOS and android mobile apps
|
||||
- 🔍 Full text search, filter and sort for easy retrieval
|
||||
- 🌓 Dark/Light mode support
|
||||
- 🧩 Browser extension (star it [here](https://github.com/linkwarden/browser-extension)!)
|
||||
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
|
||||
- ⬇️ Import and export your bookmarks.
|
||||
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
||||
- 📦 Installable Progressive Web App (PWA).
|
||||
- 🍎 iOS Shortcut to save Links to Linkwarden.
|
||||
- 🔑 API keys.
|
||||
- ✅ Bulk actions.
|
||||
- 👥 User administration.
|
||||
- 🌐 Support for Other Languages (i18n).
|
||||
- 📁 Image and PDF Uploads.
|
||||
- 🎨 Custom Icons for Links and Collections.
|
||||
- 🔔 RSS Feed Subscription.
|
||||
- ✨ And many more features. (Literally!)
|
||||
- ⬆️ Upload from SingleFile (check out the [guide](https://docs.linkwarden.app/Usage/upload-from-singlefile))
|
||||
- 🔐 SSO integration (Enterprise and Self-hosted users only)
|
||||
- 🍎 iOS Shortcut to save links to Linkwarden
|
||||
- 🔑 API keys
|
||||
- ✅ Bulk actions
|
||||
- 👥 User administration
|
||||
- 🌐 Support for other languages (i18n)
|
||||
- 📁 Image and PDF uploads
|
||||
- 🎨 Custom icons for links and collections
|
||||
- 🔔 RSS feed subscription
|
||||
- ✨ And many more features (literally!)
|
||||
|
||||
## Get Our Official Mobile App
|
||||
|
||||
<img src="./assets/mobile_apps.png" alt="Different screens (iPad, Pixel, and iPhone)" width="400" />
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To use the app you’ll first need a Linkwarden account.
|
||||
|
||||
To create an account, you can choose between:
|
||||
|
||||
- [**Linkwarden Cloud**](https://linkwarden.app/#pricing) – instant setup, and your subscription directly supports ongoing development.
|
||||
- [**Self-hosted Linkwarden**](https://docs.linkwarden.app/self-hosting/installation) – free, but you’ll need to deploy and maintain a Linkwarden instance on a server.
|
||||
|
||||
After creating an account, download the app from your preferred store:
|
||||
|
||||
[](https://apps.apple.com/app/linkwarden/id6752550960)
|
||||
[](https://play.google.com/store/apps/details?id=app.linkwarden)
|
||||
|
||||
(To get the app as an APK outside Google Play, check out our [builds](https://github.com/linkwarden/builds) repository.)
|
||||
|
||||
## Like what we're doing? Give us a Star ⭐
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node-linker=hoisted
|
||||
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Linkwarden",
|
||||
"slug": "linkwarden",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "linkwarden",
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function CollectionsScreen() {
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{collections.isLoading ? (
|
||||
<View className="flex justify-center h-full items-center">
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
|
||||
@@ -27,8 +27,8 @@ export default function Layout() {
|
||||
Platform.OS === "ios"
|
||||
? "transparent"
|
||||
: colorScheme === "dark"
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
? rawTheme["dark"]["base-100"]
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Platform, ScrollView, StyleSheet } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useDashboardData } from "@linkwarden/router/dashboardData";
|
||||
import useAuthStore from "@/store/auth";
|
||||
@@ -53,22 +60,36 @@ export default function DashboardScreen() {
|
||||
});
|
||||
}, [dashboardSections]);
|
||||
|
||||
const [pullRefreshing, setPullRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setPullRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
dashboardData.refetch(),
|
||||
userData.refetch(),
|
||||
collectionsData.refetch(),
|
||||
tagsData.refetch(),
|
||||
]);
|
||||
} finally {
|
||||
setPullRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (orderedSections.length === 0 && dashboardData.isLoading)
|
||||
return (
|
||||
<View className="flex justify-center h-screen items-center bg-base-100">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<Spinner
|
||||
refreshing={
|
||||
dashboardData.isRefetching ||
|
||||
userData.isRefetching ||
|
||||
collectionsData.isRefetching ||
|
||||
tagsData.isRefetching
|
||||
}
|
||||
onRefresh={() => {
|
||||
dashboardData.refetch();
|
||||
userData.refetch();
|
||||
collectionsData.refetch();
|
||||
tagsData.refetch();
|
||||
}}
|
||||
refreshing={pullRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
progressBackgroundColor={
|
||||
rawTheme[colorScheme as ThemeName]["base-200"]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function TagsScreen() {
|
||||
collapsableChildren={false}
|
||||
>
|
||||
{tags.isLoading ? (
|
||||
<View className="flex justify-center h-full items-center">
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
|
||||
@@ -20,56 +20,58 @@ export default function HomeScreen() {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={SlideInDown.springify().damping(100).stiffness(300)}
|
||||
className="flex-col justify-end h-full bg-primary relative"
|
||||
className="flex-col justify-end h-full"
|
||||
>
|
||||
<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>
|
||||
<View className="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>
|
||||
<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={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<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>
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
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.navigate("/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>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
<Svg
|
||||
viewBox="0 0 1440 320"
|
||||
width={Dimensions.get("screen").width}
|
||||
height={Dimensions.get("screen").width * (320 / 1440) + 2}
|
||||
>
|
||||
<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>
|
||||
<SafeAreaView
|
||||
edges={["bottom"]}
|
||||
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.navigate("/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>
|
||||
</SafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Platform, Text, View } from "react-native";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Alert, Platform } from "react-native";
|
||||
import { View, Text, Alert } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActionSheet, {
|
||||
FlatList,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Platform, Text, View } from "react-native";
|
||||
import { Alert, Text, View } from "react-native";
|
||||
import { useRef, useState } from "react";
|
||||
import ActionSheet, { ActionSheetRef } from "react-native-actions-sheet";
|
||||
import Input from "@/components/ui/Input";
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Links({ links, data }: Props) {
|
||||
const [promptedRefetch, setPromptedRefetch] = useState(false);
|
||||
|
||||
return data.isLoading ? (
|
||||
<View className="flex justify-center h-full items-center">
|
||||
<View className="flex justify-center h-screen items-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import { IconSymbol } from "../ui/IconSymbol";
|
||||
import ModalBase from "../ModalBase";
|
||||
import { Text } from "react-native";
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
export default function AddLink({ isVisible, onClose }: Props) {
|
||||
return (
|
||||
// <ModalBase isVisible={isVisible} onClose={onClose}>
|
||||
<Text>Hi</Text>
|
||||
// </ModalBase>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
@@ -15,6 +20,7 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"corepack": true,
|
||||
"distribution": "store",
|
||||
"autoIncrement": true,
|
||||
"channel": "production"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@linkwarden/mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
@@ -76,7 +76,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~52.0.2",
|
||||
|
||||
@@ -3,13 +3,10 @@ import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
import { MobileAuth } from "@linkwarden/types";
|
||||
import { Alert } from "react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { mmkvPersister } from "@/lib/queryPersister";
|
||||
import { clearCache } from "@/lib/cache";
|
||||
|
||||
const CACHE_DIR = FileSystem.documentDirectory + "archivedData/";
|
||||
|
||||
type AuthStore = {
|
||||
auth: MobileAuth;
|
||||
signIn: (
|
||||
@@ -78,31 +75,42 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await fetch(instance + "/api/v1/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(async (res) => {
|
||||
try {
|
||||
const res = await Promise.race([
|
||||
fetch(`${instance}/api/v1/session`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||
),
|
||||
]);
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
set({ auth: { session, instance, status: "authenticated" } });
|
||||
router.replace("/(tabs)/dashboard");
|
||||
} else {
|
||||
Alert.alert("Error", "Invalid credentials");
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.message === "TIMEOUT") {
|
||||
Alert.alert(
|
||||
"Request timed out",
|
||||
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Network error",
|
||||
"Could not connect to the server. Please check your network configuration and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
signOut: async () => {
|
||||
|
||||
@@ -15,13 +15,13 @@ const useDataStore = create<DataStore>((set, get) => ({
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
theme: "light",
|
||||
theme: "system",
|
||||
preferredBrowser: "app",
|
||||
},
|
||||
setData: async () => {
|
||||
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
|
||||
|
||||
colorScheme.set(dataString.theme || "light");
|
||||
colorScheme.set(dataString.theme || "system");
|
||||
|
||||
if (dataString)
|
||||
set((state) => ({ data: { ...state.data, ...dataString } }));
|
||||
|
||||
@@ -9,24 +9,39 @@ type Props = {
|
||||
|
||||
export default function Announcement({ toggleAnnouncementBar }: Props) {
|
||||
const announcementId = localStorage.getItem("announcementId");
|
||||
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||
|
||||
return (
|
||||
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
|
||||
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
|
||||
<i className="bi-stars text-xl text-yellow-600 dark:text-yellow-500"></i>
|
||||
<p className="w-4/5 text-center text-sm sm:text-base">
|
||||
<Trans
|
||||
i18nKey="new_version_announcement"
|
||||
values={{ version: announcementId }}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://blog.linkwarden.app/releases/${announcementId}`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
{announcementId ? (
|
||||
<Trans
|
||||
i18nKey="new_version_announcement"
|
||||
values={{ version: announcementId }}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://linkwarden.app/blog/releases/${announcementId}`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
) : announcementMessage ? (
|
||||
<Trans
|
||||
i18nKey={announcementMessage}
|
||||
components={[
|
||||
<Link
|
||||
href={`https://linkwarden.app/blog`}
|
||||
target="_blank"
|
||||
className="underline decoration-dotted underline-offset-4 hover:text-primary duration-100"
|
||||
key={0}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
) : undefined}
|
||||
</p>
|
||||
<Button variant="ghost" size="icon" onClick={toggleAnnouncementBar}>
|
||||
<i className="bi-x text-xl"></i>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { ReactNode } from "react";
|
||||
@@ -52,7 +51,11 @@ export default function CenteredForm({
|
||||
values={{ date: new Date().getFullYear() }}
|
||||
i18nKey="all_rights_reserved"
|
||||
components={[
|
||||
<Link href="https://linkwarden.app" className="font-semibold" />,
|
||||
<Link
|
||||
href="https://linkwarden.app"
|
||||
className="font-semibold"
|
||||
key="linkwarden-website-key"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
@@ -36,11 +36,10 @@ const CollectionListing = () => {
|
||||
const updateCollection = useUpdateCollection();
|
||||
const { data: collections = [], isLoading } = useCollections();
|
||||
|
||||
const { data: user, refetch } = useUser();
|
||||
const { data: user } = useUser();
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const router = useRouter();
|
||||
const currentPath = router.asPath;
|
||||
|
||||
const [tree, setTree] = useState<TreeData | undefined>();
|
||||
|
||||
@@ -53,7 +52,7 @@ const CollectionListing = () => {
|
||||
user?.collectionOrder
|
||||
);
|
||||
} else return undefined;
|
||||
}, [collections, user, router]);
|
||||
}, [collections, user]);
|
||||
|
||||
useEffect(() => {
|
||||
setTree(initialTree);
|
||||
@@ -281,7 +280,7 @@ const CollectionListing = () => {
|
||||
<Tree
|
||||
tree={tree}
|
||||
renderItem={(itemProps) =>
|
||||
renderItem({ ...itemProps }, currentPath, droppableActive)
|
||||
renderItem({ ...itemProps }, router.asPath, droppableActive)
|
||||
}
|
||||
onExpand={onExpand}
|
||||
onCollapse={onCollapse}
|
||||
|
||||
@@ -27,6 +27,7 @@ import LinkPin from "./LinkViews/LinkComponents/LinkPin";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
export function DashboardLinks({
|
||||
links,
|
||||
@@ -63,10 +64,13 @@ type Props = {
|
||||
};
|
||||
|
||||
export function Card({ link, editMode, dashboardType }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `${link.id}-${dashboardType}`,
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
dashboardType,
|
||||
},
|
||||
});
|
||||
@@ -163,6 +167,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className={`bg-gray-50 h-40 bg-opacity-80`}></div>
|
||||
@@ -206,7 +211,11 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||
{show.collection && !isPublicRoute && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
@@ -220,7 +229,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
|
||||
@@ -15,9 +15,11 @@ import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import toast from "react-hot-toast";
|
||||
import { useUpdateLink } from "@linkwarden/router/links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { restrictToWindowEdges, snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||
import { snapCenterToCursor } from "@dnd-kit/modifiers";
|
||||
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
|
||||
import { useUpdateTag } from "@linkwarden/router/tags";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
interface DragNDropProps {
|
||||
children: React.ReactNode;
|
||||
@@ -28,7 +30,6 @@ interface DragNDropProps {
|
||||
/**
|
||||
* All links available for drag and drop
|
||||
*/
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
|
||||
/**
|
||||
* Override the default sensors used for drag and drop.
|
||||
@@ -47,14 +48,15 @@ interface DragNDropProps {
|
||||
export default function DragNDrop({
|
||||
children,
|
||||
activeLink,
|
||||
links,
|
||||
setActiveLink,
|
||||
sensors: sensorProp,
|
||||
onDragEnd: onDragEndProp,
|
||||
}: DragNDropProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateTag = useUpdateTag();
|
||||
const updateLink = useUpdateLink();
|
||||
const pinLink = usePinLink();
|
||||
const { data: user } = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
@@ -72,10 +74,10 @@ export default function DragNDrop({
|
||||
const sensors = useSensors(mouseSensor, touchSensor);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const draggedLink = links.find(
|
||||
(link: any) => link.id === event.active.data.current?.linkId
|
||||
setActiveLink(
|
||||
(event.active.data.current
|
||||
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
|
||||
);
|
||||
setActiveLink(draggedLink || null);
|
||||
};
|
||||
|
||||
const handleDragOverCancel = () => {
|
||||
@@ -83,70 +85,169 @@ export default function DragNDrop({
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
// If an onDragEnd prop is provided, use it instead of the default behavior
|
||||
if (onDragEndProp) {
|
||||
onDragEndProp(event);
|
||||
return;
|
||||
}
|
||||
const { over } = event;
|
||||
|
||||
const { over, active } = event;
|
||||
if (!over || !activeLink) return;
|
||||
|
||||
let updatedLink: LinkIncludingShortenedCollectionAndTags | null = null;
|
||||
const overData = over.data.current;
|
||||
const targetId = String(over.id);
|
||||
|
||||
// if the link is dropped over a tag
|
||||
if (over.data.current?.type === "tag") {
|
||||
const isTagAlreadyExists = activeLink.tags.some(
|
||||
(tag) => tag.name === over.data.current?.name
|
||||
const isFromRecentSection = active.data.current?.dashboardType === "recent";
|
||||
|
||||
setActiveLink(null);
|
||||
|
||||
const mutateWithToast = async (
|
||||
updatedLink: LinkIncludingShortenedCollectionAndTags,
|
||||
opts?: { invalidateDashboardOnError?: boolean }
|
||||
) => {
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: async (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
if (
|
||||
opts?.invalidateDashboardOnError &&
|
||||
typeof queryClient !== "undefined"
|
||||
) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["dashboardData"],
|
||||
});
|
||||
}
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// DROP ON TAG
|
||||
if (overData?.type === "tag") {
|
||||
const tagName = overData?.name as string | undefined;
|
||||
if (!tagName) return;
|
||||
|
||||
const isTagAlreadyExists = activeLink.tags?.some(
|
||||
(tag) => tag.name === tagName
|
||||
);
|
||||
if (isTagAlreadyExists) {
|
||||
toast.error(t("tag_already_added"));
|
||||
return;
|
||||
}
|
||||
// to match the tags structure required to update the link
|
||||
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
}));
|
||||
const newTags = [...allTags, { name: over.data.current?.name as string }];
|
||||
updatedLink = {
|
||||
|
||||
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
|
||||
(tag) => ({
|
||||
name: tag.name,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
tags: newTags as any,
|
||||
tags: [...allTags, { name: tagName }] as any,
|
||||
};
|
||||
} else {
|
||||
const collectionId = over.data.current?.id as number;
|
||||
const collectionName = over.data.current?.name as string;
|
||||
const ownerId = over.data.current?.ownerId as number;
|
||||
|
||||
// Immediately hide the drag overlay
|
||||
setActiveLink(null);
|
||||
|
||||
// if the link dropped over the same collection, toast
|
||||
if (activeLink.collection.id === collectionId) {
|
||||
toast.error(t("link_already_in_collection"));
|
||||
return;
|
||||
}
|
||||
|
||||
updatedLink = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
await mutateWithToast(updatedLink, {
|
||||
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
// DROP ON DASHBOARD "PINNED" SECTION
|
||||
const isPinnedSection = targetId === "pinned-links-section";
|
||||
|
||||
const canPin =
|
||||
typeof pinLink === "function" &&
|
||||
typeof user !== "undefined" &&
|
||||
typeof user?.id !== "undefined";
|
||||
|
||||
if (isPinnedSection && canPin) {
|
||||
if (Array.isArray(activeLink.pinnedBy) && !activeLink.pinnedBy.length) {
|
||||
if (typeof queryClient !== "undefined") {
|
||||
const optimisticallyPinned = {
|
||||
...activeLink,
|
||||
pinnedBy: [user!.id],
|
||||
};
|
||||
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((l: any) =>
|
||||
l.id === optimisticallyPinned.id ? optimisticallyPinned : l
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
pinLink(activeLink);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DROP ON COLLECTION (dashboard + sidebar)
|
||||
const collectionId = overData?.id as number | undefined;
|
||||
const collectionName = overData?.name as string | undefined;
|
||||
const ownerId = overData?.ownerId as number | undefined;
|
||||
|
||||
if (!collectionId || !collectionName || typeof ownerId === "undefined")
|
||||
return;
|
||||
|
||||
const isSameCollection = activeLink.collection?.id === collectionId;
|
||||
if (isSameCollection) {
|
||||
if (isFromRecentSection) toast.error(t("link_already_in_collection"));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof queryClient !== "undefined") {
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((l: any) =>
|
||||
l.id === updatedLink.id ? updatedLink : l
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.collectionLinks) return oldData;
|
||||
|
||||
const oldCollectionId = activeLink.collection?.id;
|
||||
if (!oldCollectionId) return oldData;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
collectionLinks: {
|
||||
...oldData.collectionLinks,
|
||||
[oldCollectionId]: (
|
||||
oldData.collectionLinks[oldCollectionId] || []
|
||||
).filter((l: any) => l.id !== updatedLink.id),
|
||||
[collectionId]: [
|
||||
...(oldData.collectionLinks[collectionId] || []),
|
||||
updatedLink,
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await mutateWithToast(updatedLink, {
|
||||
invalidateDashboardOnError: typeof queryClient !== "undefined",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragStart={handleDragStart}
|
||||
|
||||
@@ -187,6 +187,7 @@ export default function LinkDetails({
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40"></div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import ViewDropdown from "./ViewDropdown";
|
||||
import { TFunction } from "i18next";
|
||||
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import { useRouter } from "next/router";
|
||||
import useLinkStore from "@/store/links";
|
||||
import {
|
||||
@@ -46,7 +45,8 @@ const LinkListOptions = ({
|
||||
setEditMode,
|
||||
links,
|
||||
}: Props) => {
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, setSelected, clearSelected, selectionCount } =
|
||||
useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
const refreshPreservations = useArchiveAction();
|
||||
@@ -62,45 +62,42 @@ const LinkListOptions = ({
|
||||
if (editMode && setEditMode) return setEditMode(false);
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
if (selectionCount === links.length) {
|
||||
clearSelected();
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
setSelected(links.map((link) => link.id as number));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const bulkRefreshPreservations = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await refreshPreservations.mutateAsync(
|
||||
{
|
||||
linkIds: selectedLinks.map((link) => link.id as number),
|
||||
linkIds: ids,
|
||||
},
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
@@ -108,7 +105,7 @@ const LinkListOptions = ({
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
setEditMode?.(false);
|
||||
toast.success(t("links_being_archived"));
|
||||
}
|
||||
@@ -133,7 +130,7 @@ const LinkListOptions = ({
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
}}
|
||||
className={
|
||||
editMode ? "bg-primary/20 hover:bg-primary/20" : ""
|
||||
@@ -161,15 +158,15 @@ const LinkListOptions = ({
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
checked={selectionCount === links.length && links.length > 0}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
{selectionCount > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_selected")
|
||||
: t("links_selected", { count: selectedLinks.length })}
|
||||
: t("links_selected", {
|
||||
count: selectionCount,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("nothing_selected")}</span>
|
||||
@@ -183,7 +180,7 @@ const LinkListOptions = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBulkRefreshPreservationsModal(true)}
|
||||
disabled={selectedLinks.length === 0}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-arrow-clockwise" />
|
||||
</Button>
|
||||
@@ -201,13 +198,7 @@ const LinkListOptions = ({
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-pencil-square" />
|
||||
</Button>
|
||||
@@ -229,13 +220,7 @@ const LinkListOptions = ({
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
<i className="bi-trash text-error" />
|
||||
</Button>
|
||||
@@ -278,10 +263,10 @@ const LinkListOptions = ({
|
||||
title={t("refresh_preserved_formats")}
|
||||
>
|
||||
<p className="mb-5">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("refresh_preserved_formats_confirmation_desc")
|
||||
: t("refresh_multiple_preserved_formats_confirmation_desc", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink, useGetLink } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkModal from "@/components/ModalContent/LinkModal";
|
||||
@@ -21,25 +17,25 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
linkModal: boolean;
|
||||
className?: string;
|
||||
setLinkModal: (value: boolean) => void;
|
||||
t: TFunction<"translation", undefined>;
|
||||
className?: string;
|
||||
ghost?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkActions({
|
||||
link,
|
||||
linkModal,
|
||||
className,
|
||||
t,
|
||||
setLinkModal,
|
||||
className,
|
||||
ghost,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const permissions = usePermissions(link.collection.id as number);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -15,15 +14,8 @@ import {
|
||||
formatAvailable,
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import { useRouter } from "next/router";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
@@ -31,146 +23,68 @@ import openLink from "@/lib/client/openLink";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
className?: string;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
user: any;
|
||||
disableDraggable: boolean;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkCard({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
user,
|
||||
disableDraggable,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
refetch().catch((error) => {
|
||||
console.error("Error refetching link:", error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const isLinkSelected = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||
isLinkSelected && "border-primary bg-base-300",
|
||||
isSelected && "border-primary bg-base-300",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"relative group touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div ref={ref}>
|
||||
<div ref={ref} className="h-full">
|
||||
<div
|
||||
className="rounded-xl cursor-pointer h-full flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
@@ -197,6 +111,7 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div
|
||||
@@ -240,9 +155,13 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
<Separator className="mb-1" />
|
||||
|
||||
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
|
||||
{show.collection && !isPublicRoute && (
|
||||
{show.collection && !isPublicRoute && collection && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
@@ -256,8 +175,8 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
@@ -266,3 +185,5 @@ export default function LinkCard({ link, columns, editMode }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkCard);
|
||||
|
||||
@@ -5,20 +5,17 @@ import {
|
||||
} from "@linkwarden/types";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkCollection({
|
||||
function LinkCollection({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
return !isPublicRoute && collection?.name ? (
|
||||
<>
|
||||
<Link
|
||||
@@ -47,3 +44,5 @@ export default function LinkCollection({
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default React.memo(LinkCollection);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import React from "react";
|
||||
|
||||
export default function LinkDate({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
function LinkDate({ link }: { link: LinkIncludingShortenedCollectionAndTags }) {
|
||||
const formattedDate = new Date(
|
||||
(link.importDate || link.createdAt) as string
|
||||
).toLocaleString("en-US", {
|
||||
@@ -21,3 +17,5 @@ export default function LinkDate({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkDate);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconWeight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
|
||||
|
||||
export default function LinkIcon({
|
||||
function LinkIcon({
|
||||
link,
|
||||
className,
|
||||
hideBackground,
|
||||
@@ -45,17 +45,17 @@ export default function LinkIcon({
|
||||
) : link.type === "url" && url ? (
|
||||
<>
|
||||
<Image
|
||||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=64`}
|
||||
src={`/api/v1/getFavicon?url=${encodeURIComponent(url.origin)}`}
|
||||
width={64}
|
||||
height={64}
|
||||
alt=""
|
||||
unoptimized
|
||||
className={clsx(
|
||||
iconClasses,
|
||||
faviconLoaded ? "" : "absolute opacity-0"
|
||||
)}
|
||||
draggable="false"
|
||||
onLoadingComplete={() => setFaviconLoaded(true)}
|
||||
onError={() => setFaviconLoaded(false)}
|
||||
onLoad={() => setFaviconLoaded(true)}
|
||||
/>
|
||||
{!faviconLoaded && (
|
||||
<LinkPlaceholderIcon
|
||||
@@ -104,3 +104,5 @@ const LinkPlaceholderIcon = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LinkIcon);
|
||||
|
||||
@@ -2,113 +2,61 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||
import { cn, isPWA } from "@/lib/utils";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import { atLeastOneFormatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
count: number;
|
||||
className?: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkList({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const { data: user } = useUser();
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
const linkIndex = selectedLinks.findIndex(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
if (linkIndex !== -1) {
|
||||
const updatedLinks = [...selectedLinks];
|
||||
updatedLinks.splice(linkIndex, 1);
|
||||
setSelectedLinks(updatedLinks);
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -117,14 +65,16 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"rounded-md border relative group items-center flex",
|
||||
selectedStyle,
|
||||
isSelected
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent",
|
||||
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1",
|
||||
isDragging ? "opacity-30" : "opacity-100",
|
||||
"duration-200, touch-manipulation select-none"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -163,19 +113,23 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||
<div className="flex items-center gap-x-3 text-neutral flex-wrap">
|
||||
{show.link && <LinkTypeBadge link={link} />}
|
||||
{show.collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
{show.collection && collection && (
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isPublic && <LinkPin link={link} />}
|
||||
{!isPublicRoute && <LinkPin link={link} />}
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
@@ -184,3 +138,5 @@ export default function LinkCardCompact({ link, editMode }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkList);
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||
@@ -16,142 +15,58 @@ import {
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
import Link from "next/link";
|
||||
import LinkIcon from "./LinkIcon";
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import LinkTypeBadge from "./LinkTypeBadge";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import clsx from "clsx";
|
||||
import LinkPin from "./LinkPin";
|
||||
import { useRouter } from "next/router";
|
||||
import LinkFormats from "./LinkFormats";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
columns: number;
|
||||
collection: CollectionIncludingMembersAndLinkCount;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
imageHeightClass: string;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// we don't want to use the draggable feature for screen under 1023px since the sidebar is hidden
|
||||
const isSmallScreen = useMediaQuery("(max-width: 1023px)");
|
||||
function LinkMasonry({
|
||||
link,
|
||||
collection,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
imageHeightClass,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: link.id?.toString() ?? "",
|
||||
data: {
|
||||
linkId: link.id,
|
||||
link,
|
||||
},
|
||||
disabled: isSmallScreen,
|
||||
disabled: disableDraggable,
|
||||
});
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
|
||||
[columns]
|
||||
);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const { setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
setSelectedLinks([]);
|
||||
}
|
||||
}, [editMode]);
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCollection(
|
||||
collections.find(
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
isVisible &&
|
||||
!link.preview?.startsWith("archives") &&
|
||||
link.preview !== "unavailable"
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
refetch().catch((error) => {
|
||||
console.error("Error refetching link:", error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [isVisible, link.preview]);
|
||||
|
||||
const isLinkSelected = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
const [linkModal, setLinkModal] = useState(false);
|
||||
|
||||
@@ -160,11 +75,11 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-xl relative group",
|
||||
isLinkSelected && "border-primary bg-base-300"
|
||||
isSelected && "border-primary bg-base-300"
|
||||
)}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
editMode
|
||||
? toggleSelected(link.id as number)
|
||||
: editMode
|
||||
? toast.error(t("link_selection_error"))
|
||||
: undefined
|
||||
@@ -195,6 +110,7 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
) : link.preview === "unavailable" ? null : (
|
||||
<div
|
||||
@@ -258,9 +174,13 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
<Separator className="mb-1" />
|
||||
|
||||
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
|
||||
{!isPublicRoute && show.collection && (
|
||||
{!isPublicRoute && show.collection && collection && (
|
||||
<div className="cursor-pointer truncate">
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
<LinkCollection
|
||||
link={link}
|
||||
collection={collection}
|
||||
isPublicRoute={isPublicRoute}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{show.date && <LinkDate link={link} />}
|
||||
@@ -273,8 +193,8 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-xl duration-100"></div>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
linkModal={linkModal}
|
||||
t={t}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 text-neutral z-20"
|
||||
/>
|
||||
@@ -283,3 +203,5 @@ export default function LinkMasonry({ link, editMode, columns }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkMasonry);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function LinkTypeBadge({
|
||||
function LinkTypeBadge({
|
||||
link,
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
@@ -50,3 +50,5 @@ export default function LinkTypeBadge({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LinkTypeBadge);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
@@ -7,26 +8,43 @@ import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||
import Masonry from "react-masonry-css";
|
||||
import resolveConfig from "tailwindcss/resolveConfig";
|
||||
import tailwindConfig from "../../tailwind.config.js";
|
||||
import { useMemo } from "react";
|
||||
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useRouter } from "next/router";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import useLinkStore from "@/store/links";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
export function CardView({
|
||||
function CardView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
user,
|
||||
disableDraggable,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
user: any;
|
||||
disableDraggable: boolean;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -59,6 +77,23 @@ export function CardView({
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() =>
|
||||
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -82,51 +117,66 @@ export function CardView({
|
||||
|
||||
return (
|
||||
<div className={`${gridColClass} grid gap-5 pb-5`}>
|
||||
{links?.map((e, i) => {
|
||||
{links?.map((e) => {
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkCard
|
||||
key={i}
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
user={user}
|
||||
disableDraggable={disableDraggable}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
imageHeightClass={imageHeightClass}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasonryView({
|
||||
function MasonryView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
const settings = useLocalSettingsStore((state) => state.settings);
|
||||
|
||||
@@ -159,6 +209,23 @@ export function MasonryView({
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
const heightMap = {
|
||||
1: "h-44",
|
||||
2: "h-40",
|
||||
3: "h-36",
|
||||
4: "h-32",
|
||||
5: "h-28",
|
||||
6: "h-24",
|
||||
7: "h-20",
|
||||
8: "h-20",
|
||||
};
|
||||
|
||||
const imageHeightClass = useMemo(
|
||||
() =>
|
||||
columnCount ? heightMap[columnCount as keyof typeof heightMap] : "h-40",
|
||||
[columnCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (settings.columns === 0) {
|
||||
@@ -180,17 +247,7 @@ export function MasonryView({
|
||||
};
|
||||
}, [settings.columns]);
|
||||
|
||||
const fullConfig = resolveConfig(tailwindConfig as any);
|
||||
|
||||
const breakpointColumnsObj = useMemo(() => {
|
||||
return {
|
||||
default: 5,
|
||||
1900: 4,
|
||||
1500: 3,
|
||||
880: 2,
|
||||
550: 1,
|
||||
};
|
||||
}, []);
|
||||
const breakpointColumnsObj = { default: 5, 1900: 4, 1500: 3, 880: 2, 550: 1 };
|
||||
|
||||
return (
|
||||
<Masonry
|
||||
@@ -200,75 +257,100 @@ export function MasonryView({
|
||||
columnClassName="flex flex-col gap-5 !w-full"
|
||||
className={`${gridColClass} grid gap-5 pb-5`}
|
||||
>
|
||||
{links?.map((e, i) => {
|
||||
{links?.map((e) => {
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkMasonry
|
||||
key={i}
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
imageHeightClass={imageHeightClass}
|
||||
editMode={editMode}
|
||||
columns={columnCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-4"
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div className="flex flex-col gap-4" ref={placeHolderRef}>
|
||||
<div className="skeleton h-40 w-full"></div>
|
||||
<div className="skeleton h-3 w-2/3"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-full"></div>
|
||||
<div className="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
)}
|
||||
</Masonry>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListView({
|
||||
function ListView({
|
||||
links,
|
||||
collectionsById,
|
||||
isPublicRoute,
|
||||
t,
|
||||
disableDraggable,
|
||||
user,
|
||||
isSelected,
|
||||
toggleSelected,
|
||||
editMode,
|
||||
isLoading,
|
||||
placeholders,
|
||||
hasNextPage,
|
||||
placeHolderRef,
|
||||
}: {
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
isLoading?: boolean;
|
||||
placeholders?: number[];
|
||||
hasNextPage?: boolean;
|
||||
placeHolderRef?: any;
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
collectionsById: Map<number, CollectionIncludingMembersAndLinkCount>;
|
||||
isPublicRoute: boolean;
|
||||
t: TFunction<"translation", undefined>;
|
||||
disableDraggable: boolean;
|
||||
user: any;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
editMode: boolean;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
placeHolderRef: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{links?.map((e, i) => {
|
||||
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
|
||||
const collection = collectionsById.get(e.collection.id as number);
|
||||
const selected = isSelected(e.id as number);
|
||||
|
||||
return (
|
||||
<LinkList
|
||||
key={e.id}
|
||||
link={e}
|
||||
collection={collection as CollectionIncludingMembersAndLinkCount}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
isSelected={selected}
|
||||
toggleSelected={toggleSelected}
|
||||
count={i}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{(hasNextPage || isLoading) &&
|
||||
placeholders?.map((e, i) => {
|
||||
return (
|
||||
<div
|
||||
ref={e === 1 ? placeHolderRef : undefined}
|
||||
key={i}
|
||||
className="flex gap-2 py-2 px-1"
|
||||
>
|
||||
<div className="skeleton h-12 w-12"></div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="skeleton h-2 w-2/3"></div>
|
||||
<div className="skeleton h-2 w-full"></div>
|
||||
<div className="skeleton h-2 w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(hasNextPage || isLoading) && (
|
||||
<div ref={placeHolderRef} className="flex gap-2 py-2 px-1">
|
||||
<div className="skeleton h-12 w-12"></div>
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="skeleton h-2 w-2/3"></div>
|
||||
<div className="skeleton h-2 w-full"></div>
|
||||
<div className="skeleton h-2 w-1/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,30 +359,88 @@ export default function Links({
|
||||
layout,
|
||||
links,
|
||||
editMode,
|
||||
placeholderCount,
|
||||
useData,
|
||||
}: {
|
||||
layout: ViewMode;
|
||||
links?: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
placeholderCount?: number;
|
||||
useData?: any;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
|
||||
useData.fetchNextPage();
|
||||
if (!inView) return;
|
||||
if (!useData.hasNextPage) return;
|
||||
if (useData.isFetchingNextPage) return;
|
||||
|
||||
useData.fetchNextPage();
|
||||
}, [
|
||||
inView,
|
||||
useData.hasNextPage,
|
||||
useData.isFetchingNextPage,
|
||||
useData.fetchNextPage,
|
||||
]);
|
||||
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const collectionsById = useMemo(() => {
|
||||
const m = new Map<number, (typeof collections)[number]>();
|
||||
for (const c of collections) m.set(c.id as any, c);
|
||||
return m;
|
||||
}, [collections]);
|
||||
|
||||
const { clearSelected, isSelected, toggleSelected } = useLinkStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editMode) {
|
||||
clearSelected();
|
||||
}
|
||||
}, [useData, inView]);
|
||||
}, [editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (
|
||||
links?.some(
|
||||
(e) => !e.preview?.startsWith("archives") && e.preview !== "unavailable"
|
||||
)
|
||||
) {
|
||||
interval = setInterval(async () => {
|
||||
useData.refetch().catch((error: any) => {
|
||||
console.error("Error refetching link:", error);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [links]);
|
||||
|
||||
const disableDraggable = useMediaQuery("(max-width: 1023px)");
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (layout === ViewMode.List) {
|
||||
return (
|
||||
<ListView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
@@ -308,10 +448,16 @@ export default function Links({
|
||||
} else if (layout === ViewMode.Masonry) {
|
||||
return (
|
||||
<MasonryView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
disableDraggable={disableDraggable}
|
||||
user={user}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
@@ -320,16 +466,19 @@ export default function Links({
|
||||
// Default to card view
|
||||
return (
|
||||
<CardView
|
||||
links={links}
|
||||
editMode={editMode}
|
||||
links={links || []}
|
||||
collectionsById={collectionsById}
|
||||
isPublicRoute={isPublicRoute}
|
||||
t={t}
|
||||
user={user}
|
||||
disableDraggable={disableDraggable}
|
||||
toggleSelected={toggleSelected}
|
||||
isSelected={isSelected}
|
||||
editMode={editMode || false}
|
||||
isLoading={useData?.isLoading}
|
||||
placeholders={placeholderCountToArray(placeholderCount)}
|
||||
hasNextPage={useData?.hasNextPage}
|
||||
placeHolderRef={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholderCountToArray = (num?: number) =>
|
||||
num ? Array.from({ length: num }, (_, i) => i + 1) : [];
|
||||
|
||||
@@ -13,47 +13,45 @@ type Props = {
|
||||
|
||||
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
|
||||
const deleteLinksById = useBulkDeleteLinks();
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
const ids = Object.keys(selectedIds).map(Number);
|
||||
|
||||
await deleteLinksById.mutateAsync(
|
||||
selectedLinks.map((link) => link.id as number),
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
await deleteLinksById.mutateAsync(ids, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin text-red-500">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("delete_link")
|
||||
: t("delete_links", { count: selectedLinks.length })}
|
||||
: t("delete_links", { count: selectionCount })}
|
||||
</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("link_deletion_confirmation_message")
|
||||
: t("links_deletion_confirmation_message", {
|
||||
count: selectedLinks.length,
|
||||
count: selectionCount,
|
||||
})}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
|
||||
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const { selectedIds, clearSelected, selectionCount } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||
const [updatedValues, setUpdatedValues] = useState<
|
||||
@@ -40,9 +40,13 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
|
||||
const links = Object.keys(selectedIds).map((k) => ({
|
||||
id: Number(k),
|
||||
}));
|
||||
|
||||
await updateLinks.mutateAsync(
|
||||
{
|
||||
links: selectedLinks,
|
||||
links,
|
||||
newData: updatedValues,
|
||||
removePreviousTags,
|
||||
},
|
||||
@@ -54,7 +58,7 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
setSelectedLinks([]);
|
||||
clearSelected();
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
@@ -67,9 +71,9 @@ export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">
|
||||
{selectedLinks.length === 1
|
||||
{selectionCount === 1
|
||||
? t("edit_link")
|
||||
: t("edit_links", { count: selectedLinks.length })}
|
||||
: t("edit_links", { count: selectionCount })}
|
||||
</p>
|
||||
<Separator className="my-3" />
|
||||
|
||||
|
||||
@@ -66,6 +66,16 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
||||
}
|
||||
}, [currentFormat]);
|
||||
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
setImageLoaded(true);
|
||||
}
|
||||
}, [currentFormat, link?.id, link?.updatedAt]);
|
||||
|
||||
if (!link?.id) return null;
|
||||
|
||||
const renderFormat = () => {
|
||||
@@ -126,6 +136,7 @@ export const PreservationContent: React.FC<Props> = ({ link, format }) => {
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
ref={imgRef}
|
||||
src={`/api/v1/archives/${link.id}?format=${currentFormat}`}
|
||||
className={clsx("w-fit mx-auto", !imageLoaded && "hidden")}
|
||||
onLoad={(e) => {
|
||||
|
||||
@@ -28,11 +28,10 @@ import HighlightDrawer from "../HighlightDrawer";
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
format?: ArchivedFormat;
|
||||
showNavbar: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
const PreservationNavbar = ({ link, format, className }: Props) => {
|
||||
const { data: collections = [] } = useCollections();
|
||||
|
||||
const [collection, setCollection] =
|
||||
@@ -83,8 +82,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-2 z-10 bg-base-100 flex gap-2 justify-between transform transition-transform duration-200 ease-in-out fixed top-0 left-0 right-0",
|
||||
showNavbar ? "translate-y-0" : "-translate-y-full",
|
||||
"p-2 z-10 bg-base-100 flex gap-2 justify-between fixed top-0 left-0 right-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -217,7 +215,7 @@ const PreservationNavbar = ({ link, format, showNavbar, className }: Props) => {
|
||||
<ToggleDarkMode />
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
t={t}
|
||||
linkModal={linkModal}
|
||||
setLinkModal={(e) => setLinkModal(e)}
|
||||
ghost
|
||||
|
||||
@@ -3,15 +3,12 @@ import { useRouter } from "next/router";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import { PreservationContent } from "./PreservationContent";
|
||||
import PreservationNavbar from "./PreservationNavbar";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
|
||||
export default function PreservationPageContent() {
|
||||
const router = useRouter();
|
||||
const { links } = useLinks();
|
||||
|
||||
const [showNavbar, setShowNavbar] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollTop = useRef(0);
|
||||
|
||||
let isPublicRoute = router.pathname.startsWith("/public") ? true : undefined;
|
||||
|
||||
@@ -41,41 +38,13 @@ export default function PreservationPageContent() {
|
||||
};
|
||||
}, [links]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
const st = container.scrollTop;
|
||||
// if scrolling down and beyond a small threshold, hide
|
||||
if (st - 10 > lastScrollTop.current) {
|
||||
if (Number(router.query.format) === ArchivedFormat.readability)
|
||||
setShowNavbar(false);
|
||||
}
|
||||
// if scrolling up, show
|
||||
else if (st < lastScrollTop.current - 10) {
|
||||
setShowNavbar(true);
|
||||
}
|
||||
lastScrollTop.current = st <= 0 ? 0 : st; // for Mobile or negative
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
}, [router.query.format]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{link?.id && (
|
||||
<PreservationNavbar
|
||||
link={link}
|
||||
format={Number(router.query.format)}
|
||||
showNavbar={showNavbar}
|
||||
/>
|
||||
<PreservationNavbar link={link} format={Number(router.query.format)} />
|
||||
)}
|
||||
<div
|
||||
className={`bg-base-200 overflow-y-auto w-screen ${
|
||||
showNavbar ? "h-[calc(100vh-3.1rem)] mt-[3.1rem]" : "h-screen"
|
||||
}`}
|
||||
className={`bg-base-200 overflow-y-auto w-screen h-[calc(100vh-3.1rem)] mt-[3.1rem]`}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<PreservationContent link={link} format={Number(router.query.format)} />
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function ProfilePhoto({
|
||||
draggable={false}
|
||||
onError={() => setImage("")}
|
||||
className="aspect-square rounded-full"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import CollectionListing from "@/components/CollectionListing";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
import { TagListing } from "./TagListing";
|
||||
import TagListing from "./TagListing";
|
||||
import { Button } from "./ui/button";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import Image from "next/image";
|
||||
@@ -90,6 +90,7 @@ export default function Sidebar({
|
||||
alt="Linkwarden Icon"
|
||||
className="h-8 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : user?.theme === "light" ? (
|
||||
<Image
|
||||
@@ -99,6 +100,7 @@ export default function Sidebar({
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
@@ -108,6 +110,7 @@ export default function Sidebar({
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ interface TagListingProps {
|
||||
tags: Tag[];
|
||||
active?: string;
|
||||
}
|
||||
export function TagListing({ tags, active }: TagListingProps) {
|
||||
|
||||
export default function TagListing({ tags, active }: TagListingProps) {
|
||||
const { active: droppableActive } = useDndContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const ctx = useDndContext();
|
||||
console.log("DndContext active?", ctx.active);
|
||||
|
||||
if (!tags[0]) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,10 @@ import Announcement from "@/components/Announcement";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import getLatestVersion from "@/lib/client/getLatestVersion";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -40,29 +44,34 @@ export default function MainLayout({ children }: Props) {
|
||||
const toggleAnnouncementBar = () => setShowAnnouncement(!showAnnouncement);
|
||||
const toggleSidebar = () => setSidebarIsCollapsed(!sidebarIsCollapsed);
|
||||
|
||||
return (
|
||||
<div className="flex" data-testid="dashboard-wrapper">
|
||||
{showAnnouncement && (
|
||||
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||
)}
|
||||
<div className="hidden lg:block">
|
||||
<Sidebar
|
||||
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
|
||||
toggleSidebar={toggleSidebar}
|
||||
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||
/>
|
||||
</div>
|
||||
const [activeLink, setActiveLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
|
||||
|
||||
<div
|
||||
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}
|
||||
return (
|
||||
<DragNDrop activeLink={activeLink} setActiveLink={setActiveLink}>
|
||||
<div className="flex" data-testid="dashboard-wrapper">
|
||||
{showAnnouncement && (
|
||||
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||
)}
|
||||
<div className="hidden lg:block">
|
||||
<Sidebar
|
||||
className={`${sidebarIsCollapsed ? "w-14" : "w-80"}`}
|
||||
toggleSidebar={toggleSidebar}
|
||||
sidebarIsCollapsed={sidebarIsCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DragNDrop>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import updateLinkById from "../linkId/updateLinkById";
|
||||
import { UpdateLinkSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
|
||||
export default async function updateLinks(
|
||||
userId: number,
|
||||
links: UpdateLinkSchemaType[],
|
||||
links: { id: number }[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
@@ -13,19 +14,35 @@ export default async function updateLinks(
|
||||
) {
|
||||
let allUpdatesSuccessful = true;
|
||||
|
||||
// Have to use a loop here rather than updateMany, see the following:
|
||||
// https://github.com/prisma/prisma/issues/3143
|
||||
for (const link of links) {
|
||||
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
|
||||
const ids = links.map((l) => l.id);
|
||||
|
||||
if (removePreviousTags) {
|
||||
// If removePreviousTags is true, replace the existing tags with new tags
|
||||
updatedTags = [...(newData.tags ?? [])];
|
||||
}
|
||||
const dbLinks = await prisma.link.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
description: true,
|
||||
icon: true,
|
||||
iconWeight: true,
|
||||
color: true,
|
||||
collectionId: true,
|
||||
collection: { select: { id: true, ownerId: true } },
|
||||
tags: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Map id -> link for quick lookup
|
||||
const byId = new Map(dbLinks.map((l) => [l.id, l]));
|
||||
|
||||
for (const l of links) {
|
||||
const link = byId.get(l.id);
|
||||
|
||||
if (!link) continue;
|
||||
|
||||
const updatedData: UpdateLinkSchemaType = {
|
||||
...link,
|
||||
tags: updatedTags,
|
||||
tags: [...(newData.tags ?? [])],
|
||||
collection: {
|
||||
...link.collection,
|
||||
id: newData.collectionId ?? link.collection.id,
|
||||
@@ -35,7 +52,8 @@ export default async function updateLinks(
|
||||
const updatedLink = await updateLinkById(
|
||||
userId,
|
||||
link.id as number,
|
||||
updatedData
|
||||
updatedData,
|
||||
removePreviousTags
|
||||
);
|
||||
|
||||
if (updatedLink.status !== 200) {
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
export default async function updateLinkById(
|
||||
userId: number,
|
||||
linkId: number,
|
||||
body: UpdateLinkSchemaType
|
||||
body: UpdateLinkSchemaType,
|
||||
removePreviousTags?: boolean
|
||||
) {
|
||||
const dataValidation = UpdateLinkSchema.safeParse(body);
|
||||
|
||||
@@ -105,6 +106,30 @@ export default async function updateLinkById(
|
||||
},
|
||||
});
|
||||
|
||||
const uniqueTags = (() => {
|
||||
const seen = new Set<string>();
|
||||
return (data.tags ?? []).filter((t) => {
|
||||
const key = t.name;
|
||||
if (!key) return false;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
})();
|
||||
|
||||
const tagConnectOrCreate = uniqueTags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: { connect: { id: data.collection.ownerId } },
|
||||
},
|
||||
}));
|
||||
|
||||
if (
|
||||
data.url &&
|
||||
oldLink &&
|
||||
@@ -134,31 +159,21 @@ export default async function updateLinkById(
|
||||
readable: oldLink?.url !== data.url ? null : undefined,
|
||||
monolith: oldLink?.url !== data.url ? null : undefined,
|
||||
preview: oldLink?.url !== data.url ? null : undefined,
|
||||
lastPreserved: oldLink?.url !== data.url ? null : undefined,
|
||||
indexVersion: null,
|
||||
collection: {
|
||||
connect: {
|
||||
id: data.collection.id,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
set: [],
|
||||
connectOrCreate: data.tags.map((tag) => ({
|
||||
where: {
|
||||
name_ownerId: {
|
||||
name: tag.name,
|
||||
ownerId: data.collection.ownerId,
|
||||
},
|
||||
tags: removePreviousTags
|
||||
? {
|
||||
set: [],
|
||||
connectOrCreate: tagConnectOrCreate,
|
||||
}
|
||||
: {
|
||||
connectOrCreate: tagConnectOrCreate,
|
||||
},
|
||||
create: {
|
||||
name: tag.name,
|
||||
owner: {
|
||||
connect: {
|
||||
id: data.collection.ownerId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
pinnedBy: data?.pinnedBy
|
||||
? data.pinnedBy[0]?.id === userId
|
||||
? { connect: { id: userId } }
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
export default async function getLatestVersion(setShowAnnouncement: Function) {
|
||||
const announcementId = localStorage.getItem("announcementId");
|
||||
const announcementMessage = localStorage.getItem("announcementMessage");
|
||||
|
||||
const response = await fetch(
|
||||
`https://blog.linkwarden.app/latest-announcement.json`
|
||||
`https://linkwarden.app/blog/latest-announcement.json`
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const latestAnnouncement = data.id;
|
||||
const latestMessage = data.message;
|
||||
|
||||
if (announcementId !== latestAnnouncement) {
|
||||
if (
|
||||
announcementId != latestAnnouncement ||
|
||||
announcementMessage != latestMessage
|
||||
) {
|
||||
setShowAnnouncement(true);
|
||||
localStorage.setItem("announcementId", latestAnnouncement);
|
||||
if (latestAnnouncement)
|
||||
localStorage.setItem("announcementId", latestAnnouncement);
|
||||
if (latestMessage)
|
||||
localStorage.setItem("announcementMessage", latestMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,9 @@ const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
staticPageGenerationTimeout: 1000,
|
||||
images: {
|
||||
// For fetching the favicons
|
||||
domains: ["t2.gstatic.com"],
|
||||
|
||||
// For profile pictures (Google OAuth)
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
// For profile pictures (Google OAuth)
|
||||
{ hostname: "*.googleusercontent.com" },
|
||||
],
|
||||
|
||||
minimumCacheTTL: 10,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@linkwarden/web",
|
||||
"version": "v2.13.1",
|
||||
"version": "v2.13.5",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
@@ -39,13 +39,6 @@
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tanstack/react-query": "^5.51.15",
|
||||
"@tanstack/react-query-devtools": "^5.51.15",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/rss": "^0.0.32",
|
||||
"axios": "^1.5.1",
|
||||
"bcrypt": "^5.1.0",
|
||||
"bootstrap-icons": "^1.11.2",
|
||||
@@ -53,8 +46,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"colorjs.io": "^0.5.2",
|
||||
"csstype": "^3.1.2",
|
||||
"dompurify": "^3.0.6",
|
||||
"eslint": "8.46.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"eslint-config-next": "13.4.9",
|
||||
"formidable": "^3.5.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
@@ -67,15 +59,16 @@
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "13.4.12",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.22.1",
|
||||
"next-i18next": "^15.3.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"papaparse": "^5.5.3",
|
||||
"playwright": "^1.55.0",
|
||||
"playwright": "1.57.0",
|
||||
"react": "18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-image-file-resizer": "^0.4.8",
|
||||
@@ -86,25 +79,33 @@
|
||||
"react-window": "^1.8.10",
|
||||
"rss": "^1.2.2",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sharp": "^0.34.5",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"stripe": "^18.4.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod": "^4.1.13",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/dompurify": "^3.0.4",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/node-fetch": "^2.6.10",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/rss": "^0.0.32",
|
||||
"@types/shelljs": "^0.8.15",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"daisyui": "^4.4.2",
|
||||
"eslint": "8.46.0",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "3.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { ReactElement, ReactNode, useEffect } from "react";
|
||||
import "@/styles/globals.css";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
@@ -13,6 +13,7 @@ import { isPWA } from "@/lib/utils";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { NextPage } from "next";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -22,12 +23,19 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
function App({
|
||||
Component,
|
||||
pageProps,
|
||||
}: AppProps<{
|
||||
session: Session;
|
||||
}>) {
|
||||
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
|
||||
getLayout?: (page: ReactElement) => ReactNode;
|
||||
};
|
||||
|
||||
type PageProps = { session?: Session | null };
|
||||
|
||||
type AppPropsWithLayout = AppProps<PageProps> & {
|
||||
Component: NextPageWithLayout<PageProps>;
|
||||
};
|
||||
|
||||
function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPWA()) {
|
||||
const meta = document.createElement("meta");
|
||||
@@ -98,7 +106,7 @@ function App({
|
||||
</ToastBar>
|
||||
)}
|
||||
</Toaster>
|
||||
<Component {...pageProps} />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
{/* </GetData> */}
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -130,7 +130,10 @@ async function handleGet(req: NextApiRequest, res: NextApiResponse) {
|
||||
: `archives/${collection.id}/${linkId + suffix}`;
|
||||
|
||||
const { file, contentType, status } = await readFile(filePath);
|
||||
res.setHeader("Content-Type", contentType).status(status as number);
|
||||
res
|
||||
.setHeader("Content-Type", contentType)
|
||||
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
|
||||
.status(status as number);
|
||||
return res.send(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ if (emailEnabled) {
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
async sendVerificationRequest({ identifier, url, provider, token }: any) {
|
||||
const recentVerificationRequestsCount =
|
||||
await prisma.verificationToken.count({
|
||||
where: {
|
||||
@@ -160,13 +160,13 @@ if (emailEnabled) {
|
||||
token,
|
||||
});
|
||||
},
|
||||
}),
|
||||
} as any),
|
||||
EmailProvider({
|
||||
id: "invite",
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
maxAge: 1200,
|
||||
async sendVerificationRequest({ identifier, url, provider, token }) {
|
||||
async sendVerificationRequest({ identifier, url, provider, token }: any) {
|
||||
const parentSubscriptionEmail = (
|
||||
await prisma.user.findFirst({
|
||||
where: {
|
||||
@@ -210,7 +210,7 @@ if (emailEnabled) {
|
||||
token,
|
||||
});
|
||||
},
|
||||
})
|
||||
} as any)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
return res
|
||||
.setHeader("Content-Type", contentType)
|
||||
.setHeader("Cache-Control", "private, max-age=31536000, immutable")
|
||||
.status(status as number)
|
||||
.send(file);
|
||||
}
|
||||
|
||||
91
apps/web/pages/api/v1/getFavicon/index.ts
Normal file
91
apps/web/pages/api/v1/getFavicon/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
function isImage(ct: string | null) {
|
||||
return !!ct && ct.toLowerCase().startsWith("image/");
|
||||
}
|
||||
|
||||
async function fetchImage(src: string, timeoutMs = 1500) {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const r = await fetch(src, {
|
||||
method: "GET",
|
||||
headers: { Accept: "image/*" },
|
||||
redirect: "follow",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!r.ok || !r.body) return null;
|
||||
|
||||
const ct = r.headers.get("content-type");
|
||||
if (!isImage(ct)) return null;
|
||||
|
||||
return { body: r.body, contentType: ct! };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "GET") return res.status(405).end();
|
||||
|
||||
const raw = req.query.url;
|
||||
const urlStr = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!urlStr) return res.status(400).end();
|
||||
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(decodeURIComponent(urlStr));
|
||||
} catch {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
const origin = u.origin;
|
||||
const hostname = u.hostname;
|
||||
|
||||
const canonical = `/api/v1/getFavicon?url=${encodeURIComponent(origin)}`;
|
||||
if (req.url !== canonical) {
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
return res.redirect(308, canonical);
|
||||
}
|
||||
|
||||
const sources = [
|
||||
`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(
|
||||
origin
|
||||
)}&size=64`,
|
||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
|
||||
];
|
||||
|
||||
for (const src of sources) {
|
||||
const hit = await fetchImage(src);
|
||||
if (!hit) continue;
|
||||
|
||||
res.status(200);
|
||||
res.setHeader("Content-Type", hit.contentType);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"public, max-age=86400, s-maxage=2592000, stale-while-revalidate=604800, immutable"
|
||||
);
|
||||
|
||||
Readable.fromWeb(hit.body as any).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(204);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800"
|
||||
);
|
||||
return res.end();
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||
const updated = await updateLinkById(
|
||||
user.id,
|
||||
Number(req.query.id),
|
||||
req.body
|
||||
req.body,
|
||||
true // since we're passing the existing tags into the request body
|
||||
);
|
||||
return res.status(updated.status).json({
|
||||
response: updated.response,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
@@ -36,9 +36,9 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Index() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -112,300 +112,291 @@ export default function Index() {
|
||||
);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
<div
|
||||
className="p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 0%, ${
|
||||
user?.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${user?.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
<MainLayout>
|
||||
<div
|
||||
className="p-5 flex gap-3 flex-col"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${activeCollection?.color}20 0%, ${
|
||||
user?.theme === "dark" ? "#262626" : "#f3f4f6"
|
||||
} 13rem, ${user?.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
|
||||
}}
|
||||
>
|
||||
{activeCollection && (
|
||||
<div className="flex gap-3 items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCollection.icon ? (
|
||||
<Icon
|
||||
icon={activeCollection.icon}
|
||||
size={45}
|
||||
weight={
|
||||
(activeCollection.iconWeight || "regular") as IconWeight
|
||||
}
|
||||
color={activeCollection.color}
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="bi-folder-fill text-3xl"
|
||||
style={{ color: activeCollection.color }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="sm:text-3xl text-2xl w-full py-1 break-words hyphens-auto font-thin">
|
||||
{activeCollection?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-2 text-neutral"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={t("more")}
|
||||
>
|
||||
<i className="bi-three-dots text-xl" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
for (const link of links) {
|
||||
if (link.url) window.open(link.url, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi-box-arrow-up-right" />
|
||||
{t("open_all_links")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("edit_collection_info")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
<i className="bi-globe" />
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
: t("view_team")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<i className="bi-folder-plus" />
|
||||
{t("create_subcollection")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCollectionModal(true)}
|
||||
className="text-error"
|
||||
>
|
||||
{permissions === true ? (
|
||||
<>
|
||||
<i className="bi-trash" />
|
||||
{t("delete_collection")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi-box-arrow-left" />
|
||||
{t("leave_collection")}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection && (
|
||||
<div className="min-w-[15rem]">
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{activeCollection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
name={e.user.name}
|
||||
className="-ml-3"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{activeCollection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{activeCollection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm ml-2">
|
||||
{activeCollection.members.length > 0
|
||||
? activeCollection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author_and_others", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author", { author: collectionOwner.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection?.description && (
|
||||
<p>{activeCollection.description}</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon="bi-folder"
|
||||
title={t("collections")}
|
||||
description={t(
|
||||
collections.filter((e) => e.parentId === activeCollection?.id)
|
||||
.length === 1
|
||||
? "showing_count_result"
|
||||
: "showing_count_results",
|
||||
{
|
||||
count: collections.filter(
|
||||
(e) => e.parentId === activeCollection?.id
|
||||
).length,
|
||||
}
|
||||
)}
|
||||
className="scale-90 w-fit"
|
||||
/>
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{collections
|
||||
.filter((e) => e.parentId === activeCollection?.id)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? editMode
|
||||
: undefined
|
||||
}
|
||||
setEditMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? setEditMode
|
||||
: undefined
|
||||
}
|
||||
links={links}
|
||||
>
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) ? (
|
||||
<PageHeader
|
||||
icon={"bi-link-45deg"}
|
||||
title={t("links")}
|
||||
description={
|
||||
activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
{activeCollection && (
|
||||
<div className="flex gap-3 items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeCollection.icon ? (
|
||||
<Icon
|
||||
icon={activeCollection.icon}
|
||||
size={45}
|
||||
weight={
|
||||
(activeCollection.iconWeight || "regular") as IconWeight
|
||||
}
|
||||
className="scale-90 w-fit"
|
||||
color={activeCollection.color}
|
||||
/>
|
||||
) : (
|
||||
<p>
|
||||
{activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})}
|
||||
</p>
|
||||
<i
|
||||
className="bi-folder-fill text-3xl"
|
||||
style={{ color: activeCollection.color }}
|
||||
/>
|
||||
)}
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && <NoLinksFound />}
|
||||
<p className="sm:text-3xl text-2xl w-full py-1 break-words hyphens-auto font-thin">
|
||||
{activeCollection?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-2 text-neutral"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={t("more")}
|
||||
>
|
||||
<i className="bi-three-dots text-xl" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
for (const link of links) {
|
||||
if (link.url) window.open(link.url, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i className="bi-box-arrow-up-right" />
|
||||
{t("open_all_links")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem onClick={() => setEditCollectionModal(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("edit_collection_info")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
<i className="bi-globe" />
|
||||
{permissions === true
|
||||
? t("share_and_collaborate")
|
||||
: t("view_team")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{permissions === true && (
|
||||
<DropdownMenuItem onClick={() => setNewCollectionModal(true)}>
|
||||
<i className="bi-folder-plus" />
|
||||
{t("create_subcollection")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteCollectionModal(true)}
|
||||
className="text-error"
|
||||
>
|
||||
{permissions === true ? (
|
||||
<>
|
||||
<i className="bi-trash" />
|
||||
{t("delete_collection")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="bi-box-arrow-left" />
|
||||
{t("leave_collection")}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{activeCollection && (
|
||||
<>
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeCollection && (
|
||||
<div className="min-w-[15rem]">
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
className="flex items-center px-1 py-1 rounded-full cursor-pointer hover:bg-base-content/20 transition-colors duration-200"
|
||||
onClick={() => setEditCollectionSharingModal(true)}
|
||||
>
|
||||
{collectionOwner.id && (
|
||||
<ProfilePhoto
|
||||
src={collectionOwner.image || undefined}
|
||||
name={collectionOwner.name}
|
||||
/>
|
||||
)}
|
||||
{activeCollection.members
|
||||
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||
.map((e, i) => {
|
||||
return (
|
||||
<ProfilePhoto
|
||||
key={i}
|
||||
src={e.user.image ? e.user.image : undefined}
|
||||
name={e.user.name}
|
||||
className="-ml-3"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.slice(0, 3)}
|
||||
{activeCollection.members.length - 3 > 0 && (
|
||||
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||
<span>+{activeCollection.members.length - 3}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-neutral text-sm ml-2">
|
||||
{activeCollection.members.length > 0
|
||||
? activeCollection.members.length === 1
|
||||
? t("by_author_and_other", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author_and_others", {
|
||||
author: collectionOwner.name,
|
||||
count: activeCollection.members.length,
|
||||
})
|
||||
: t("by_author", { author: collectionOwner.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection?.description && <p>{activeCollection.description}</p>}
|
||||
|
||||
<Separator />
|
||||
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon="bi-folder"
|
||||
title={t("collections")}
|
||||
description={t(
|
||||
collections.filter((e) => e.parentId === activeCollection?.id)
|
||||
.length === 1
|
||||
? "showing_count_result"
|
||||
: "showing_count_results",
|
||||
{
|
||||
count: collections.filter(
|
||||
(e) => e.parentId === activeCollection?.id
|
||||
).length,
|
||||
}
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal
|
||||
onClose={() => setNewCollectionModal(false)}
|
||||
parent={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
className="scale-90 w-fit"
|
||||
/>
|
||||
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
{collections
|
||||
.filter((e) => e.parentId === activeCollection?.id)
|
||||
.map((e) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? editMode
|
||||
: undefined
|
||||
}
|
||||
setEditMode={
|
||||
permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete
|
||||
? setEditMode
|
||||
: undefined
|
||||
}
|
||||
links={links}
|
||||
>
|
||||
{collections.some((e) => e.parentId === activeCollection?.id) ? (
|
||||
<PageHeader
|
||||
icon={"bi-link-45deg"}
|
||||
title={t("links")}
|
||||
description={
|
||||
activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
}
|
||||
className="scale-90 w-fit"
|
||||
/>
|
||||
) : (
|
||||
<p>
|
||||
{activeCollection?._count?.links === 1
|
||||
? t("showing_count_result", {
|
||||
count: activeCollection?._count?.links,
|
||||
})
|
||||
: t("showing_count_results", {
|
||||
count: activeCollection?._count?.links,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && <NoLinksFound />}
|
||||
{activeCollection && (
|
||||
<>
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal
|
||||
onClose={() => setNewCollectionModal(false)}
|
||||
parent={activeCollection}
|
||||
/>
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CollectionCard from "@/components/CollectionCard";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ReactElement, useMemo, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import { useSession } from "next-auth/react";
|
||||
import SortDropdown from "@/components/SortDropdown";
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Collections() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: collections = [], isLoading } = useCollections();
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
@@ -53,102 +54,106 @@ export default function Collections() {
|
||||
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("collections")}
|
||||
description={t("collections_you_own")}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<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">
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
|
||||
</div>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("collections")}
|
||||
description={t("collections_you_own")}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setNewCollectionModal(true)}
|
||||
>
|
||||
<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">
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && collections && !collections[0] ? (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
className="flex flex-col gap-2 justify-center h-full w-full mx-auto p-10"
|
||||
{!isLoading && collections && !collections[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_collection")}
|
||||
</p>
|
||||
<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)}
|
||||
>
|
||||
<p className="text-center text-xl">
|
||||
{t("create_your_first_collection")}
|
||||
</p>
|
||||
<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" />
|
||||
<i className="bi-plus-lg text-xl mr-2" />
|
||||
{t("new_collection")}
|
||||
</Button>
|
||||
</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")}
|
||||
</Button>
|
||||
</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] && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("other_collections")}
|
||||
description={t("other_collections_desc")}
|
||||
/>
|
||||
|
||||
<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)
|
||||
.filter((e) => e.ownerId !== data?.user.id)
|
||||
.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] && (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={"bi-folder"}
|
||||
title={t("other_collections")}
|
||||
description={t("other_collections_desc")}
|
||||
/>
|
||||
|
||||
<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) => (
|
||||
<CollectionCard key={e.id} collection={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
)}
|
||||
</MainLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ReactElement, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -34,8 +34,9 @@ import { useUpdateLink } from "@linkwarden/router/links";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
|
||||
export default function Dashboard() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: collections = [] } = useCollections();
|
||||
const {
|
||||
@@ -45,19 +46,8 @@ export default function Dashboard() {
|
||||
...dashboardData
|
||||
} = useDashboardData();
|
||||
|
||||
/**
|
||||
* Get a combined list of all links, including those from collections.
|
||||
* Dupplications are fine since this is used for finding dragged link
|
||||
*/
|
||||
const allLinks = useMemo(() => {
|
||||
const _collectionLinks = Object.values(collectionLinks).flat();
|
||||
return [...links, ..._collectionLinks];
|
||||
}, [collectionLinks, links]);
|
||||
|
||||
const { data: tags = [] } = useTags();
|
||||
const { data: user } = useUser();
|
||||
const pinLink = usePinLink();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [numberOfLinks, setNumberOfLinks] = useState(0);
|
||||
const [activeLink, setActiveLink] =
|
||||
@@ -148,214 +138,79 @@ export default function Dashboard() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { over, active } = event;
|
||||
if (!over || !activeLink) return;
|
||||
|
||||
const targetSectionId = over.id as string;
|
||||
const collectionId = over.data.current?.id as number;
|
||||
const collectionName = over.data.current?.name as string;
|
||||
const ownerId = over.data.current?.ownerId as number;
|
||||
|
||||
const isFromRecentSection = active.data.current?.dashboardType === "recent";
|
||||
|
||||
// Immediately hide the drag overlay
|
||||
setActiveLink(null);
|
||||
if (over.data.current?.type === "tag") {
|
||||
const isTagAlreadyExists = activeLink.tags.some(
|
||||
(tag) => tag.name === over.data.current?.name
|
||||
);
|
||||
if (isTagAlreadyExists) {
|
||||
toast.error(t("tag_already_added"));
|
||||
return;
|
||||
}
|
||||
// to match the tags structure required to update the link
|
||||
const allTags: { name: string }[] = activeLink.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
}));
|
||||
const newTags = [...allTags, { name: over.data.current?.name as string }];
|
||||
const updatedLink = {
|
||||
...activeLink,
|
||||
tags: newTags as any,
|
||||
};
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
// If there's an error, invalidate queries to restore the original state
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle pinning the link
|
||||
if (targetSectionId === "pinned-links-section") {
|
||||
if (Array.isArray(activeLink.pinnedBy) && !activeLink.pinnedBy.length) {
|
||||
// optimistically update the link's pinned state
|
||||
const updatedLink = {
|
||||
...activeLink,
|
||||
pinnedBy: [user?.id],
|
||||
};
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((link: any) =>
|
||||
link.id === updatedLink.id ? updatedLink : link
|
||||
),
|
||||
};
|
||||
});
|
||||
pinLink(activeLink);
|
||||
}
|
||||
// Handle moving the link to a different collection
|
||||
} else if (activeLink.collection.id !== collectionId) {
|
||||
// Optimistically update the link's collection immediately
|
||||
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
|
||||
...activeLink,
|
||||
collection: {
|
||||
id: collectionId,
|
||||
name: collectionName,
|
||||
ownerId,
|
||||
},
|
||||
};
|
||||
|
||||
// Optimistically update the dashboard data cache
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.links) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
links: oldData.links.map((link: any) =>
|
||||
link.id === updatedLink.id ? updatedLink : link
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Optimistically update the collection links cache
|
||||
if (collectionId) {
|
||||
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
|
||||
if (!oldData?.collectionLinks) return oldData;
|
||||
|
||||
const oldCollectionId = activeLink.collection.id!;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
collectionLinks: {
|
||||
...oldData.collectionLinks,
|
||||
// Remove from old collection
|
||||
[oldCollectionId]: (
|
||||
oldData.collectionLinks[oldCollectionId] || []
|
||||
).filter((link: any) => link.id !== updatedLink.id),
|
||||
// Add to new collection
|
||||
[collectionId]: [
|
||||
...(oldData.collectionLinks[collectionId] || []),
|
||||
updatedLink,
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
await updateLink.mutateAsync(updatedLink, {
|
||||
onSettled: (_, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
// If there's an error, invalidate queries to restore the original state
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (isFromRecentSection) {
|
||||
// show error if link is dragged from recent section to the target collection which it already belongs to
|
||||
toast.error(t("link_already_in_collection"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
onDragEnd={handleDragEnd}
|
||||
links={allLinks}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<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" />
|
||||
<p className="font-thin">{t("dashboard")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardLayoutDropdown />
|
||||
<ViewDropdown
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
dashboard
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<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" />
|
||||
<p className="font-thin">{t("dashboard")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardLayoutDropdown />
|
||||
<ViewDropdown
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
dashboard
|
||||
/>
|
||||
</div>
|
||||
{orderedSections[0] ? (
|
||||
orderedSections?.map((section, i) => (
|
||||
<Section
|
||||
key={i}
|
||||
sectionData={section}
|
||||
t={t}
|
||||
collection={collections.find(
|
||||
(c) => c.id === section.collectionId
|
||||
)}
|
||||
collectionLinks={
|
||||
section.collectionId
|
||||
? collectionLinks[section.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tags={tags}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
setNewLinkModal={setNewLinkModal}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-4 xl:flex-row xl:justify-evenly xl:w-full">
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
</div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{orderedSections[0] ? (
|
||||
orderedSections?.map((section, i) => (
|
||||
<Section
|
||||
key={i}
|
||||
sectionData={section}
|
||||
t={t}
|
||||
collection={collections.find(
|
||||
(c) => c.id === section.collectionId
|
||||
)}
|
||||
collectionLinks={
|
||||
section.collectionId
|
||||
? collectionLinks[section.collectionId]
|
||||
: []
|
||||
}
|
||||
links={links}
|
||||
tags={tags}
|
||||
numberOfLinks={numberOfLinks}
|
||||
collectionsLength={collections.length}
|
||||
numberOfPinnedLinks={numberOfPinnedLinks}
|
||||
dashboardData={dashboardData}
|
||||
setNewLinkModal={setNewLinkModal}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="xl:flex flex flex-col sm:grid grid-cols-2 gap-4 xl:flex-row xl:justify-evenly xl:w-full">
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
<div className="skeleton h-20 w-full"></div>
|
||||
</div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
<div className="skeleton h-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSurveyModal && (
|
||||
<SurveyModal
|
||||
submit={submitSurvey}
|
||||
onClose={() => {
|
||||
setShowsSurveyModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
)}
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
{showSurveyModal && (
|
||||
<SurveyModal
|
||||
submit={submitSurvey}
|
||||
onClose={() => {
|
||||
setShowsSurveyModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import NoLinksFound from "@/components/NoLinksFound";
|
||||
import { useLinks, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import { Sort, ViewMode } from "@linkwarden/types";
|
||||
import { useRouter } from "next/router";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import clsx from "clsx";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Index() {
|
||||
const [activeLink, setActiveLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
@@ -40,50 +34,43 @@ export default function Index() {
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className={clsx("flex items-center gap-3")}>
|
||||
<i
|
||||
className={`bi-link-45deg text-primary text-3xl drop-shadow`}
|
||||
></i>
|
||||
<div>
|
||||
<p className="text-2xl capitalize font-thin">
|
||||
{t("all_links")}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm">{t("all_links_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<NoLinksFound text={t("you_have_not_added_any_links")} />
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className={clsx("flex items-center gap-3")}>
|
||||
<i className={`bi-link-45deg text-primary text-3xl drop-shadow`}></i>
|
||||
<div>
|
||||
<p className="text-2xl capitalize font-thin">{t("all_links")}</p>
|
||||
<p className="text-xs sm:text-sm">{t("all_links_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
<NoLinksFound text={t("you_have_not_added_any_links")} />
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import { Sort, ViewMode } from "@linkwarden/types";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import { useLinks } from "@linkwarden/router/links";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function PinnedLinks() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
@@ -28,67 +24,60 @@ export default function PinnedLinks() {
|
||||
pinnedOnly: true,
|
||||
});
|
||||
|
||||
const [activeLink, setActiveLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={t("pinned")}
|
||||
description={t("pinned_links_desc")}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader
|
||||
icon={"bi-pin-angle"}
|
||||
title={t("pinned")}
|
||||
description={t("pinned_links_desc")}
|
||||
/>
|
||||
</LinkListOptions>
|
||||
|
||||
{!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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5 text-primary drop-shadow"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354m1.58 1.408-.002-.001zm-.002-.001.002.001A.5.5 0 0 1 6 2v5a.5.5 0 0 1-.276.447h-.002l-.012.007-.054.03a5 5 0 0 0-.827.58c-.318.278-.585.596-.725.936h7.792c-.14-.34-.407-.658-.725-.936a5 5 0 0 0-.881-.61l-.012-.006h-.002A.5.5 0 0 1 10 7V2a.5.5 0 0 1 .295-.458 1.8 1.8 0 0 0 .351-.271c.08-.08.155-.17.214-.271H5.14q.091.15.214.271a1.8 1.8 0 0 0 .37.282" />
|
||||
</svg>
|
||||
<p className="text-center text-xl">
|
||||
{t("pin_favorite_links_here")}
|
||||
</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("pin_favorite_links_here_desc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5 text-primary drop-shadow"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M4.146.146A.5.5 0 0 1 4.5 0h7a.5.5 0 0 1 .5.5c0 .68-.342 1.174-.646 1.479-.126.125-.25.224-.354.298v4.431l.078.048c.203.127.476.314.751.555C12.36 7.775 13 8.527 13 9.5a.5.5 0 0 1-.5.5h-4v4.5c0 .276-.224 1.5-.5 1.5s-.5-1.224-.5-1.5V10h-4a.5.5 0 0 1-.5-.5c0-.973.64-1.725 1.17-2.189A6 6 0 0 1 5 6.708V2.277a3 3 0 0 1-.354-.298C4.342 1.674 4 1.179 4 .5a.5.5 0 0 1 .146-.354m1.58 1.408-.002-.001zm-.002-.001.002.001A.5.5 0 0 1 6 2v5a.5.5 0 0 1-.276.447h-.002l-.012.007-.054.03a5 5 0 0 0-.827.58c-.318.278-.585.596-.725.936h7.792c-.14-.34-.407-.658-.725-.936a5 5 0 0 0-.881-.61l-.012-.006h-.002A.5.5 0 0 1 10 7V2a.5.5 0 0 1 .295-.458 1.8 1.8 0 0 0 .351-.271c.08-.08.155-.17.214-.271H5.14q.091.15.214.271a1.8 1.8 0 0 0 .37.282" />
|
||||
</svg>
|
||||
<p className="text-center text-xl">{t("pin_favorite_links_here")}</p>
|
||||
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm">
|
||||
{t("pin_favorite_links_here_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
)}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import React, { useState, FormEvent } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
@@ -306,7 +306,6 @@ export default function PublicCollections() {
|
||||
}) as any
|
||||
}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
{!data.isLoading && links && !links[0] && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, FormEvent } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLogins } from "./api/v1/logins";
|
||||
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { ReactElement, useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import LinkListOptions from "@/components/LinkListOptions";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Links from "@/components/LinkViews/Links";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
|
||||
export default function Search() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -41,38 +41,35 @@ export default function Search() {
|
||||
});
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader icon={"bi-search"} title={t("search_results")} />
|
||||
</LinkListOptions>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<PageHeader icon={"bi-search"} title={t("search_results")} />
|
||||
</LinkListOptions>
|
||||
|
||||
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
placeholderCount={1}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
{!data.isLoading && links && !links[0] && <p>{t("nothing_found")}</p>}
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
useData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import NewTokenModal from "@/components/ModalContent/NewTokenModal";
|
||||
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
|
||||
import { AccessToken } from "@linkwarden/prisma/client";
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function AccessTokens() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const [newTokenModal, setNewTokenModal] = useState(false);
|
||||
const [revokeTokenModal, setRevokeTokenModal] = useState(false);
|
||||
const [selectedToken, setSelectedToken] = useState<AccessToken | null>(null);
|
||||
@@ -29,7 +30,7 @@ export default function AccessTokens() {
|
||||
const { data: tokens = [] } = useTokens();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("access_tokens")}
|
||||
</p>
|
||||
@@ -124,8 +125,14 @@ export default function AccessTokens() {
|
||||
activeToken={selectedToken}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, ChangeEvent } from "react";
|
||||
import { useState, useEffect, ChangeEvent, ReactElement } from "react";
|
||||
import { AccountSettings } from "@linkwarden/types";
|
||||
import { toast } from "react-hot-toast";
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
@@ -25,8 +25,9 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Account() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const [emailChangeVerificationModal, setEmailChangeVerificationModal] =
|
||||
useState(false);
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
@@ -145,7 +146,7 @@ export default function Account() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("accountSettings")}
|
||||
</p>
|
||||
@@ -358,8 +359,14 @@ export default function Account() {
|
||||
newEmail={user.email || ""}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -2,7 +2,7 @@ import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useRouter } from "next/router";
|
||||
import InviteModal from "@/components/ModalContent/InviteModal";
|
||||
import { User as U } from "@linkwarden/prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUsers } from "@linkwarden/router/users";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
interface User extends U {
|
||||
subscriptions: {
|
||||
@@ -34,7 +35,7 @@ type UserModal = {
|
||||
|
||||
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
|
||||
|
||||
export default function Billing() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -74,7 +75,7 @@ export default function Billing() {
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("billing_settings")}
|
||||
</p>
|
||||
@@ -289,8 +290,14 @@ export default function Billing() {
|
||||
userId={deleteUserModal.userId}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState } from "react";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { useTranslation } from "next-i18next";
|
||||
@@ -7,8 +7,9 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useUpdateUser, useUser } from "@linkwarden/router/user";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Password() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
@@ -52,7 +53,7 @@ export default function Password() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("change_password")}
|
||||
</p>
|
||||
@@ -90,8 +91,14 @@ export default function Password() {
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, ReactElement } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import Checkbox from "@/components/Checkbox";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Preference() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useLocalSettingsStore();
|
||||
const updateUserPreference = useUpdateUserPreference();
|
||||
@@ -187,7 +188,7 @@ export default function Preference() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">{t("preference")}</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
@@ -613,8 +614,14 @@ export default function Preference() {
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -3,14 +3,15 @@ import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useRssSubscriptions } from "@linkwarden/router/rss";
|
||||
import DeleteRssSubscriptionModal from "@/components/ModalContent/DeleteRssSubscriptionModal";
|
||||
import { useState } from "react";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { RssSubscription } from "@linkwarden/prisma/client";
|
||||
import NewRssSubscriptionModal from "@/components/ModalContent/NewRssSubscriptionModal";
|
||||
import { useConfig } from "@linkwarden/router/config";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function RssSubscriptions() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: rssSubscriptions = [] } = useRssSubscriptions();
|
||||
|
||||
@@ -27,7 +28,7 @@ export default function RssSubscriptions() {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">
|
||||
{t("rss_subscriptions")}
|
||||
</p>
|
||||
@@ -96,8 +97,14 @@ export default function RssSubscriptions() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ReactElement, useEffect, useState } from "react";
|
||||
import ConfirmationModal from "@/components/ConfirmationModal";
|
||||
import { LinkArchiveActionSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useArchiveAction } from "@linkwarden/router/links";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Worker() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const archiveAction = useArchiveAction();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -52,7 +53,7 @@ export default function Worker() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<>
|
||||
<p className="capitalize text-3xl font-thin inline">{t("worker")}</p>
|
||||
|
||||
<Separator className="my-3" />
|
||||
@@ -114,8 +115,14 @@ export default function Worker() {
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <SettingsLayout>{page}</SettingsLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { signOut, useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useRouter } from "next/router";
|
||||
import CenteredForm from "@/layouts/CenteredForm";
|
||||
import CenteredForm from "@/components/CenteredForm";
|
||||
import { Plan } from "@linkwarden/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import getServerSideProps from "@/lib/client/getServerSideProps";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, ReactElement, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
export default function Index() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -145,123 +145,109 @@ export default function Index() {
|
||||
);
|
||||
|
||||
return (
|
||||
<DragNDrop
|
||||
links={links}
|
||||
activeLink={activeLink}
|
||||
setActiveLink={setActiveLink}
|
||||
>
|
||||
<MainLayout>
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-2 items-center font-thin">
|
||||
<i className="bi-hash text-primary text-3xl" />
|
||||
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||
<LinkListOptions
|
||||
t={t}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode}
|
||||
links={links}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex gap-2 items-center font-thin">
|
||||
<i className="bi-hash text-primary text-3xl" />
|
||||
|
||||
{renameTag ? (
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="sm:text-3xl text-xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={submit}>
|
||||
<i className="bi-check2 text-neutral text-xl" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={cancelUpdateTag}
|
||||
{renameTag ? (
|
||||
<form onSubmit={submit} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="sm:text-3xl text-xl bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={submit}>
|
||||
<i className="bi-check2 text-neutral text-xl" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={cancelUpdateTag}>
|
||||
<i className="bi-x text-neutral text-xl" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="sm:text-3xl text-xl">{activeTag?.name}</p>
|
||||
<div className="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("more")}>
|
||||
<i className="bi-three-dots text-xl text-neutral" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align={
|
||||
activeTag?.name.length && activeTag?.name.length > 8
|
||||
? "end"
|
||||
: "start"
|
||||
}
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<i className="bi-x text-neutral text-xl" />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="sm:text-3xl text-xl">{activeTag?.name}</p>
|
||||
<div className="relative">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("more")}>
|
||||
<i className="bi-three-dots text-xl text-neutral" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuItem onClick={() => setRenameTag(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("rename_tag")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuContent
|
||||
sideOffset={4}
|
||||
align={
|
||||
activeTag?.name.length && activeTag?.name.length > 8
|
||||
? "end"
|
||||
: "start"
|
||||
}
|
||||
className="bg-base-200 border border-neutral-content rounded-box p-1"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setRenameTag(true)}>
|
||||
<i className="bi-pencil-square" />
|
||||
{t("rename_tag")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={remove}
|
||||
className="text-error"
|
||||
>
|
||||
<i className="bi-trash" />
|
||||
{t("delete_tag")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
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>
|
||||
)}
|
||||
<DropdownMenuItem onClick={remove} className="text-error">
|
||||
<i className="bi-trash" />
|
||||
{t("delete_tag")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => setBulkDeleteLinksModal(false)}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal onClose={() => setBulkEditLinksModal(false)} />
|
||||
)}
|
||||
</MainLayout>
|
||||
</DragNDrop>
|
||||
</LinkListOptions>
|
||||
|
||||
<Links
|
||||
editMode={editMode}
|
||||
links={links}
|
||||
layout={viewMode}
|
||||
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>
|
||||
)}
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal onClose={() => setBulkDeleteLinksModal(false)} />
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal onClose={() => setBulkEditLinksModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ReactElement, useMemo, useState } from "react";
|
||||
import NewTagModal from "@/components/ModalContent/NewTagModal";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import BulkDeleteTagsModal from "@/components/ModalContent/BulkDeleteTagsModal";
|
||||
import MergeTagsModal from "@/components/ModalContent/MergeTagsModal";
|
||||
import { NextPageWithLayout } from "../_app";
|
||||
|
||||
enum TagSort {
|
||||
DateNewestFirst = 0,
|
||||
@@ -32,7 +33,7 @@ enum TagSort {
|
||||
LinkCountLowHigh = 5,
|
||||
}
|
||||
|
||||
export default function Tags() {
|
||||
const Page: NextPageWithLayout = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: tags = [], isLoading } = useTags();
|
||||
|
||||
@@ -72,194 +73,192 @@ export default function Tags() {
|
||||
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")} />
|
||||
<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"
|
||||
onClick={() => setNewTagModal(true)}
|
||||
disabled={selectedTags.length < 2}
|
||||
>
|
||||
<i className="bi-plus-lg text-xl text-neutral"></i>
|
||||
<i className="bi-intersect" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("new_tag")}</p>
|
||||
<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 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 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>
|
||||
)}
|
||||
|
||||
{newTagModal && <NewTagModal onClose={() => setNewTagModal(false)} />}
|
||||
{bulkDeleteModal && (
|
||||
<BulkDeleteTagsModal
|
||||
@@ -281,8 +280,14 @@ export default function Tags() {
|
||||
setSelectedTags={setSelectedTags}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return <MainLayout>{page}</MainLayout>;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -393,75 +393,75 @@
|
||||
"saved": "Salvato",
|
||||
"untitled": "Senza titolo",
|
||||
"no_tags": "Nessun tag.",
|
||||
"no_description_provided": "No description provided.",
|
||||
"change_icon": "Change Icon",
|
||||
"upload_banner": "Upload Banner",
|
||||
"columns": "Columns",
|
||||
"no_description_provided": "Nessuna descrizione fornita.",
|
||||
"change_icon": "Cambia Icona",
|
||||
"upload_banner": "Carica Grafica",
|
||||
"columns": "Colonne",
|
||||
"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.",
|
||||
"team_management": "Team Management",
|
||||
"invite_user": "Invite User",
|
||||
"invite_users": "Invite Users",
|
||||
"invite_user_desc": "To invite someone to your team, please enter their email address below:",
|
||||
"invalid_url_guide": "Inserisci un Indirizzo valido per il Collegamento. (Dovrebbe iniziare con http/https)",
|
||||
"email_invalid": "Inserisci un indirizzo email valido.",
|
||||
"username_invalid_guide": "Il nome utente deve contenere almeno 3 caratteri, non sono ammessi spazi e caratteri speciali.",
|
||||
"team_management": "Gestione Team",
|
||||
"invite_user": "Invita Utente",
|
||||
"invite_users": "Invita Utenti",
|
||||
"invite_user_desc": "Per invitare qualcuno nel tuo team, inserisci il suo indirizzo email qui sotto:",
|
||||
"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",
|
||||
"learn_more": "Learn more",
|
||||
"invite_user_price": "Il costo di ogni postazione è di ${{price}} al mese o ${{priceAnnual}} all'anno, a seconda del tuo attuale piano di abbonamento.",
|
||||
"send_invitation": "Invia Invito",
|
||||
"learn_more": "Ulteriori informazioni",
|
||||
"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",
|
||||
"invitation_accepted": "Invito Accettato!",
|
||||
"status": "Stato",
|
||||
"pending": "In Attesa",
|
||||
"active": "Attivo",
|
||||
"manage_seats": "Gestisci Postazioni",
|
||||
"seats_purchased": "{{count}} posti acquistati",
|
||||
"seat_purchased": "{{count}} seat purchased",
|
||||
"date_added": "Date Added",
|
||||
"resend_invite": "Resend Invitation",
|
||||
"resend_invite_success": "Invitation Resent!",
|
||||
"remove_user": "Remove User",
|
||||
"continue_to_dashboard": "Continue to Dashboard",
|
||||
"confirm_user_removal_desc": "They will need to have a subscription to access Linkwarden again.",
|
||||
"click_out_to_apply": "Click outside to apply",
|
||||
"submit": "Submit",
|
||||
"thanks_for_feedback": "Thanks for your feedback!",
|
||||
"quick_survey": "Quick Survey",
|
||||
"seat_purchased": "{{count}} postazioni acquistate",
|
||||
"date_added": "Data Aggiunta",
|
||||
"resend_invite": " Reinvia Invito",
|
||||
"resend_invite_success": "Invito Reinviato!",
|
||||
"remove_user": "Rimuovi Utente",
|
||||
"continue_to_dashboard": "Continua alla Dashboard",
|
||||
"confirm_user_removal_desc": "Dovranno sottoscrivere un abbonamento per accedere nuovamente a Linkwarden.",
|
||||
"click_out_to_apply": "Fai clic all'esterno per applicare",
|
||||
"submit": "Invia",
|
||||
"thanks_for_feedback": "Grazie per il tuo feedback!",
|
||||
"quick_survey": "Sondaggio Rapido",
|
||||
"how_did_you_discover_linkwarden": "Come hai scoperto Linkwarden?",
|
||||
"rather_not_say": "Rather not say",
|
||||
"search_engine": "Search Engine (Google, Bing, etc.)",
|
||||
"rather_not_say": "Preferisco non specificarlo",
|
||||
"search_engine": "Motore di Ricerca (Google, Bing, ecc.)",
|
||||
"reddit": "Reddit",
|
||||
"lemmy": "Lemmy",
|
||||
"people_recommendation": "Recommendation (Friend, Family, etc.)",
|
||||
"open_all_links": "Open all Links",
|
||||
"people_recommendation": "Raccomandazione (Amico, Famiglia, etc.)",
|
||||
"open_all_links": "Apri tutti i Link",
|
||||
"ai_settings": "Impostazioni IA",
|
||||
"generate_tags_for_existing_links": "Generate tags for existing Links",
|
||||
"generate_tags_for_existing_links": "Genera tag per i Link esistenti",
|
||||
"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_predefined_tags_desc": "Categorizza automaticamente i link in tag predefiniti in base al contenuto di ciascun 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.",
|
||||
"based_on_existing_tags_desc": "Categorizza automaticamente i link in tag esistenti in base al contenuto di ciascun link.",
|
||||
"auto_generate_tags": "Auto-generate Tags",
|
||||
"auto_generate_tags_desc": "Auto-generate relevant tags based on the content of each link.",
|
||||
"auto_generate_tags_desc": "Genera automaticamente tag pertinenti in base al contenuto di ogni link.",
|
||||
"disabled": "Disabilitato",
|
||||
"ai_tagging_disabled_desc": "AI tagging is disabled.",
|
||||
"tag_selection_placeholder": "Choose or add custom tags…",
|
||||
"rss_subscriptions": "RSS Subscriptions",
|
||||
"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_subscriptions_desc": "Le iscrizioni ai feed RSS sono un modo per rimanere aggiornati sui tuoi siti web e blog preferiti. Linkwarden recupererà automaticamente gli ultimi articoli ogni {{number}} minuti dai feed da te forniti.",
|
||||
"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": "Feed RSS",
|
||||
"pinned_links": "Pinned Links",
|
||||
"recent_links": "Recent Links",
|
||||
"pinned_links": "Link Fissati",
|
||||
"recent_links": "Link Recenti",
|
||||
"search_results": "Search Results",
|
||||
"linkwarden_icon": "Icona Linkwarden",
|
||||
"permanent_session": "This is a permanent session",
|
||||
"locale": "en-US",
|
||||
"not_found_404": "404 - Not Found",
|
||||
"collection_publicly_shared": "Questa collezione è condivisa pubblicamente.",
|
||||
"search_for_links": "Search for Links",
|
||||
"search_for_links": "Cerca per Link",
|
||||
"search_query_invalid_symbol": "The search query should not contain '%'.",
|
||||
"open_modal_new_tab": "Open this modal in a new tab",
|
||||
"file": "File",
|
||||
|
||||
@@ -494,36 +494,36 @@
|
||||
"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.",
|
||||
"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",
|
||||
"trial_left_plural": "Trial ends in {{count}} days. Subscribe.",
|
||||
"trial_left_singular": "Trial ends in 1 day. Subscribe."
|
||||
"all_tags": "すべてのタグ",
|
||||
"new_tag": "新しいタグ",
|
||||
"create_new_tag": "新しいタグを作成",
|
||||
"tag_deletion_confirmation_message": "このタグを削除しますか?",
|
||||
"delete_tags_by_number_of_links": "<0/>リンクを含むタグを削除",
|
||||
"delete_all_tags": "すべてのタグを削除",
|
||||
"bulk_delete_tags": "タグを一括削除",
|
||||
"count_tags_deleted": "{{count}} 個のタグが削除されました",
|
||||
"count_tag_deleted": "{{count}} 個のタグが削除されました",
|
||||
"tag_name_placeholder": "例: テクノロジー",
|
||||
"link_count_high_low": "リンク数(高から低)",
|
||||
"link_count_low_high": "リンク数(低から高)",
|
||||
"tags_selected": "{{count}} 個のタグが選択されました",
|
||||
"tag_selected": "1 個のタグが選択されました",
|
||||
"merge_tags": "タグをマージする",
|
||||
"merge_count_tags": "{{count}} 件のタグをマージ",
|
||||
"rename_tag_instruction": "最終的なタグの名前を入力してください。",
|
||||
"merging": "マージ中…",
|
||||
"delete_tags": "{{count}} 件のタグを削除",
|
||||
"tags_deletion_confirmation_message": "{{count}} 件のタグを削除してもよろしいですか?この操作によりすべてのリンクからタグが削除されます。",
|
||||
"subscribe_later": "後で申し込みますか?",
|
||||
"create_your_first_tag": "最初のタグを作成しよう!",
|
||||
"create_your_first_tag_desc": "タグはリンクの分類や検索を容易にします。トピックやプロジェクトなど、あなたにとって使いやすい体系でタグを作成できます。",
|
||||
"create_your_first_collection": "最初のコレクションを作しよう!",
|
||||
"create_your_first_collection_desc": "コレクションはリンクをまとめるフォルダのような機能で、他のユーザーと共有できます。",
|
||||
"this_tag_has_no_links": "このタグにはリンクがありません",
|
||||
"this_tag_has_no_links_desc": "リンクの作成時や編集時にこのタグを使用してください!",
|
||||
"accept_promotional_emails": "新機能やお得な情報をメールでお知らせします。",
|
||||
"expand_sidebar": "サイドバーを展開",
|
||||
"shrink_sidebar": "サイドバーを縮小",
|
||||
"trial_left_plural": "お試し期間は {{count}} 日で終了します。登録してください。",
|
||||
"trial_left_singular": "試用期間はあと 1 日で終了します。登録してください。"
|
||||
}
|
||||
|
||||
@@ -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 檔案)",
|
||||
"from_omnivore": "從 Omnivore(ZIP 檔案)",
|
||||
"from_pocket": "From Pocket (CSV file)",
|
||||
"from_pocket": "從Pocket匯入 (CSV 檔案)",
|
||||
"pinned": "已釘選",
|
||||
"pinned_links_desc": "您釘選的連結",
|
||||
"pin_favorite_links_here": "在此釘選您喜愛的連結!",
|
||||
@@ -343,7 +343,7 @@
|
||||
"link_deletion_confirmation_message": "確定要刪除此連結嗎?",
|
||||
"warning": "警告",
|
||||
"irreversible_warning": "此動作無法復原!",
|
||||
"tip": "Tip",
|
||||
"tip": "提示",
|
||||
"shift_key_tip": "按住 Shift 鍵再點選「刪除」可略過後續的確認步驟。",
|
||||
"deleting_collection": "正在刪除收藏集...",
|
||||
"collection_deleted": "收藏集已刪除。",
|
||||
@@ -482,37 +482,37 @@
|
||||
"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",
|
||||
"theme": "佈景主題",
|
||||
"font_style": "字體樣式",
|
||||
"font_size": "字體大小",
|
||||
"line_height": "行高",
|
||||
"line_width": "行寬",
|
||||
"notes_highlights": "筆記與標記",
|
||||
"no_notes_highlights": "這個連結中沒有筆記或標記。",
|
||||
"save": "保存",
|
||||
"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.",
|
||||
"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?",
|
||||
"all_tags": "所有標籤",
|
||||
"new_tag": "新增標籤",
|
||||
"create_new_tag": "建立新標籤",
|
||||
"tag_deletion_confirmation_message": "確定要刪除此標籤?",
|
||||
"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",
|
||||
"delete_all_tags": "刪除所有標籤",
|
||||
"bulk_delete_tags": "批量刪除標籤",
|
||||
"count_tags_deleted": "已刪除 {{count}} 個標籤",
|
||||
"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)",
|
||||
"link_count_high_low": "連結數量 (由高至低)",
|
||||
"link_count_low_high": "連結數量 (由低至高)",
|
||||
"tags_selected": "{{count}} Tags selected",
|
||||
"tag_selected": "1 Tag selected",
|
||||
"merge_tags": "Merge Tags",
|
||||
"merge_count_tags": "Merge {{count}} Tags",
|
||||
"merge_tags": "合併標籤",
|
||||
"merge_count_tags": "合併 {{count}} 個標籤",
|
||||
"rename_tag_instruction": "Please provide a name for the final tag.",
|
||||
"merging": "Merging...",
|
||||
"delete_tags": "Delete {{count}} Tags",
|
||||
"merging": "合併中……",
|
||||
"delete_tags": "刪除 {{count}} 個標籤",
|
||||
"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!",
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
|
||||
type ResponseObject = {
|
||||
ok: boolean;
|
||||
data: object | string;
|
||||
};
|
||||
|
||||
type LinkStore = {
|
||||
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
|
||||
updateLinks: (
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
>
|
||||
) => Promise<ResponseObject>;
|
||||
selectedIds: Record<number, true>;
|
||||
isSelected: (id: number) => boolean;
|
||||
toggleSelected: (id: number) => void;
|
||||
clearSelected: () => void;
|
||||
setSelected: (ids: number[]) => void;
|
||||
selectionCount: number;
|
||||
};
|
||||
|
||||
const useLinkStore = create<LinkStore>()((set) => ({
|
||||
selectedLinks: [],
|
||||
setSelectedLinks: (links) => set({ selectedLinks: links }),
|
||||
updateLinks: async (links, removePreviousTags, newData) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({ links, removePreviousTags, newData }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
const useLinkStore = create<LinkStore>()((set, get) => ({
|
||||
selectedIds: {},
|
||||
|
||||
const data = await response.json();
|
||||
isSelected: (id) => !!get().selectedIds[id],
|
||||
|
||||
if (response.ok) {
|
||||
// Update the selected links with the new data
|
||||
}
|
||||
toggleSelected: (id) =>
|
||||
set((state) => {
|
||||
const next = { ...state.selectedIds };
|
||||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
if (next[id]) {
|
||||
delete next[id];
|
||||
return { selectedIds: next, selectionCount: state.selectionCount - 1 };
|
||||
} else {
|
||||
next[id] = true;
|
||||
return { selectedIds: next, selectionCount: state.selectionCount + 1 };
|
||||
}
|
||||
}),
|
||||
|
||||
clearSelected: () => set({ selectedIds: {}, selectionCount: 0 }),
|
||||
|
||||
setSelected: (ids) =>
|
||||
set(() => {
|
||||
const next: Record<number, true> = {};
|
||||
for (let i = 0; i < ids.length; i++) next[ids[i]] = true;
|
||||
return { selectedIds: next, selectionCount: Object.keys(next).length };
|
||||
}),
|
||||
|
||||
selectionCount: 0,
|
||||
}));
|
||||
|
||||
export default useLinkStore;
|
||||
|
||||
@@ -5,24 +5,24 @@ import {
|
||||
predefinedTagsPrompt,
|
||||
} from "./prompts";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { generateObject, LanguageModelV1 } from "ai";
|
||||
import { generateText } from "ai";
|
||||
import { LanguageModelV2 } from "@ai-sdk/provider";
|
||||
import {
|
||||
createOpenAICompatible,
|
||||
OpenAICompatibleProviderSettings,
|
||||
} from "@ai-sdk/openai-compatible";
|
||||
import { perplexity } from "@ai-sdk/perplexity";
|
||||
import { azure } from "@ai-sdk/azure";
|
||||
import { z } from "zod";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { createOllama } from "ollama-ai-provider";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { titleCase } from "@linkwarden/lib";
|
||||
|
||||
// Function to concat /api with the base URL properly
|
||||
const ensureValidURL = (base: string, path: string) =>
|
||||
`${base.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
||||
|
||||
const getAIModel = (): LanguageModelV1 => {
|
||||
const getAIModel = (): LanguageModelV2 => {
|
||||
if (process.env.OPENAI_API_KEY && process.env.OPENAI_MODEL) {
|
||||
let config: OpenAICompatibleProviderSettings = {
|
||||
baseURL:
|
||||
@@ -51,16 +51,14 @@ const getAIModel = (): LanguageModelV1 => {
|
||||
),
|
||||
});
|
||||
|
||||
return ollama(process.env.OLLAMA_MODEL, {
|
||||
structuredOutputs: true,
|
||||
});
|
||||
return ollama(process.env.OLLAMA_MODEL);
|
||||
}
|
||||
if (process.env.OPENROUTER_API_KEY && process.env.OPENROUTER_MODEL) {
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
});
|
||||
|
||||
return openrouter(process.env.OPENROUTER_MODEL) as LanguageModelV1;
|
||||
return openrouter(process.env.OPENROUTER_MODEL) as LanguageModelV2;
|
||||
}
|
||||
if (process.env.PERPLEXITY_API_KEY) {
|
||||
return perplexity(process.env.PERPLEXITY_MODEL || "sonar-pro");
|
||||
@@ -128,15 +126,13 @@ export default async function autoTagLink(
|
||||
return console.log("No predefined tags to auto tag for link: ", link.url);
|
||||
}
|
||||
|
||||
const { object } = await generateObject({
|
||||
const { text } = await generateText({
|
||||
model: getAIModel(),
|
||||
prompt: prompt,
|
||||
output: "array",
|
||||
schema: z.string(),
|
||||
});
|
||||
|
||||
try {
|
||||
let tags = object;
|
||||
let tags: string[] = JSON.parse(text);
|
||||
|
||||
if (!tags || tags.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "child_process";
|
||||
import { createFile } from "@linkwarden/filesystem";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { Link } from "@prisma/client";
|
||||
import { Link } from "@linkwarden/prisma/client";
|
||||
|
||||
export default async function handleMonolith(
|
||||
link: Link,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { JSDOM } from "jsdom";
|
||||
import DOMPurify from "dompurify";
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { createFile } from "@linkwarden/filesystem";
|
||||
import { Link } from "@prisma/client";
|
||||
import { Link } from "@linkwarden/prisma/client";
|
||||
|
||||
const handleReadability = async (
|
||||
content: string,
|
||||
@@ -19,7 +19,7 @@ const handleReadability = async (
|
||||
|
||||
const article = new Readability(dom.window.document).parse();
|
||||
const articleText = article?.textContent
|
||||
.replace(/ +(?= )/g, "") // strip out multiple spaces
|
||||
?.replace(/ +(?= )/g, "") // strip out multiple spaces
|
||||
.replace(/(\r\n|\n|\r)/gm, " ") // strip out line breaks
|
||||
.slice(0, TEXT_CONTENT_LIMIT ? TEXT_CONTENT_LIMIT : undefined); // limit characters if TEXT_CONTENT_LIMIT is defined
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const generateTagsPrompt = (text: string) => `
|
||||
You are a Bookmark Manager that should extract relevant tags from the following text, here are the rules:
|
||||
- The final output should be only an array of tags (like ["tag1", "tag2", "...").
|
||||
- The final output should be only an array of tags (like ["tag1", "tag2", "..."]).
|
||||
- The tags should be in the language of the text.
|
||||
- The maximum number of tags is 5.
|
||||
- Each tag should be maximum one to two words.
|
||||
@@ -15,7 +15,7 @@ export const predefinedTagsPrompt = (text: string, tags: string[]) => `
|
||||
You are a Bookmark Manager that should match the following text with predefined tags.
|
||||
Predefined tags: ${tags.join(", ")}.
|
||||
Here are the rules:
|
||||
- The final output should be only an array of tags (like ["tag1", "tag2", "...").
|
||||
- The final output should be only an array of tags (like ["tag1", "tag2", "..."]).
|
||||
- The tags should be in the language of the text.
|
||||
- The maximum number of tags is 5.
|
||||
- Each tag should be maximum one to two words.
|
||||
@@ -30,7 +30,7 @@ export const existingTagsPrompt = (text: string, tags: string[]) => `
|
||||
You are a Bookmark Manager that should match the following text with existing tags.
|
||||
The existing tags are sorted from most used to least used: ${tags.join(", ")}.
|
||||
Here are the rules:
|
||||
- The final output should be only an array of tags (like ["tag1", "tag2", "...").
|
||||
- The final output should be only an array of tags (like ["tag1", "tag2", "..."]).
|
||||
- The tags should be in the language of the text.
|
||||
- The maximum number of tags is 5.
|
||||
- Each tag should be maximum one to two words.
|
||||
|
||||
@@ -9,30 +9,30 @@
|
||||
"start": "tsx index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "1.1.5",
|
||||
"@ai-sdk/azure": "1.1.5",
|
||||
"@ai-sdk/openai-compatible": "^0.2.13",
|
||||
"@ai-sdk/perplexity": "1.1.9",
|
||||
"@ai-sdk/anthropic": "2.0.56",
|
||||
"@ai-sdk/azure": "2.0.88",
|
||||
"@ai-sdk/openai-compatible": "1.0.29",
|
||||
"@ai-sdk/perplexity": "2.0.22",
|
||||
"@linkwarden/filesystem": "*",
|
||||
"@linkwarden/lib": "*",
|
||||
"@linkwarden/prisma": "*",
|
||||
"@linkwarden/types": "*",
|
||||
"@mozilla/readability": "^0.4.4",
|
||||
"@openrouter/ai-sdk-provider": "^0.4.3",
|
||||
"ai": "^4.3.9",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"ai": "^5.0.113",
|
||||
"axios": "^1.5.1",
|
||||
"dompurify": "^3.0.6",
|
||||
"dompurify": "^3.2.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jsdom": "^22.1.0",
|
||||
"meilisearch": "^0.48.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"ollama-ai-provider": "^1.2.0",
|
||||
"ollama-ai-provider-v2": "^1.5.5",
|
||||
"playwright": "^1.55.0",
|
||||
"rss-parser": "^3.13.0",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"tsx": "^4.19.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startIndexing } from "./workers/linkIndexing";
|
||||
import { linkProcessing } from "./workers/linkProcessing";
|
||||
import { migrationWorker } from "./workers/migrationWorker";
|
||||
import { startRSSPolling } from "./workers/rssPolling";
|
||||
import { trialEndEmailWorker } from "./workers/trialEndEmailWorker";
|
||||
|
||||
@@ -7,6 +8,8 @@ const workerIntervalInSeconds =
|
||||
Number(process.env.ARCHIVE_SCRIPT_INTERVAL) || 10;
|
||||
|
||||
async function init() {
|
||||
await migrationWorker();
|
||||
|
||||
console.log("\x1b[34m%s\x1b[0m", "Initializing the worker...");
|
||||
startRSSPolling();
|
||||
linkProcessing(workerIntervalInSeconds);
|
||||
|
||||
80
apps/worker/workers/migrationWorker.ts
Normal file
80
apps/worker/workers/migrationWorker.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@linkwarden/prisma";
|
||||
import { AppMigrationStatus } from "@linkwarden/prisma/client";
|
||||
|
||||
export async function migrationWorker() {
|
||||
console.log("\x1b[34m%s\x1b[0m", "Checking for migrations...");
|
||||
|
||||
// go through all the migrations one by one in order, first see where it needs to start
|
||||
try {
|
||||
const dbMigrations = await prisma.appMigration.findMany();
|
||||
|
||||
const statusByName = new Map(dbMigrations.map((m) => [m.name, m.status]));
|
||||
|
||||
// sort by id
|
||||
const ordered = [...migrations].sort((a, b) => a.id - b.id);
|
||||
|
||||
// find the first migration that's not APPLIED
|
||||
const firstIdx = ordered.findIndex(
|
||||
(m) => statusByName.get(m.name) !== AppMigrationStatus.APPLIED
|
||||
);
|
||||
|
||||
if (firstIdx === -1) {
|
||||
// console.log("\x1b[32m%s\x1b[0m", "No pending migrations."); // Uncomment later
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = firstIdx; i < ordered.length; i++) {
|
||||
const m = ordered[i];
|
||||
const status = statusByName.get(m.name);
|
||||
|
||||
if (status === AppMigrationStatus.APPLIED) continue;
|
||||
|
||||
console.log("\x1b[34m%s\x1b[0m", `Applying ${m.name}...`);
|
||||
|
||||
await prisma.appMigration.upsert({
|
||||
where: { name: m.name },
|
||||
create: { name: m.name, status: AppMigrationStatus.PENDING },
|
||||
update: { status: AppMigrationStatus.PENDING, finishedAt: null },
|
||||
});
|
||||
|
||||
try {
|
||||
await m.run();
|
||||
|
||||
await prisma.appMigration.update({
|
||||
where: { name: m.name },
|
||||
data: { status: AppMigrationStatus.APPLIED, finishedAt: new Date() },
|
||||
});
|
||||
|
||||
statusByName.set(m.name, AppMigrationStatus.APPLIED);
|
||||
console.log("\x1b[32m%s\x1b[0m", `Applied ${m.name}`);
|
||||
} catch (err) {
|
||||
await prisma.appMigration.update({
|
||||
where: { name: m.name },
|
||||
data: { status: AppMigrationStatus.FAILED, finishedAt: new Date() },
|
||||
});
|
||||
|
||||
console.error("\x1b[31m%s\x1b[0m", `FAILED ${m.name}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\x1b[32m%s\x1b[0m", "All migrations applied.");
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
type AppMigrationDef = {
|
||||
id: number;
|
||||
name: string;
|
||||
run: () => Promise<void>;
|
||||
};
|
||||
|
||||
const migrations: AppMigrationDef[] = [
|
||||
// {
|
||||
// id: 1,
|
||||
// name: "0001_first_migration",
|
||||
// run: async () => {},
|
||||
// },
|
||||
// to create a new `AppMigrationDef`, make sure to have the `id` and the `name` field to be unique (incremental id)
|
||||
];
|
||||
BIN
assets/app_store.png
Normal file
BIN
assets/app_store.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
assets/google_play.png
Normal file
BIN
assets/google_play.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/home.png
BIN
assets/home.png
Binary file not shown.
|
Before Width: | Height: | Size: 776 KiB After Width: | Height: | Size: 606 KiB |
BIN
assets/mobile_apps.png
Normal file
BIN
assets/mobile_apps.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkwarden",
|
||||
"packageManager": "yarn@1.22.0",
|
||||
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -11,6 +11,7 @@
|
||||
"web:dev": "dotenv -- yarn workspace @linkwarden/web dev",
|
||||
"web:build": "dotenv -- yarn workspace @linkwarden/web build",
|
||||
"web:start": "dotenv -- yarn workspace @linkwarden/web start",
|
||||
"update:browserslist-db": "npx update-browserslist-db@latest",
|
||||
"worker:dev": "dotenv -- yarn workspace @linkwarden/worker dev",
|
||||
"worker:start": "dotenv -- yarn workspace @linkwarden/worker start",
|
||||
"concurrently:dev": "concurrently \"dotenv -- yarn workspace @linkwarden/web dev\" \"dotenv -- yarn workspace @linkwarden/worker dev\"",
|
||||
@@ -23,8 +24,8 @@
|
||||
"postinstall": "yarn workspace @linkwarden/web run postinstall && patch-package"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.7"
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^8.0.0"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user