mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-04 14:57:02 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9edb450b6a | ||
|
|
4fa1f57351 | ||
|
|
f3d30085de | ||
|
|
da8761387f | ||
|
|
c9fd573b31 | ||
|
|
c99f9edd9a | ||
|
|
389a96dadc | ||
|
|
c8b1129e4f | ||
|
|
b9fd802288 | ||
|
|
549299743c | ||
|
|
21b6ab3de4 | ||
|
|
155ca17b55 | ||
|
|
686e3b44e1 | ||
|
|
f13c5e1cfc | ||
|
|
7e34d98bc4 | ||
|
|
e9c1c5217b | ||
|
|
209e0faa1b | ||
|
|
27a86c0b28 | ||
|
|
0198a9148e | ||
|
|
45dc95122a | ||
|
|
8c9cd34ec3 | ||
|
|
6b3dba3faf | ||
|
|
81ae7c64a9 | ||
|
|
d39a0ed5b2 | ||
|
|
ffc9971ce6 | ||
|
|
a8a9ad602f | ||
|
|
bc750bd588 | ||
|
|
e3de382739 | ||
|
|
57601413d4 | ||
|
|
af8a650096 | ||
|
|
b445fde85a | ||
|
|
7bbdec0f85 | ||
|
|
4743aa8144 | ||
|
|
fedd19770e | ||
|
|
6c5253121c | ||
|
|
6536b34c41 | ||
|
|
2c812e11e4 | ||
|
|
13305c06c4 | ||
|
|
3cbbeb55a4 | ||
|
|
6bb261c81a | ||
|
|
dbad316bac | ||
|
|
cc37543324 | ||
|
|
7c9307dd84 | ||
|
|
c794c0814e | ||
|
|
78d6d1c70a | ||
|
|
f79f57ccda | ||
|
|
eb8402448d | ||
|
|
350cdb485a | ||
|
|
8bd3bd3763 | ||
|
|
f2cfbf0b10 | ||
|
|
513c03dcae | ||
|
|
98b7e38139 | ||
|
|
9b5c08655a | ||
|
|
caf706e8ea | ||
|
|
d54f7da5a5 | ||
|
|
ae2e3c80db | ||
|
|
06285ce6d7 | ||
|
|
fdf48abd29 | ||
|
|
59252759f2 | ||
|
|
dd96d80d42 | ||
|
|
f8efbe95e6 | ||
|
|
b4b6edd618 | ||
|
|
d066378076 | ||
|
|
cddfc5dba6 | ||
|
|
4bdcfa0ee7 | ||
|
|
cf84474921 | ||
|
|
95e662358f | ||
|
|
eb31acbc30 | ||
|
|
7a34d836be | ||
|
|
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 | ||
|
|
27997b8f4b |
@@ -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,12 +61,18 @@ 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: |
|
||||
echo "Initializing Databases"
|
||||
@@ -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 \
|
||||
|
||||
71
README.md
71
README.md
@@ -3,8 +3,10 @@
|
||||
<h1>Linkwarden</h1>
|
||||
<h3>Bookmarks, Evolved</h3>
|
||||
|
||||
<a href="https://trendshift.io/repositories/4006" target="_blank"><img src="https://trendshift.io/api/badge/repositories/4006" alt="linkwarden%2Flinkwarden | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
|
||||
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a>
|
||||
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=43856801"><img src="https://img.shields.io/badge/Hacker%20News-301-%23FF6600"></img></a>
|
||||
|
||||
<a href="https://github.com/linkwarden/linkwarden/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/linkwarden/linkwarden"></a>
|
||||
<a href="https://crowdin.com/project/linkwarden">
|
||||
@@ -38,32 +40,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.1.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "linkwarden",
|
||||
@@ -53,7 +53,9 @@
|
||||
[
|
||||
"expo-share-intent",
|
||||
{
|
||||
"iosAppGroupIdentifier": "group.app.linkwarden"
|
||||
"iosAppGroupIdentifier": "group.app.linkwarden",
|
||||
"iosActivationRules": "SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.url\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.text\" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO \"public.plain-text\")).@count > 0).@count > 0",
|
||||
"androidIntentFilters": ["text/*"]
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -14,7 +14,7 @@ import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
|
||||
export default function CollectionsScreen() {
|
||||
const { colorScheme } = useColorScheme();
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function Layout() {
|
||||
headerLargeStyle: {
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
|
||||
},
|
||||
headerBackTitle: "Back",
|
||||
headerStyle: {
|
||||
backgroundColor:
|
||||
Platform.OS === "ios"
|
||||
@@ -28,6 +29,15 @@ export default function Layout() {
|
||||
: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen
|
||||
name="preferredCollection"
|
||||
options={{
|
||||
headerTitle: "Preferred Collection",
|
||||
headerLargeTitle: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
AppWindowMac,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
LogOut,
|
||||
Mail,
|
||||
Moon,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import useDataStore from "@/store/data";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { signOut, auth } = useAuthStore();
|
||||
@@ -40,6 +43,8 @@ export default function SettingsScreen() {
|
||||
updateData({ theme: override });
|
||||
}, [override]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
@@ -196,6 +201,33 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Save Shared Links To</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
<TouchableOpacity
|
||||
className="flex-row gap-2 items-center justify-between py-3 px-4"
|
||||
onPress={() => router.navigate("/settings/preferredCollection")}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Folder
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
<Text className="text-base-content">Preferred collection</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text numberOfLines={1} className="text-neutral max-w-[140px]">
|
||||
{data.preferredCollection?.name || "None"}
|
||||
</Text>
|
||||
<ChevronRight
|
||||
size={20}
|
||||
color={rawTheme[colorScheme as ThemeName].neutral}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="mb-4 mx-4 text-neutral">Contact Us</Text>
|
||||
<View className="bg-base-200 rounded-xl flex-col">
|
||||
|
||||
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
99
apps/mobile/app/(tabs)/settings/preferredCollection.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { View, Text, FlatList, TouchableOpacity } from "react-native";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import useDataStore from "@/store/data";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import Input from "@/components/ui/Input";
|
||||
import { Folder, Check } from "lucide-react-native";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
|
||||
const PreferredCollectionScreen = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const { data, updateData } = useDataStore();
|
||||
const collections = useCollections(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (!collections.data) return [];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q === "") return collections.data;
|
||||
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
|
||||
}, [collections.data, searchQuery]);
|
||||
|
||||
const renderCollection = useCallback(
|
||||
({
|
||||
item: collection,
|
||||
}: {
|
||||
item: CollectionIncludingMembersAndLinkCount;
|
||||
}) => {
|
||||
const isSelected = data.preferredCollection?.id === collection.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="bg-base-200 rounded-lg px-4 py-3 mb-3 flex-row items-center justify-between"
|
||||
onPress={() => updateData({ preferredCollection: collection })}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 w-[70%]">
|
||||
<Folder
|
||||
size={20}
|
||||
fill={collection.color || "gray"}
|
||||
color={collection.color || "gray"}
|
||||
/>
|
||||
<Text numberOfLines={1} className="text-base-content">
|
||||
{collection.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{isSelected ? (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
) : null}
|
||||
<Text className="text-neutral">
|
||||
{collection._count?.links ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
},
|
||||
[colorScheme, data.preferredCollection?.id, updateData]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-base-100">
|
||||
<FlatList
|
||||
data={filteredCollections}
|
||||
keyExtractor={(item) => item.id?.toString() || ""}
|
||||
renderItem={renderCollection}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 20,
|
||||
}}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
ListHeaderComponent={
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-200 h-10"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferredCollectionScreen;
|
||||
@@ -13,7 +13,7 @@ import React, { useEffect, useState } from "react";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
export default function TagsScreen() {
|
||||
@@ -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>
|
||||
|
||||
@@ -33,7 +33,7 @@ import useTmpStore from "@/store/tmp";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
MobileAuth,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
|
||||
import { deleteLinkCache } from "@/lib/cache";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
@@ -120,8 +120,8 @@ const RootComponent = ({
|
||||
auth: MobileAuth;
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const updateLink = useUpdateLink(auth);
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const { tmp } = useTmpStore();
|
||||
|
||||
@@ -229,12 +229,12 @@ const RootComponent = ({
|
||||
{tmp.link && tmp.user && (
|
||||
<DropdownMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={async () => {
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
|
||||
? true
|
||||
: false;
|
||||
await updateLink.mutateAsync({
|
||||
updateLink.mutateAsync({
|
||||
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
@@ -282,18 +282,15 @@ const RootComponent = ({
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
onPress: async () => {
|
||||
deleteLink.mutate(
|
||||
tmp.link?.id as number,
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
},
|
||||
}
|
||||
tmp.link?.id as number
|
||||
);
|
||||
// go back
|
||||
|
||||
await deleteLinkCache(
|
||||
tmp.link?.id as number
|
||||
);
|
||||
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
SafeAreaView,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
import { Redirect, useRouter } from "expo-router";
|
||||
import useAuthStore from "@/store/auth";
|
||||
@@ -14,20 +14,29 @@ import { Check } from "lucide-react-native";
|
||||
import { useAddLink } from "@linkwarden/router/links";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
export default function IncomingScreen() {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { data, updateData } = useDataStore();
|
||||
const addLink = useAddLink(auth);
|
||||
const addLink = useAddLink({ auth });
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.status === "authenticated" && data.shareIntent.url)
|
||||
addLink.mutate(
|
||||
{ url: data.shareIntent.url },
|
||||
{
|
||||
onSuccess: () => {
|
||||
url: data.shareIntent.url,
|
||||
collection: { id: data.preferredCollection?.id },
|
||||
},
|
||||
{
|
||||
onSuccess: (e) => {
|
||||
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
updateData({
|
||||
shareIntent: {
|
||||
@@ -36,7 +45,7 @@ export default function IncomingScreen() {
|
||||
},
|
||||
});
|
||||
router.replace("/dashboard");
|
||||
}, 1000);
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error adding the link.");
|
||||
@@ -50,49 +59,39 @@ export default function IncomingScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-base-100">
|
||||
{data?.shareIntent.url ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Check
|
||||
size={140}
|
||||
className="mb-3 text-base-content"
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl font-semibold text-base-content">
|
||||
Link Saved!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="mt-3 text-base text-base-content opacity-70">
|
||||
One sec… {String(data?.shareIntent.url)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-1 items-center justify-center">
|
||||
{data?.shareIntent.url && showSuccess && link ? (
|
||||
<>
|
||||
<Check
|
||||
size={140}
|
||||
className="mb-3 text-base-content"
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
<Text className="text-2xl font-semibold text-base-content">
|
||||
Link Saved!
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="w-fit mx-auto mt-5"
|
||||
onPress={() =>
|
||||
SheetManager.show("edit-link-sheet", {
|
||||
payload: {
|
||||
link: link,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="text-neutral text-center w-fit">Edit Link</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text className="mt-3 text-base text-base-content opacity-70">
|
||||
One sec…
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
check: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: "600",
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import ReadableFormat from "@/components/Formats/ReadableFormat";
|
||||
import ImageFormat from "@/components/Formats/ImageFormat";
|
||||
import PdfFormat from "@/components/Formats/PdfFormat";
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 237 KiB |
@@ -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";
|
||||
@@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
export default function AddLinkSheet() {
|
||||
const actionSheetRef = useRef<ActionSheetRef>(null);
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink(auth);
|
||||
const addLink = useAddLink({ auth, Alert });
|
||||
const [link, setLink] = useState("");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AddLinkSheet() {
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
@@ -31,6 +31,10 @@ export default function AddLinkSheet() {
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
New Link
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="e.g. https://example.com"
|
||||
className="mb-4 bg-base-100"
|
||||
@@ -39,21 +43,12 @@ export default function AddLinkSheet() {
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
addLink.mutate(
|
||||
{ url: link },
|
||||
{
|
||||
onSuccess: () => {
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error adding the link.");
|
||||
console.error("Error adding link:", error);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
onPress={() => {
|
||||
addLink.mutate({ url: link });
|
||||
|
||||
actionSheetRef.current?.hide();
|
||||
setLink("");
|
||||
}}
|
||||
isLoading={addLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Alert, Platform } from "react-native";
|
||||
import { View, Text, Alert, TouchableOpacity } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import ActionSheet, {
|
||||
FlatList,
|
||||
@@ -15,13 +15,15 @@ import useAuthStore from "@/store/auth";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
TagIncludingLinkCount,
|
||||
} from "@linkwarden/types/global";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { Folder, ChevronRight, Check } from "lucide-react-native";
|
||||
import { Folder, ChevronRight, ChevronLeft, Check } from "lucide-react-native";
|
||||
import useTmpStore from "@/store/tmp";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTags } from "@linkwarden/router/tags";
|
||||
|
||||
const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
const { auth } = useAuthStore();
|
||||
@@ -31,7 +33,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
const [link, setLink] = useState<
|
||||
LinkIncludingShortenedCollectionAndTags | undefined
|
||||
>(props.payload?.link);
|
||||
const editLink = useUpdateLink(auth);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
@@ -45,6 +47,10 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Edit Link
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
@@ -82,23 +88,29 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* <Button variant="input" className="mb-4 h-auto">
|
||||
<Button
|
||||
variant="input"
|
||||
className="mb-4 h-auto"
|
||||
onPress={() => router?.navigate("tags", { link })}
|
||||
>
|
||||
{link?.tags && link?.tags.length > 0 ? (
|
||||
<View className="flex-row flex-wrap items-center gap-2 w-[90%]">
|
||||
{link.tags.map((tag) => (
|
||||
<View
|
||||
key={tag.id}
|
||||
className="bg-gray-200 rounded-md h-7 px-2 py-1"
|
||||
className="bg-neutral rounded-md h-7 px-2 py-1"
|
||||
>
|
||||
<Text numberOfLines={1}>{tag.name}</Text>
|
||||
<Text numberOfLines={1} className="text-base-100">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-gray-500">No tags</Text>
|
||||
<Text className="text-neutral">No tags</Text>
|
||||
)}
|
||||
<ChevronRight size={16} color={"gray"} />
|
||||
</Button> */}
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
multiline
|
||||
@@ -112,23 +124,15 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={() =>
|
||||
editLink.mutate(link as LinkIncludingShortenedCollectionAndTags, {
|
||||
onSuccess: () => {
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
},
|
||||
onError: (error) => {
|
||||
Alert.alert("Error", "There was an error editing the link.");
|
||||
console.error("Error editing link:", error);
|
||||
},
|
||||
})
|
||||
}
|
||||
isLoading={editLink.isPending}
|
||||
onPress={() => {
|
||||
updateLink.mutate(link as LinkIncludingShortenedCollectionAndTags);
|
||||
if (link && tmp.link)
|
||||
updateTmp({
|
||||
link,
|
||||
});
|
||||
SheetManager.hide("edit-link-sheet");
|
||||
}}
|
||||
isLoading={updateLink.isPending}
|
||||
variant="accent"
|
||||
className="mb-2"
|
||||
>
|
||||
@@ -150,7 +154,7 @@ const Main = (props: SheetProps<"edit-link-sheet">) => {
|
||||
|
||||
const Collections = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink(auth);
|
||||
const addLink = useAddLink({ auth });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const { link: currentLink } = useSheetRouteParams<
|
||||
@@ -175,13 +179,11 @@ const Collections = () => {
|
||||
item: CollectionIncludingMembersAndLinkCount;
|
||||
}) => {
|
||||
const onSelect = () => {
|
||||
// 1. Create a brand-new link object with the new collection
|
||||
const updatedLink = {
|
||||
...currentLink!,
|
||||
...currentLink,
|
||||
collection,
|
||||
};
|
||||
|
||||
// 2. Navigate back to "main", passing the updated link as payload
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: updatedLink });
|
||||
};
|
||||
@@ -216,16 +218,32 @@ const Collections = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="px-8 py-5 max-h-[80vh]">
|
||||
<View className="py-5 max-h-[80vh]">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 top-6 left-8 absolute"
|
||||
onPress={() => {
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: currentLink });
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text className="text-primary">Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Collection
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Search collections"
|
||||
className="mb-4 bg-base-100"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredCollections}
|
||||
data={[...filteredCollections]}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
@@ -236,7 +254,106 @@ const Collections = () => {
|
||||
No collections match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
contentContainerClassName="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Tags = () => {
|
||||
const { auth } = useAuthStore();
|
||||
const addLink = useAddLink({ auth });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const router = useSheetRouter("edit-link-sheet");
|
||||
const params = useSheetRouteParams("edit-link-sheet", "tags");
|
||||
const tags = useTags(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const [updatedLink, setUpdatedLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(params.link);
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
if (!tags.data) return [];
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q === "") return tags.data;
|
||||
return tags.data.filter((tag) => tag.name.toLowerCase().includes(q));
|
||||
}, [tags.data, searchQuery]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item: tag }: { item: TagIncludingLinkCount }) => {
|
||||
const onSelect = () => {
|
||||
const isSelected = (updatedLink?.tags || []).some(
|
||||
(t) => t.id === tag.id
|
||||
);
|
||||
const nextTags = isSelected
|
||||
? (updatedLink?.tags || []).filter((t) => t.id !== tag.id)
|
||||
: [...(updatedLink?.tags || []), tag];
|
||||
|
||||
setUpdatedLink({
|
||||
...updatedLink,
|
||||
tags: nextTags,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="input" className="mb-2" onPress={onSelect}>
|
||||
<View className="flex-row items-center gap-2 w-[75%]">
|
||||
<Text numberOfLines={1} className="w-full text-base-content">
|
||||
{tag.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{updatedLink?.tags.find((e) => e.id === tag.id) && (
|
||||
<Check
|
||||
size={16}
|
||||
color={rawTheme[colorScheme as ThemeName].primary}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-neutral">{tag._count?.links ?? 0}</Text>
|
||||
</View>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
[addLink, params.link, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="py-5 max-h-[80vh]">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 top-6 left-8 absolute"
|
||||
onPress={() => {
|
||||
router?.popToTop();
|
||||
router?.navigate("main", { link: updatedLink });
|
||||
}}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={18}
|
||||
color={rawTheme[colorScheme as ThemeName]["primary"]}
|
||||
/>
|
||||
<Text className="text-primary">Back</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
Tags
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Search tags"
|
||||
className="mb-4 bg-base-100 mx-8"
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={filteredTags}
|
||||
keyExtractor={(e, i) => i.toString()}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={
|
||||
<Text
|
||||
style={{ textAlign: "center", marginTop: 20 }}
|
||||
className="text-neutral"
|
||||
>
|
||||
No tags match “{searchQuery}”
|
||||
</Text>
|
||||
}
|
||||
contentContainerClassName="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -251,6 +368,10 @@ const routes: Route[] = [
|
||||
name: "collections",
|
||||
component: Collections,
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
component: Tags,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EditLinkSheet() {
|
||||
@@ -262,9 +383,8 @@ export default function EditLinkSheet() {
|
||||
<ActionSheet
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
display: "none",
|
||||
}}
|
||||
enableRouterBackNavigation={true}
|
||||
routes={routes}
|
||||
initialRoute="main"
|
||||
containerStyle={{
|
||||
|
||||
@@ -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";
|
||||
@@ -26,7 +26,7 @@ export default function NewCollectionSheet() {
|
||||
ref={actionSheetRef}
|
||||
gestureEnabled
|
||||
indicatorStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["neutral-content"],
|
||||
display: "none",
|
||||
}}
|
||||
containerStyle={{
|
||||
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
|
||||
@@ -34,6 +34,10 @@ export default function NewCollectionSheet() {
|
||||
safeAreaInsets={insets}
|
||||
>
|
||||
<View className="px-8 py-5">
|
||||
<Text className="font-semibold text-lg mx-auto mb-5 text-base-content">
|
||||
New Collection
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder="Name"
|
||||
className="mb-4 bg-base-100"
|
||||
|
||||
@@ -7,7 +7,7 @@ import SupportSheet from "./SupportSheet";
|
||||
import AddLinkSheet from "./AddLinkSheet";
|
||||
import EditLinkSheet from "./EditLinkSheet";
|
||||
import NewCollectionSheet from "./NewCollectionSheet";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
registerSheet("support-sheet", SupportSheet);
|
||||
registerSheet("add-link-sheet", AddLinkSheet);
|
||||
@@ -29,6 +29,9 @@ declare module "react-native-actions-sheet" {
|
||||
collections: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
tags: RouteDefinition<{
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
"new-collection-sheet": SheetDefinition;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
@@ -19,7 +19,7 @@ const CollectionListing = ({ collection }: Props) => {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
const deleteCollection = useDeleteCollection(auth);
|
||||
const deleteCollection = useDeleteCollection({ auth, Alert });
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Hash,
|
||||
Link,
|
||||
} from "lucide-react-native";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
import { Image, Platform, ScrollView } from "react-native";
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import Pdf from "react-native-pdf";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { decode } from "html-entities";
|
||||
import { useColorScheme } from "nativewind";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { CalendarDays, Link } from "lucide-react-native";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import { Link as LinkType } from "@linkwarden/prisma/client";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { ArchivedFormat } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { ArchivedFormat } from "@linkwarden/types/global";
|
||||
import getFormatBasedOnPreference from "@linkwarden/lib/getFormatBasedOnPreference";
|
||||
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
|
||||
import {
|
||||
@@ -40,12 +40,12 @@ type Props = {
|
||||
const LinkListing = ({ link, dashboard }: Props) => {
|
||||
const { auth } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const updateLink = useUpdateLink(auth);
|
||||
const updateLink = useUpdateLink({ auth, Alert });
|
||||
const { data: user } = useUser(auth);
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { data } = useDataStore();
|
||||
|
||||
const deleteLink = useDeleteLink(auth);
|
||||
const deleteLink = useDeleteLink({ auth, Alert });
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
@@ -57,7 +57,7 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}, [link]);
|
||||
}, [link.url]);
|
||||
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
@@ -122,8 +122,8 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
<View className="flex flex-row gap-1 items-center mt-1.5 pr-1.5 self-start rounded-md">
|
||||
<Folder
|
||||
size={16}
|
||||
fill={link.collection.color || ""}
|
||||
color={link.collection.color || ""}
|
||||
fill={link.collection.color || "#0ea5e9"}
|
||||
color={link.collection.color || "#0ea5e9"}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
@@ -215,11 +215,11 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
|
||||
<ContextMenu.Item
|
||||
key="pin-link"
|
||||
onSelect={async () => {
|
||||
onSelect={() => {
|
||||
const isAlreadyPinned =
|
||||
link?.pinnedBy && link.pinnedBy[0] ? true : false;
|
||||
|
||||
await updateLink.mutateAsync({
|
||||
updateLink.mutateAsync({
|
||||
...link,
|
||||
pinnedBy: (isAlreadyPinned
|
||||
? [{ id: undefined }]
|
||||
@@ -319,12 +319,10 @@ const LinkListing = ({ link, dashboard }: Props) => {
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteLink.mutate(link.id as number, {
|
||||
onSuccess: async () => {
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
});
|
||||
onPress: async () => {
|
||||
deleteLink.mutate(link.id as number);
|
||||
|
||||
await deleteLinkCache(link.id as number);
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "react-native";
|
||||
import LinkListing from "@/components/LinkListing";
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Spinner from "@/components/ui/Spinner";
|
||||
import { rawTheme, ThemeName } from "@/lib/colors";
|
||||
import { useColorScheme } from "nativewind";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable, Platform, Alert } from "react-native";
|
||||
import { decode } from "html-entities";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import useAuthStore from "@/store/auth";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { router } from "expo-router";
|
||||
import { MobileAuth } from "@linkwarden/types";
|
||||
import { MobileAuth } from "@linkwarden/types/global";
|
||||
import { Alert } from "react-native";
|
||||
import * 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: (
|
||||
@@ -55,13 +52,20 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||
console.log("Signing into", instance);
|
||||
|
||||
if (token) {
|
||||
// make a request to the API to validate the token
|
||||
await fetch(instance + "/api/v1/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then(async (res) => {
|
||||
try {
|
||||
// make a request to the API to validate the token
|
||||
const res = await Promise.race([
|
||||
fetch(instance + "/api/v1/users/me", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}),
|
||||
new Promise<Response>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("TIMEOUT")), 30000)
|
||||
),
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
await SecureStore.setItemAsync("INSTANCE", instance);
|
||||
await SecureStore.setItemAsync("TOKEN", token);
|
||||
@@ -76,33 +80,56 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||
} else {
|
||||
Alert.alert("Error", "Invalid token");
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.message === "TIMEOUT") {
|
||||
Alert.alert(
|
||||
"Request timed out",
|
||||
"Unable to reach the server in time. Please check your network configuration and try again."
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Network error",
|
||||
"Could not connect to the server. Please check your network configuration and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { MobileData } from "@linkwarden/types";
|
||||
import { MobileData } from "@linkwarden/types/global";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { colorScheme } from "nativewind";
|
||||
|
||||
@@ -15,13 +15,14 @@ const useDataStore = create<DataStore>((set, get) => ({
|
||||
hasShareIntent: false,
|
||||
url: "",
|
||||
},
|
||||
theme: "light",
|
||||
theme: "system",
|
||||
preferredBrowser: "app",
|
||||
preferredCollection: null,
|
||||
},
|
||||
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 } }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { User } from "@linkwarden/prisma/client";
|
||||
|
||||
type Tmp = {
|
||||
|
||||
125
apps/web/components/AdminSidebar.tsx
Normal file
125
apps/web/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function AdminSidebar({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const LINKWARDEN_VERSION = process.env.version;
|
||||
|
||||
const { data: user } = useUser();
|
||||
|
||||
const router = useRouter();
|
||||
const [active, setActive] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setActive(router.asPath);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{user?.theme === "light" ? (
|
||||
<Image
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={"/linkwarden_dark.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/admin/user-administration">
|
||||
<div
|
||||
className={`${
|
||||
active === "/admin/user-administration"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-people text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("user_administration")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/background-jobs">
|
||||
<div
|
||||
className={`${
|
||||
active === "/admin/background-jobs"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-gear-wide-connected text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("background_jobs")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href={`https://github.com/linkwarden/linkwarden/releases`}
|
||||
target="_blank"
|
||||
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
|
||||
>
|
||||
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
|
||||
</Link>
|
||||
<Link href="https://docs.linkwarden.app" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-github text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("github")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("twitter")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("mastodon")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ProfilePhoto from "./ProfilePhoto";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
|
||||
@@ -11,7 +11,7 @@ import Tree, {
|
||||
} from "@atlaskit/tree";
|
||||
import { Collection } from "@linkwarden/prisma/client";
|
||||
import Link from "next/link";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "next-i18next";
|
||||
@@ -23,7 +23,7 @@ import { useUpdateUser, useUser } from "@linkwarden/router/user";
|
||||
import Icon from "./Icon";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Droppable from "./Droppable";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { Active, useDndContext } from "@dnd-kit/core";
|
||||
|
||||
interface ExtendedTreeItem extends TreeItem {
|
||||
@@ -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}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ConfirmationModal({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal toggleModal={toggleModal} className={className}>
|
||||
<Modal toggleModal={() => toggleModal()} className={className}>
|
||||
<p className="text-xl font-thin">{title}</p>
|
||||
<Separator className="mb-3 mt-1" />
|
||||
{children}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface DashboardSectionOption {
|
||||
@@ -274,7 +274,7 @@ export default function DashboardLayoutDropdown() {
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1 mx-2">
|
||||
<p className="text-sm text-neutral mb-1">
|
||||
<p className="text-xs font-bold text-neutral mb-1">
|
||||
{t("display_on_dashboard")}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import unescapeString from "@/lib/client/unescapeString";
|
||||
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useGetLink, useLinks } from "@linkwarden/router/links";
|
||||
import { useGetLink } from "@linkwarden/router/links";
|
||||
import { useRouter } from "next/router";
|
||||
import openLink from "@/lib/client/openLink";
|
||||
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
|
||||
@@ -26,7 +26,8 @@ import LinkTypeBadge from "./LinkViews/LinkComponents/LinkTypeBadge";
|
||||
import LinkPin from "./LinkViews/LinkComponents/LinkPin";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
export function DashboardLinks({
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -78,8 +82,6 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
settings: { show },
|
||||
} = useLocalSettingsStore();
|
||||
|
||||
const { links } = useLinks();
|
||||
|
||||
const router = useRouter();
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
@@ -98,7 +100,7 @@ export function Card({ link, editMode, dashboardType }: Props) {
|
||||
(e) => e.id === link.collection.id
|
||||
) as CollectionIncludingMembersAndLinkCount
|
||||
);
|
||||
}, [collections, links]);
|
||||
}, [collections, link]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
@@ -163,6 +165,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 +209,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 +227,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"
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import { useUpdateLink } from "@linkwarden/router/links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 updateLink = useUpdateLink({ toast, t });
|
||||
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,151 @@ 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 }
|
||||
) => {
|
||||
updateLink.mutateAsync(updatedLink);
|
||||
};
|
||||
|
||||
// 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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import importBookmarks from "@/lib/client/importBookmarks";
|
||||
import { MigrationFormat } from "@linkwarden/types";
|
||||
import { MigrationFormat } from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
@@ -113,7 +113,7 @@ export default function LinkDetails({
|
||||
);
|
||||
};
|
||||
|
||||
const updateLink = useUpdateLink();
|
||||
const updateLink = useUpdateLink({ toast, t });
|
||||
const updateFile = useUpdateFile();
|
||||
|
||||
const submit = async (e?: any) => {
|
||||
@@ -126,21 +126,9 @@ export default function LinkDetails({
|
||||
return;
|
||||
}
|
||||
|
||||
const load = toast.loading(t("updating"));
|
||||
updateLink.mutateAsync(link);
|
||||
|
||||
await updateLink.mutateAsync(link, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success(t("updated"));
|
||||
setMode && setMode("view");
|
||||
setLink(data);
|
||||
}
|
||||
},
|
||||
});
|
||||
setMode && setMode("view");
|
||||
};
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
@@ -187,6 +175,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,14 +4,13 @@ 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 {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useArchiveAction, useBulkDeleteLinks } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -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/global";
|
||||
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();
|
||||
@@ -58,7 +54,7 @@ export default function LinkActions({
|
||||
const [refreshPreservationsModal, setRefreshPreservationsModal] =
|
||||
useState(false);
|
||||
|
||||
const deleteLink = useDeleteLink();
|
||||
const deleteLink = useDeleteLink({ toast, t });
|
||||
|
||||
const updateArchive = async () => {
|
||||
const load = toast.loading(t("sending_request"));
|
||||
@@ -135,13 +131,7 @@ export default function LinkActions({
|
||||
onClick={async (e) => {
|
||||
if (e.shiftKey) {
|
||||
const load = toast.loading(t("deleting"));
|
||||
await deleteLink.mutateAsync(link.id as number, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
if (error) toast.error(error.message);
|
||||
else toast.success(t("deleted"));
|
||||
},
|
||||
});
|
||||
await deleteLink.mutateAsync(link.id as number);
|
||||
} else {
|
||||
setDeleteLinkModal(true);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
} from "@linkwarden/types/global";
|
||||
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);
|
||||
|
||||
@@ -2,23 +2,20 @@ import Icon from "@/components/Icon";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import { 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 { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
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);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { formatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Image from "next/image";
|
||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
import { IconWeight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import oklchVariableToHex from "@/lib/client/oklchVariableToHex";
|
||||
|
||||
export default function LinkIcon({
|
||||
function LinkIcon({
|
||||
link,
|
||||
className,
|
||||
hideBackground,
|
||||
@@ -30,6 +30,10 @@ export default function LinkIcon({
|
||||
|
||||
const [faviconLoaded, setFaviconLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setFaviconLoaded(false);
|
||||
}, [link.url]);
|
||||
|
||||
return (
|
||||
<div onClick={() => onClick && onClick()}>
|
||||
{link.icon ? (
|
||||
@@ -45,17 +49,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 +108,5 @@ const LinkPlaceholderIcon = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(LinkIcon);
|
||||
|
||||
@@ -1,114 +1,62 @@
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
} from "@linkwarden/types/global";
|
||||
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);
|
||||
|
||||
@@ -2,9 +2,8 @@ import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
} from "@linkwarden/types/global";
|
||||
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 { cn } from "@linkwarden/lib/utils";
|
||||
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,4 +1,4 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
import usePinLink from "@/lib/client/pinLink";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
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,32 +1,50 @@
|
||||
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
|
||||
import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ViewMode,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
|
||||
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) : [];
|
||||
|
||||
@@ -66,7 +66,13 @@ export default function MobileNavigation({}: Props) {
|
||||
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
|
||||
</div>
|
||||
</div>
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal
|
||||
onClose={() => {
|
||||
setNewLinkModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import { useRouter } from "next/router";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import Modal from "../Modal";
|
||||
@@ -22,7 +21,6 @@ export default function DeleteCollectionModal({
|
||||
const { t } = useTranslation();
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const router = useRouter();
|
||||
const permissions = usePermissions(collection.id as number);
|
||||
|
||||
@@ -30,32 +28,15 @@ export default function DeleteCollectionModal({
|
||||
setCollection(activeCollection);
|
||||
}, []);
|
||||
|
||||
const deleteCollection = useDeleteCollection();
|
||||
const deleteCollection = useDeleteCollection({ toast, t });
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
if (!collection) return null;
|
||||
if (!collection) return null;
|
||||
|
||||
setSubmitLoader(true);
|
||||
deleteCollection.mutateAsync(collection.id as number);
|
||||
|
||||
const load = toast.loading(t("deleting_collection"));
|
||||
|
||||
deleteCollection.mutateAsync(collection.id as number, {
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("deleted"));
|
||||
router.push("/collections");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
router.push("/collections");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import Modal from "../Modal";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -18,7 +18,7 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
||||
const [link, setLink] =
|
||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
|
||||
const deleteLink = useDeleteLink();
|
||||
const deleteLink = useDeleteLink({ toast, t });
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -26,26 +26,15 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
const load = toast.loading(t("deleting"));
|
||||
deleteLink.mutateAsync(link.id as number);
|
||||
|
||||
await deleteLink.mutateAsync(link.id as number, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
if (
|
||||
router.pathname.startsWith("/links/[id]") ||
|
||||
router.pathname.startsWith("/preserved/[id]")
|
||||
) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
toast.success(t("deleted"));
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
});
|
||||
if (
|
||||
router.pathname.startsWith("/links/[id]") ||
|
||||
router.pathname.startsWith("/preserved/[id]")
|
||||
) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import Modal from "../Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isAdmin = data?.user?.id === config?.ADMIN;
|
||||
const isAdmin = data?.user?.id === (config?.ADMIN || 1);
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUpdateCollection } from "@linkwarden/router/collections";
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AccountSettings,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
Member,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import ProfilePhoto from "../ProfilePhoto";
|
||||
@@ -41,6 +41,9 @@ export default function EditCollectionSharingModal({
|
||||
const [collection, setCollection] =
|
||||
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||
|
||||
const [propagateToSubcollections, setPropagateToSubcollections] =
|
||||
useState(false);
|
||||
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const updateCollection = useUpdateCollection();
|
||||
|
||||
@@ -53,19 +56,22 @@ export default function EditCollectionSharingModal({
|
||||
|
||||
const load = toast.loading(t("updating_collection"));
|
||||
|
||||
await updateCollection.mutateAsync(collection, {
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
await updateCollection.mutateAsync(
|
||||
{ ...collection, propagateToSubcollections },
|
||||
{
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("updated"));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -365,6 +371,27 @@ export default function EditCollectionSharingModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<div>
|
||||
<label className="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={propagateToSubcollections}
|
||||
onChange={() =>
|
||||
setPropagateToSubcollections(!propagateToSubcollections)
|
||||
}
|
||||
className="checkbox checkbox-primary"
|
||||
/>
|
||||
<span className="label-text">
|
||||
{t("apply_members_roles_to_subcollections")}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-neutral text-sm">
|
||||
{t("apply_members_roles_to_subcollections_desc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{permissions === true && !isPublicRoute && (
|
||||
<Button
|
||||
variant="accent"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useDeleteLink } from "@linkwarden/router/links";
|
||||
import Drawer from "../Drawer";
|
||||
@@ -43,7 +43,7 @@ export default function LinkModal({
|
||||
|
||||
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
|
||||
|
||||
const deleteLink = useDeleteLink();
|
||||
const deleteLink = useDeleteLink({ toast, t });
|
||||
|
||||
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
|
||||
|
||||
@@ -51,13 +51,8 @@ export default function LinkModal({
|
||||
setTimeout(() => (document.body.style.pointerEvents = ""), 0);
|
||||
|
||||
if (e.shiftKey && link.id) {
|
||||
const loading = toast.loading(t("deleting"));
|
||||
await deleteLink.mutateAsync(link.id, {
|
||||
onSettled: (data, error) => {
|
||||
toast.dismiss(loading);
|
||||
error ? toast.error(error.message) : toast.success(t("deleted"));
|
||||
},
|
||||
});
|
||||
deleteLink.mutateAsync(link.id);
|
||||
|
||||
onClose();
|
||||
} else {
|
||||
onDelete();
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { Collection } from "@linkwarden/prisma/client";
|
||||
import Modal from "../Modal";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types";
|
||||
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCreateCollection } from "@linkwarden/router/collections";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -7,14 +7,17 @@ import { useRouter } from "next/router";
|
||||
import Modal from "../Modal";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useAddLink } from "@linkwarden/router/links";
|
||||
import toast from "react-hot-toast";
|
||||
import { PostLinkSchemaType } from "@linkwarden/lib/schemaValidation";
|
||||
import {
|
||||
PostLinkSchema,
|
||||
PostLinkSchemaType,
|
||||
} from "@linkwarden/lib/schemaValidation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { useAddLink } from "@linkwarden/router/links";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function NewLinkModal({ onClose }: Props) {
|
||||
@@ -31,10 +34,13 @@ export default function NewLinkModal({ onClose }: Props) {
|
||||
},
|
||||
} as PostLinkSchemaType;
|
||||
|
||||
const addLink = useAddLink({
|
||||
toast,
|
||||
t,
|
||||
});
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [link, setLink] = useState<PostLinkSchemaType>(initial);
|
||||
const addLink = useAddLink();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||
const router = useRouter();
|
||||
const { data: collections = [] } = useCollections();
|
||||
@@ -80,22 +86,17 @@ export default function NewLinkModal({ onClose }: Props) {
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
const load = toast.loading(t("creating_link"));
|
||||
await addLink.mutateAsync(link, {
|
||||
onSettled: (data, error) => {
|
||||
setSubmitLoader(false);
|
||||
toast.dismiss(load);
|
||||
if (error) {
|
||||
toast.error(t(error.message));
|
||||
} else {
|
||||
onClose();
|
||||
toast.success(t("link_created"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
const dataValidation = PostLinkSchema.safeParse(link);
|
||||
|
||||
if (!dataValidation.success)
|
||||
return toast.error(
|
||||
`Error: ${
|
||||
dataValidation.error.issues[0].message
|
||||
} [${dataValidation.error.issues[0].path.join(", ")}]`
|
||||
);
|
||||
|
||||
addLink.mutateAsync(link);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import TextInput from "@/components/TextInput";
|
||||
import { TokenExpiry } from "@linkwarden/types";
|
||||
import { TokenExpiry } from "@linkwarden/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -26,12 +26,20 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import Link from "next/link";
|
||||
import SettingsSidebar from "@/components/SettingsSidebar";
|
||||
import AdminSidebar from "@/components/AdminSidebar";
|
||||
|
||||
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
|
||||
const TRIAL_PERIOD_DAYS =
|
||||
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
|
||||
|
||||
export default function Navbar() {
|
||||
export default function Navbar({
|
||||
settings,
|
||||
admin,
|
||||
}: {
|
||||
settings?: boolean;
|
||||
admin?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data: user } = useUser();
|
||||
@@ -162,13 +170,23 @@ export default function Navbar() {
|
||||
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
|
||||
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
|
||||
<div className="slide-right h-full shadow-lg">
|
||||
<Sidebar />
|
||||
{admin ? (
|
||||
<AdminSidebar />
|
||||
) : settings ? (
|
||||
<SettingsSidebar />
|
||||
) : (
|
||||
<Sidebar />
|
||||
)}
|
||||
</div>
|
||||
</ClickAwayHandler>
|
||||
</div>
|
||||
)}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||
<NewLinkModal
|
||||
onClose={() => {
|
||||
setNewLinkModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||
|
||||
@@ -40,7 +40,13 @@ export default function NoLinksFound({ text }: Props) {
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
|
||||
{newLinkModal && (
|
||||
<NewLinkModal
|
||||
onClose={() => {
|
||||
setNewLinkModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,8 @@ import { PreservationSkeleton } from "../Skeletons";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@linkwarden/types";
|
||||
import {
|
||||
atLeastOneFormatAvailable,
|
||||
formatAvailable,
|
||||
} from "@linkwarden/lib/formatStats";
|
||||
} from "@linkwarden/types/global";
|
||||
import { formatAvailable } from "@linkwarden/lib/formatStats";
|
||||
import getLinkTypeFromFormat from "@linkwarden/lib/getLinkTypeFromFormat";
|
||||
|
||||
type Props = {
|
||||
@@ -66,6 +63,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 +133,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) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ArchivedFormat,
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -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)} />
|
||||
|
||||
@@ -11,7 +11,7 @@ import usePermissions from "@/hooks/usePermissions";
|
||||
import {
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
ArchivedFormat,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import {
|
||||
useGetLinkHighlights,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function ProfileDropdown() {
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
href="/admin/user-administration"
|
||||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function ProfilePhoto({
|
||||
draggable={false}
|
||||
onError={() => setImage("")}
|
||||
className="aspect-square rounded-full"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,82 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
type Props = {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const ADVANCED_SEARCH_OPERATORS = [
|
||||
{
|
||||
operator: "name:",
|
||||
labelKey: "search_operator_name",
|
||||
icon: "bi-type",
|
||||
},
|
||||
{
|
||||
operator: "url:",
|
||||
labelKey: "search_operator_url",
|
||||
icon: "bi-link-45deg",
|
||||
},
|
||||
{
|
||||
operator: "tag:",
|
||||
labelKey: "search_operator_tag",
|
||||
icon: "bi-tag",
|
||||
},
|
||||
{
|
||||
operator: "collection:",
|
||||
labelKey: "search_operator_collection",
|
||||
icon: "bi-folder2",
|
||||
},
|
||||
{
|
||||
operator: "before:",
|
||||
labelKey: "search_operator_before",
|
||||
icon: "bi-calendar-minus",
|
||||
},
|
||||
{
|
||||
operator: "after:",
|
||||
labelKey: "search_operator_after",
|
||||
icon: "bi-calendar-plus",
|
||||
},
|
||||
{
|
||||
operator: "public:true",
|
||||
labelKey: "search_operator_public",
|
||||
icon: "bi-globe2",
|
||||
},
|
||||
{
|
||||
operator: "description:",
|
||||
labelKey: "search_operator_description",
|
||||
icon: "bi-card-text",
|
||||
},
|
||||
{
|
||||
operator: "type:",
|
||||
labelKey: "search_operator_type",
|
||||
icon: "bi-file-earmark",
|
||||
},
|
||||
{
|
||||
operator: "pinned:true",
|
||||
labelKey: "search_operator_pinned",
|
||||
icon: "bi-pin-angle",
|
||||
},
|
||||
{
|
||||
operator: "!",
|
||||
labelKey: "search_operator_exclude",
|
||||
icon: "bi-slash-circle",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function SearchBar({ placeholder }: Props) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { data: user } = useUser();
|
||||
|
||||
const [dismissSearchNote, setDismissSearchNote] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
router.query.q
|
||||
@@ -18,6 +84,15 @@ export default function SearchBar({ placeholder }: Props) {
|
||||
: setSearchQuery("");
|
||||
}, [router.query.q]);
|
||||
|
||||
const handleSuggestionClick = (operator: string) => {
|
||||
setSearchQuery((prev) => {
|
||||
const needsSpace = prev.length > 0 && !prev.endsWith(" ");
|
||||
return `${prev}${needsSpace ? " " : ""}${operator}`;
|
||||
});
|
||||
setShowSuggestions(false);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center relative group">
|
||||
<label
|
||||
@@ -30,8 +105,15 @@ export default function SearchBar({ placeholder }: Props) {
|
||||
<input
|
||||
id="search-box"
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={placeholder || t("search_for_links")}
|
||||
value={searchQuery}
|
||||
onFocus={() => {
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setShowSuggestions(false);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
e.target.value.includes("%") &&
|
||||
toast.error(t("search_query_invalid_symbol"));
|
||||
@@ -57,9 +139,75 @@ export default function SearchBar({ placeholder }: Props) {
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{ transition: "width 0.2s ease-in-out" }}
|
||||
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:focus:w-80 md:w-[15rem] md:max-w-full outline-none"
|
||||
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-80 md:max-w-full outline-none"
|
||||
/>
|
||||
|
||||
{showSuggestions && (
|
||||
<div className="absolute left-0 top-full mt-2 w-full z-50">
|
||||
<div
|
||||
className="border border-neutral-content bg-base-200 shadow-md rounded-md px-2 py-1 flex flex-col gap-1"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold text-neutral">
|
||||
{t("search_operators")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{ADVANCED_SEARCH_OPERATORS.map((entry) => (
|
||||
<button
|
||||
key={entry.operator}
|
||||
type="button"
|
||||
className="flex items-center gap-2 justify-between rounded-md px-2 py-1 text-left hover:bg-neutral-content duration-100"
|
||||
onClick={() => handleSuggestionClick(entry.operator)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<i className={`${entry.icon} text-primary text-sm`} />
|
||||
<span className="text-xs text-neutral">
|
||||
{t(entry.labelKey)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs px-1 rounded-md bg-base-100 border border-neutral-content text-base-content">
|
||||
{entry.operator}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="ghost" size="sm" className="text-xs">
|
||||
<Link
|
||||
href="https://docs.linkwarden.app/Usage/advanced-search"
|
||||
target="_blank"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{t("learn_more")}
|
||||
<i className="bi-box-arrow-up-right text-xs" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* {user?.hasUnIndexedLinks && !dismissSearchNote ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="border border-neutral p-2 my-1 rounded flex flex-col gap-2"
|
||||
>
|
||||
<p className="text-xs text-neutral">
|
||||
<i className="bi-info-circle text-primary mr-1" />
|
||||
<b>{t("note")}:</b> {t("search_unindexed_links_in_bg_info")}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setDismissSearchNote(true)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined} */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@ import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
import { useConfig } from "@linkwarden/router/config";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const LINKWARDEN_VERSION = process.env.version;
|
||||
|
||||
const { data: user } = useUser();
|
||||
const { data: config } = useConfig();
|
||||
const isAdmin = user?.id === (config?.ADMIN || 1);
|
||||
|
||||
const router = useRouter();
|
||||
const [active, setActive] = useState("");
|
||||
@@ -22,21 +20,46 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-base-100 h-full w-64 overflow-y-auto border-solid border border-base-100 border-r-neutral-content p-5 z-20 flex flex-col gap-5 justify-between ${
|
||||
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
|
||||
className || ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{user?.theme === "light" ? (
|
||||
<Image
|
||||
src={"/linkwarden_light.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={"/linkwarden_dark.png"}
|
||||
width={640}
|
||||
height={136}
|
||||
alt="Linkwarden"
|
||||
className="h-9 w-auto cursor-pointer"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/settings/account">
|
||||
<div
|
||||
className={`${
|
||||
active === "/settings/account"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-person text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("account")}</p>
|
||||
<i className="bi-person text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("account")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -46,10 +69,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/preference"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-sliders text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("preference")}</p>
|
||||
<i className="bi-sliders text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("preference")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -59,10 +84,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/rss-subscriptions"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-rss text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">RSS Subscriptions</p>
|
||||
<i className="bi-rss text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
RSS Subscriptions
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -72,10 +99,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/access-tokens"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-key text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("access_tokens")}</p>
|
||||
<i className="bi-key text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("access_tokens")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -85,28 +114,15 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/password"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-lock text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("password")}</p>
|
||||
<i className="bi-lock text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("password")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{isAdmin && (
|
||||
<Link href="/settings/worker">
|
||||
<div
|
||||
className={`${
|
||||
active === "/settings/worker"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
>
|
||||
<i className="bi-gear-wide-connected text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("worker")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{process.env.NEXT_PUBLIC_STRIPE && !user?.parentSubscriptionId && (
|
||||
<Link href="/settings/billing">
|
||||
<div
|
||||
@@ -114,10 +130,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
active === "/settings/billing"
|
||||
? "bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-credit-card text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("billing")}</p>
|
||||
<i className="bi-credit-card text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("billing")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
@@ -133,34 +151,40 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
</Link>
|
||||
<Link href="https://docs.linkwarden.app" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-question-circle text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("help")}</p>
|
||||
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-github text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("github")}</p>
|
||||
<i className="bi-github text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("github")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-twitter-x text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("twitter")}</p>
|
||||
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("twitter")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
|
||||
<div
|
||||
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
|
||||
>
|
||||
<i className="bi-mastodon text-primary text-xl"></i>
|
||||
<p className="truncate w-full pr-7">{t("mastodon")}</p>
|
||||
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
|
||||
<p className="truncate w-full font-semibold text-sm">
|
||||
{t("mastodon")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import { Sort } from "@linkwarden/types";
|
||||
import { Sort } from "@linkwarden/types/global";
|
||||
import { TFunction } from "i18next";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { resetInfiniteQueryPagination } from "@linkwarden/router/links";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "./ui/button";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types";
|
||||
import { TagIncludingLinkCount } from "@linkwarden/types/global";
|
||||
import DeleteTagModal from "./ModalContent/DeleteTagModal";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -2,14 +2,15 @@ import { Tag } from "@linkwarden/prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Droppable from "./Droppable";
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import { useDndContext } from "@dnd-kit/core";
|
||||
|
||||
interface TagListingProps {
|
||||
tags: Tag[];
|
||||
active?: string;
|
||||
}
|
||||
export function TagListing({ tags, active }: TagListingProps) {
|
||||
|
||||
export default function TagListing({ tags, active }: TagListingProps) {
|
||||
const { active: droppableActive } = useDndContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@linkwarden/lib";
|
||||
import { cn } from "@linkwarden/lib/utils";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export type TextInputProps = React.ComponentPropsWithoutRef<"input">;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import { ViewMode } from "@linkwarden/types";
|
||||
import { ViewMode } from "@linkwarden/types/global";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect } from "react";
|
||||
@@ -72,7 +72,7 @@ export default function ViewDropdown({
|
||||
{!dashboard && (
|
||||
<>
|
||||
<div className="px-1">
|
||||
<p className="text-sm text-neutral mb-1">{t("view")}</p>
|
||||
<p className="text-xs font-bold text-neutral mb-1">{t("view")}</p>
|
||||
<div className="flex gap-1 border-border">
|
||||
{[ViewMode.Card, ViewMode.Masonry, ViewMode.List].map(
|
||||
(mode) => {
|
||||
@@ -112,7 +112,7 @@ export default function ViewDropdown({
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-neutral px-1 mb-1">{t("show")}</p>
|
||||
<p className="text-xs font-bold text-neutral px-1 mb-1">{t("show")}</p>
|
||||
{visibleShows.map((key) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
@@ -131,7 +131,7 @@ export default function ViewDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-1">
|
||||
<p className="text-sm text-neutral mb-1">
|
||||
<p className="text-xs font-bold text-neutral mb-1">
|
||||
{t("columns")}:{" "}
|
||||
{settings.columns === 0 ? t("default") : settings.columns}
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@linkwarden/types/inputSelect";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { isArchivalTag } from "@linkwarden/lib";
|
||||
import { isArchivalTag } from "@linkwarden/lib/isArchivalTag";
|
||||
|
||||
const useArchivalTags = (initialTags: Tag[]) => {
|
||||
const [archivalTags, setArchivalTags] = useState<ArchivalTagOption[]>([]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Member } from "@linkwarden/types";
|
||||
import { Member } from "@linkwarden/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Member } from "@linkwarden/types";
|
||||
import { Member } from "@linkwarden/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCollections } from "@linkwarden/router/collections";
|
||||
import { useUser } from "@linkwarden/router/user";
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
CollectionIncludingMembersAndLinkCount,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
Sort,
|
||||
} from "@linkwarden/types";
|
||||
} from "@linkwarden/types/global";
|
||||
import { SetStateAction, useEffect } from "react";
|
||||
|
||||
type Props<
|
||||
|
||||
38
apps/web/layouts/AdminLayout.tsx
Normal file
38
apps/web/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import AdminSidebar from "@/components/AdminSidebar";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import React, { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex" data-testid="admin-wrapper">
|
||||
<div className="hidden lg:block">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
<div className="lg:w-[calc(100%-320px)] w-full sm:pb-0 pb-20 flex flex-col h-screen overflow-y-auto">
|
||||
<Navbar admin />
|
||||
<div className="p-5 mx-auto w-full max-w-7xl">
|
||||
<div className="gap-2 mb-3">
|
||||
<Button asChild variant="ghost" size="sm" className="text-neutral">
|
||||
<Link href="/dashboard">
|
||||
<i className="bi-chevron-left text-md" />
|
||||
<p>{t("back_to_dashboard")}</p>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import Announcement from "@/components/Announcement";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import getLatestVersion from "@/lib/client/getLatestVersion";
|
||||
import DragNDrop from "@/components/DragNDrop";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -40,29 +42,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>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user