Compare commits

..

4 Commits

Author SHA1 Message Date
daniel31x13
4e14149dfe Accepted incoming changes 2024-11-12 10:09:02 -05:00
daniel31x13
dbd096ab76 Revert "undo commit"
This reverts commit 9103f67db5.
2024-11-03 03:27:52 -05:00
daniel31x13
e37702aa14 Merge branch 'main' of https://github.com/linkwarden/linkwarden 2024-11-03 03:25:06 -05:00
daniel31x13
9103f67db5 undo commit 2024-11-03 03:25:01 -05:00
712 changed files with 22329 additions and 61467 deletions

View File

@@ -5,11 +5,3 @@ pgdata
docker-compose.yml
Dockerfile
README.md
.yarn/install-state.gz
./apps/mobile
**/.next/cache
**/.next/cache/**
data
data.ms
.git
meili_data

View File

@@ -15,6 +15,7 @@ AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT=
MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT=
@@ -26,55 +27,15 @@ NEXT_PUBLIC_DEMO_USERNAME=
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_ADMIN=
NEXT_PUBLIC_MAX_FILE_BUFFER=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
PDF_MAX_BUFFER=
SCREENSHOT_MAX_BUFFER=
READABILITY_MAX_BUFFER=
PREVIEW_MAX_BUFFER=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
IMPORT_LIMIT=
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
PLAYWRIGHT_WS_URL=
MAX_WORKERS=
DISABLE_PRESERVATION=
NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES=
RSS_SUBSCRIPTION_LIMIT_PER_USER=
TEXT_CONTENT_LIMIT=
SEARCH_FILTER_LIMIT=
INDEX_TAKE_COUNT=
MEILI_TIMEOUT=
# AI Settings
NEXT_PUBLIC_OLLAMA_ENDPOINT_URL=
OLLAMA_MODEL=
# https://ai-sdk.dev/providers/openai-compatible-providers
OPENAI_API_KEY=
OPENAI_MODEL=
# Optional: Set a custom OpenAI base URL and name (for third-party providers)
CUSTOM_OPENAI_BASE_URL=
CUSTOM_OPENAI_NAME=
# https://sdk.vercel.ai/providers/ai-sdk-providers/azure
AZURE_API_KEY=
AZURE_RESOURCE_NAME=
AZURE_MODEL=
# https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=
# https://github.com/OpenRouterTeam/ai-sdk-provider
OPENROUTER_API_KEY=
OPENROUTER_MODEL=
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
PERPLEXITY_API_KEY=
PERPLEXITY_MODEL=
# MeiliSearch Settings
MEILI_HOST=
MEILI_MASTER_KEY=
# AWS S3 Settings
SPACES_KEY=
@@ -131,10 +92,10 @@ AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=
AUTHELIA_CLIENT_ID=
AUTHELIA_CLIENT_SECRET=
AUTHELIA_WELLKNOWN_URL=
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
# Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED=
@@ -258,7 +219,6 @@ NEXT_PUBLIC_GITLAB_ENABLED=
GITLAB_CUSTOM_NAME=
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
GITLAB_AUTH_URL=
# Google
NEXT_PUBLIC_GOOGLE_ENABLED=
@@ -403,13 +363,6 @@ STRAVA_CUSTOM_NAME=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
# Synology
NEXT_PUBLIC_SYNOLOGY_ENABLED=
SYNOLOGY_CUSTOM_NAME=
SYNOLOGY_CLIENT_ID=
SYNOLOGY_CLIENT_SECRET=
SYNOLOGY_WELLKNOWN_URL=
# Todoist
NEXT_PUBLIC_TODOIST_ENABLED=
TODOIST_CUSTOM_NAME=

14
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,13 @@
# 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
github: daniel31x13
buy_me_a_coffee: daniel31x13
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']

View File

@@ -1,13 +0,0 @@
name: Installation Problem
title: Installation Problem
description: Report an issue with installation
labels: installation
body:
- type: textarea
id: feature-description
validations:
required: true
attributes:
label: For installation issues, please visit discord.linkwarden.app
description: "Invite link: https://discord.com/invite/CtuYV47nuJ"
placeholder: Please do not submit installation issues on GitHub.

View File

@@ -1,46 +0,0 @@
## 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

View File

@@ -1,18 +0,0 @@
name: Check pull request source branch
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- edited
jobs:
check-branches:
runs-on: ubuntu-latest
steps:
- name: Check branches
run: |
if [ ${{ github.head_ref }} != "dev" ] && [ ${{ github.base_ref }} == "main" ]; then
echo "Merge requests to main branch are only allowed from dev branch. Please rebase your changes to dev branch."
exit 1
fi

View File

@@ -1,42 +0,0 @@
name: Manage i18n pull requests
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
contents: write
pull-requests: write
jobs:
rewrite-author:
if: github.event.pull_request.head.ref == 'i18n'
runs-on: ubuntu-latest
steps:
- name: Checkout i18n branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Skip if already rewritten
run: |
if [ "$(git show -s --format='%an')" = 'LinkwardenBot' ]; then
echo "Already rewritten skipping."
exit 0
fi
- name: Configure bot identity
run: |
git config user.name "LinkwardenBot"
git config user.email "bot@linkwarden.app"
- name: Amend just the PR commits
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
git rebase --committer-date-is-author-date --exec 'git commit --amend --no-edit --allow-empty --author="LinkwardenBot <bot@linkwarden.app>"' "$BASE_SHA"
- name: Push rewritten history
run: git push --force-with-lease origin HEAD:i18n

View File

@@ -59,20 +59,14 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Use Node.js and Enable Yarn 4
uses: actions/setup-node@v4
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
node-version: "18"
cache: 'yarn'
- 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"
@@ -80,7 +74,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 --immutable
run: yarn install -y
- name: Cache playwright dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
@@ -125,19 +119,23 @@ jobs:
restore-keys: |
${{ runner.os }}-playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- name: Setup project
run: |
yarn prisma:generate
yarn web:build
yarn prisma:deploy
yarn prisma generate
yarn build
yarn prisma migrate deploy
- name: Start linkwarden server and worker
run: yarn concurrently:start &
run: yarn start &
- name: Run Tests
run: yarn workspace @linkwarden/web playwright test --grep ${{ matrix.test_case }}
run: npx playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report

View File

@@ -1,7 +1,6 @@
name: Create and publish a container image on release
on:
workflow_dispatch:
push:
tags:
- "*"
@@ -28,7 +27,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -41,7 +40,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v3
with:
context: .
push: true

37
.gitignore vendored
View File

@@ -1,14 +1,13 @@
# dependencies
node_modules
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
.next
/.next/
/out/
# production
@@ -35,25 +34,23 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# generated files and folders
/data
.idea
prisma/dev.db
# tests
/apps/web/tests
/apps/web/test-results/
/apps/web/blob-report/
/apps/web/playwright-report/
/apps/web/playwright/.cache/
/apps/web/playwright/.auth/
/tests
/test-results/
/blob-report/
/playwright-report/
/playwright/.cache/
/playwright/.auth/
# docker
pgdata
certificates
# generated files and folders
/data
/data.ms
meilisearch
meili_data
.idea
prisma/dev.db
.turbo
service-account-file.json
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

45
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,45 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).

View File

@@ -1,22 +1,4 @@
# Stage: monolith-builder
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.86-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# Purpose: Compiles the frontend and
# Notes:
# - Nothing extra should be left here. All commands should cleanup
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
FROM node:18.18-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
@@ -24,47 +6,35 @@ RUN mkdir /data
WORKDIR /data
RUN corepack enable
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
COPY ./.yarnrc.yml ./
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000
COPY ./apps/web/package.json ./apps/web/playwright.config.ts ./apps/web/
RUN apt-get update
COPY ./apps/worker/package.json ./apps/worker/
RUN apt-get install -y \
build-essential \
curl \
libssl-dev \
pkg-config
COPY ./packages ./packages
RUN apt-get update
COPY ./yarn.lock ./package.json ./
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
set -eux && \
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 && \
apt-get autoremove && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.cargo/bin:${PATH}"
# Copy the compiled monolith binary from the builder stage
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN cargo install monolith
RUN set -eux && \
RUN npx playwright install-deps && \
apt-get clean && \
yarn cache clean
RUN yarn playwright install
COPY . .
RUN yarn prisma:generate && \
yarn web:build && \
rm -rf apps/web/.next/cache
RUN yarn prisma generate && \
yarn build
HEALTHCHECK --interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD [ "/usr/bin/curl", "--silent", "--fail", "http://127.0.0.1:3000/" ]
EXPOSE 3000
CMD ["sh", "-c", "yarn prisma:deploy && yarn concurrently:start"]
CMD yarn prisma migrate deploy && yarn start

139
README.md
View File

@@ -1,17 +1,10 @@
<div align="center">
<img src="./assets/logo.png" width="100px" />
<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>
<h3>Bookmark Preservation for Individuals and Teams</h3>
<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=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">
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></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>
</div>
@@ -19,70 +12,74 @@
[« LAUNCH DEMO »](https://demo.linkwarden.app)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features) · [Docs](https://docs.linkwarden.app)
<img src="./assets/home.png" />
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features)
</div>
## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, read, annotate, and fully preserve what matters, all in one place.**
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.**
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://en.wikipedia.org/wiki/Link_rot)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
In addition to preservation, Linkwarden provides a user-friendly reading and annotation experience that blends the simplicity of a “read-it-later” tool with the reliability of a web archive. Whether youre highlighting key ideas, jotting down thoughts, or revisiting content long after its disappeared from the web, Linkwarden keeps your knowledge accessible and organized.
Linkwarden is also designed with collaboration in mind, enabling you to share links with the public and/or collaborate seamlessly with multiple users.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
> [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation).
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.jpg" width="23%" />
<img src="./assets/list_view.jpg" width="23%" />
<img src="./assets/all_collections.jpg" width="23%" />
<img src="./assets/manage_team.jpg" width="23%" />
<img src="./assets/readable_view.jpg" width="23%" />
<img src="./assets/preserved_formats.jpg" width="23%" />
<img src="./assets/public_page.jpg" width="23%" />
<img src="./assets/light_dashboard.jpg" width="23%" />
</div>
<details>
<summary><b>A bit of a "history"</b></summary>
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
**What happened to the old version?**
We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
</details>
## 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
- 📱 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)!)
- ⬆️ 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 youll 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 youll need to deploy and maintain a Linkwarden instance on a server.
After creating an account, download the app from your preferred store:
[![Download on the App Store](./assets/app_store.png)](https://apps.apple.com/app/linkwarden/id6752550960)
[![Get it on Google Play](./assets/google_play.png)](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.)
- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (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, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- ✨ And many more features. (Literally!)
## Like what we're doing? Give us a Star ⭐
@@ -100,33 +97,19 @@ Join and follow us in the following platforms to stay up to date about the most
## Suggestions
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
## Roadmap
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Community Projects
## Documentation
Here are some community-maintained projects that are built around Linkwarden:
- [My Links](https://apps.apple.com/ca/app/my-links-for-linkwarden/id6504573402) - iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- [LinkDroid](https://fossdroid.com/a/linkdroid-for-linkwarden.html) - Android App with share sheet integration, [source code](https://github.com/Dacid99/LinkDroid-for-Linkwarden).
- [LinkGuardian](https://github.com/Elbullazul/LinkGuardian) - An Android client for Linkwarden. Built with Kotlin and Jetpack compose.
- [StarWarden](https://github.com/rtuszik/starwarden) - A browser extension to save your starred GitHub repositories to Linkwarden.
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
## Development
If you want to contribute, Thanks! Start by choosing one of our [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue%20is%3Aopen%20sort%3Areactions-%2B1-desc), just please stay in touch with [@daniel31x13](https://github.com/daniel31x13) before starting.
# Translations
If you want to help us translate Linkwarden to your language, please check out our [Crowdin page](https://crowdin.com/project/linkwarden) and start translating. We would love to have your help!
To start translating a new language, please create an issue so we can set it up for you. New languages will be added once they reach at least 50% translation completion.
<a href="https://crowdin.com/project/linkwarden">
<img src="https://badges.crowdin.net/linkwarden/localized.svg" alt="Crowdin" /></a>
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute and the main tech stack.
## Security

View File

@@ -1,42 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
.env
ios/
android/

View File

@@ -1,2 +0,0 @@
LINKWARDEN_URL=
EXPO_PUBLIC_SHOW_LOGS=

View File

@@ -1,46 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
.env
ios/
android/
service-account-file.json
.env.local

View File

@@ -1,108 +0,0 @@
{
"expo": {
"name": "Linkwarden",
"slug": "linkwarden",
"version": "1.1.1",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "linkwarden",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "app.linkwarden",
"entitlements": {
"com.apple.security.application-groups": ["group.app.linkwarden"]
},
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/maskable_logo.jpeg",
"backgroundColor": "#ffffff"
},
"package": "app.linkwarden"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#FFFFFF",
"dark": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#171717"
}
}
],
"expo-secure-store",
[
"expo-share-intent",
{
"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/*"]
}
],
[
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true,
"extraProguardRules": "-keep public class com.horcrux.svg.** {*;}",
"allowBackup": false,
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"usesCleartextTraffic": true
}
}
],
[
"react-native-edge-to-edge",
{
"android": {
"parentTheme": "Default",
"enforceNavigationBarContrast": false
}
}
],
"./plugins/with-daynight-transparent-nav"
],
"experiments": {
"typedRoutes": true
},
"androidStatusBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content",
"translucent": false
},
"androidNavigationBar": {
"backgroundColor": "#ffffff",
"barStyle": "dark-content"
},
"extra": {
"router": {
"origin": false
},
"eas": {
"projectId": "34f82639-7a25-4ebe-81c8-2db521b612cf"
}
},
"owner": "linkwarden"
}
}

View File

@@ -1,81 +0,0 @@
import { Tabs } from "expo-router";
import React from "react";
import { Platform } from "react-native";
import HapticTab from "@/components/HapticTab";
import TabBarBackground from "@/components/ui/TabBarBackground";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Folder, Hash, House, Link, Settings } from "lucide-react-native";
export default function TabLayout() {
const { colorScheme } = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarBackground: TabBarBackground,
tabBarActiveTintColor: rawTheme[colorScheme as ThemeName].primary,
tabBarInactiveTintColor: rawTheme[colorScheme as ThemeName].neutral,
tabBarButton: HapticTab,
tabBarStyle: Platform.select({
ios: {
position: "absolute",
borderTopWidth: 0,
elevation: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
paddingLeft: 5,
paddingRight: 5,
},
default: {
borderTopWidth: 0,
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
elevation: 0,
paddingLeft: 5,
paddingRight: 5,
},
}),
}}
>
<Tabs.Screen
name="dashboard"
options={{
title: "Dashboard",
headerShown: false,
tabBarIcon: ({ color }) => <House size={24} color={color} />,
}}
/>
<Tabs.Screen
name="links"
options={{
title: "Links",
headerShown: false,
tabBarIcon: ({ color }) => <Link size={24} color={color} />,
}}
/>
<Tabs.Screen
name="collections"
options={{
title: "Collections",
headerShown: false,
tabBarIcon: ({ color }) => <Folder size={24} color={color} />,
}}
/>
<Tabs.Screen
name="tags"
options={{
title: "Tags",
headerShown: false,
tabBarIcon: ({ color }) => <Hash size={24} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
headerShown: false,
tabBarIcon: ({ color }) => <Settings size={24} color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -1,62 +0,0 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import { useCollections } from "@linkwarden/router/collections";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, id } = useLocalSearchParams<{
search?: string;
id: string;
}>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
collectionId: Number(id),
},
auth
);
const collections = useCollections(auth);
const navigation = useNavigation();
useEffect(() => {
const activeCollection = collections.data?.filter(
(e) => e.id === Number(id)
)[0];
if (activeCollection?.name)
navigation?.setOptions?.({
headerTitle: activeCollection?.name,
headerSearchBarOptions: {
placeholder: `Search ${activeCollection.name}`,
},
});
}, [navigation]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<Links links={links} data={data} />
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,44 +0,0 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Collections",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Collections",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -1,94 +0,0 @@
import {
View,
StyleSheet,
FlatList,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import useAuthStore from "@/store/auth";
import CollectionListing from "@/components/CollectionListing";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
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/global";
export default function CollectionsScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const collections = useCollections(auth);
const [filteredCollections, setFilteredCollections] = useState<
CollectionIncludingMembersAndLinkCount[]
>([]);
useEffect(() => {
const filter =
collections.data?.filter((e) =>
e.name.includes(decodeURIComponent(search || ""))
) || [];
setFilteredCollections(filter);
}, [search, collections.data]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
{collections.isLoading ? (
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={filteredCollections}
refreshControl={
<Spinner
refreshing={collections.isRefetching}
onRefresh={() => collections.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={collections.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => <CollectionListing collection={item} />}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,72 +0,0 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect, useMemo } from "react";
import { useCollections } from "@linkwarden/router/collections";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, section, collectionId } = useLocalSearchParams<{
search?: string;
section?: "pinned-links" | "recent-links" | "collection";
collectionId?: string;
}>();
const navigation = useNavigation();
const collections = useCollections(auth);
const title = useMemo(() => {
if (section === "pinned-links") return "Pinned Links";
if (section === "recent-links") return "Recent Links";
if (section === "collection") {
return (
collections.data?.find((c) => c.id?.toString() === collectionId)
?.name || "Collection"
);
}
return "Links";
}, [section, collections.data, collectionId]);
useEffect(() => {
navigation.setOptions({
headerTitle: title,
headerSearchBarOptions: {
placeholder: `Search ${title}`,
},
});
}, [title, navigation]);
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
collectionId: Number(collectionId),
pinnedOnly: section === "pinned-links",
},
auth
);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<Links links={links} data={data} />
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,89 +0,0 @@
import { rawTheme, ThemeName } from "@/lib/colors";
import { Stack, useRouter } from "expo-router";
import { Plus } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { Platform, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import * as DropdownMenu from "zeego/dropdown-menu";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
>
<Stack.Screen
name="index"
options={{
headerTitle: "Dashboard",
headerRight: () => (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<Plus
size={21}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item
key="new-link"
onSelect={() => SheetManager.show("add-link-sheet")}
>
<DropdownMenu.ItemTitle>New Link</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="new-collection"
onSelect={() => SheetManager.show("new-collection-sheet")}
>
<DropdownMenu.ItemTitle>
New Collection
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/>
<Stack.Screen
name="[section]"
options={{
headerTitle: "Links",
headerBackTitle: "Back",
headerSearchBarOptions: {
placeholder: "Search",
autoCapitalize: "none",
headerIconColor: colorScheme === "dark" ? "white" : "black",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
},
}}
/>
</Stack>
);
}

View File

@@ -1,147 +0,0 @@
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";
import { DashboardSection as DashboardSectionType } from "@linkwarden/prisma/client";
import { useUser } from "@linkwarden/router/user";
import { useCollections } from "@linkwarden/router/collections";
import { useTags } from "@linkwarden/router/tags";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import Spinner from "@/components/ui/Spinner";
import DashboardSection from "@/components/DashboardSection";
export default function DashboardScreen() {
const { auth } = useAuthStore();
const {
data: { links = [], numberOfPinnedLinks, collectionLinks = {} } = {
links: [],
},
...dashboardData
} = useDashboardData(auth);
const { data: user, ...userData } = useUser(auth);
const { data: collections = [], ...collectionsData } = useCollections(auth);
const { data: tags = [], ...tagsData } = useTags(auth);
const { colorScheme } = useColorScheme();
const [dashboardSections, setDashboardSections] = useState<
DashboardSectionType[]
>(user?.dashboardSections || []);
const [numberOfLinks, setNumberOfLinks] = useState(0);
useEffect(() => {
setDashboardSections(user?.dashboardSections || []);
}, [user?.dashboardSections]);
useEffect(() => {
setNumberOfLinks(
collections?.reduce?.(
(accumulator, collection) =>
accumulator + (collection._count as any).links,
0
)
);
}, [collections]);
const orderedSections = useMemo(() => {
return [...dashboardSections].sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
}, [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={pullRefreshing}
onRefresh={onRefresh}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
contentContainerStyle={styles.container}
className="bg-base-100"
contentInsetAdjustmentBehavior="automatic"
>
{orderedSections.map((sectionData, i) => {
if (!collections || !collections[0]) return null;
const collection = collections.find(
(c) => c.id === sectionData.collectionId
);
return (
<DashboardSection
key={sectionData.id}
sectionData={sectionData}
collection={collection}
collectionLinks={
sectionData.collectionId
? collectionLinks[sectionData.collectionId]
: []
}
links={links}
tagsLength={tags.length}
numberOfLinks={numberOfLinks}
collectionsLength={collections.length}
numberOfPinnedLinks={numberOfPinnedLinks}
dashboardData={dashboardData}
/>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 49,
flexDirection: "column",
gap: 15,
paddingVertical: 20,
},
default: {
flexDirection: "column",
gap: 15,
paddingVertical: 20,
},
}),
});

View File

@@ -1,44 +0,0 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Links",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Links",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -1,39 +0,0 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams } from "expo-router";
import React from "react";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
},
auth
);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<Links links={links} data={data} />
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,43 +0,0 @@
import { Stack } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Settings",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerBackTitle: "Back",
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen
name="preferredCollection"
options={{
headerTitle: "Preferred Collection",
headerLargeTitle: false,
}}
/>
</Stack>
);
}

View File

@@ -1,273 +0,0 @@
import useAuthStore from "@/store/auth";
import { useUser } from "@linkwarden/router/user";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Platform,
Alert,
} from "react-native";
import { nativeApplicationVersion } from "expo-application";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useEffect, useState } from "react";
import {
AppWindowMac,
Check,
ChevronRight,
ExternalLink,
Folder,
LogOut,
Mail,
Moon,
Smartphone,
Sun,
} 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();
const { data: user } = useUser(auth);
const { colorScheme, setColorScheme } = useColorScheme();
const { data, updateData } = useDataStore();
const [override, setOverride] = useState<"light" | "dark" | "system">(
data.theme || "system"
);
useEffect(() => {
setColorScheme(override);
updateData({ theme: override });
}, [override]);
const router = useRouter();
return (
<View
style={styles.container}
collapsable={false}
collapsableChildren={false}
className="bg-base-100"
>
<ScrollView
contentContainerStyle={{
padding: 20,
}}
contentContainerClassName="flex-col gap-6"
contentInsetAdjustmentBehavior="automatic"
>
<View className="bg-base-200 rounded-xl px-4 py-3">
<Text className="font-semibold text-xl text-base-content">
Your account
</Text>
<Text className="text-neutral mt-2 mb-3">
{user?.email || "@" + user?.username}
</Text>
<View className="h-px bg-neutral-content -mr-4" />
<TouchableOpacity
className="flex-row items-center mt-3"
onPress={() =>
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Sign Out",
style: "destructive",
onPress: () => {
signOut();
},
},
])
}
>
<Text className="flex-1 text-base text-red-500">Sign Out</Text>
<LogOut size={18} color="red" />
</TouchableOpacity>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Theme</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={() => setOverride("system")}
>
<View className="flex-row items-center gap-2">
<Smartphone
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-neutral">System Defaults</Text>
</View>
{override === "system" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("light")}
>
<View className="flex-row items-center gap-2">
<Sun size={20} color="orange" />
<Text className="text-orange-500 dark:text-orange-400">
Light
</Text>
</View>
{override === "light" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() => setOverride("dark")}
>
<View className="flex-row items-center gap-2">
<Moon size={20} color="royalblue" />
<Text className="text-blue-600 dark:text-blue-400">Dark</Text>
</View>
{override === "dark" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
</View>
</View>
<View>
<Text className="mb-4 mx-4 text-neutral">Preferred Browser</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={() =>
updateData({
preferredBrowser: "app",
})
}
>
<View className="flex-row items-center gap-2">
<AppWindowMac
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">In app browser</Text>
</View>
{data.preferredBrowser === "app" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
<View className="h-px bg-neutral-content ml-12" />
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={() =>
updateData({
preferredBrowser: "system",
})
}
>
<View className="flex-row items-center gap-2">
<ExternalLink
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">
System default browser
</Text>
</View>
{data.preferredBrowser === "system" ? (
<Check
size={20}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
</TouchableOpacity>
</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">
<TouchableOpacity
className="flex-row gap-2 items-center justify-between py-3 px-4"
onPress={async () => {
await Clipboard.setStringAsync("support@linkwarden.app");
Alert.alert("Copied to clipboard", "support@linkwarden.app");
}}
>
<View className="flex-row items-center gap-2">
<Mail
size={20}
color={rawTheme[colorScheme as ThemeName].neutral}
/>
<Text className="text-base-content">
support@linkwarden.app
</Text>
</View>
</TouchableOpacity>
</View>
</View>
<Text className="mx-auto text-sm text-neutral">
Linkwarden for {Platform.OS === "ios" ? "iOS" : "Android"}{" "}
{nativeApplicationVersion}
</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
flex: 1,
paddingBottom: 83,
},
default: {
flex: 1,
},
}),
});

View File

@@ -1,99 +0,0 @@
import { View, Text, FlatList, TouchableOpacity } from "react-native";
import React, { useCallback, useMemo, useState } from "react";
import useAuthStore from "@/store/auth";
import useDataStore from "@/store/data";
import { useCollections } from "@linkwarden/router/collections";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import Input from "@/components/ui/Input";
import { Folder, Check } from "lucide-react-native";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
const PreferredCollectionScreen = () => {
const { auth } = useAuthStore();
const { data, updateData } = useDataStore();
const collections = useCollections(auth);
const { colorScheme } = useColorScheme();
const [searchQuery, setSearchQuery] = useState("");
const filteredCollections = useMemo(() => {
if (!collections.data) return [];
const q = searchQuery.trim().toLowerCase();
if (q === "") return collections.data;
return collections.data.filter((col) => col.name.toLowerCase().includes(q));
}, [collections.data, searchQuery]);
const renderCollection = useCallback(
({
item: collection,
}: {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const isSelected = data.preferredCollection?.id === collection.id;
return (
<TouchableOpacity
className="bg-base-200 rounded-lg px-4 py-3 mb-3 flex-row items-center justify-between"
onPress={() => updateData({ preferredCollection: collection })}
>
<View className="flex-row items-center gap-2 w-[70%]">
<Folder
size={20}
fill={collection.color || "gray"}
color={collection.color || "gray"}
/>
<Text numberOfLines={1} className="text-base-content">
{collection.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{isSelected ? (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
) : null}
<Text className="text-neutral">
{collection._count?.links ?? 0}
</Text>
</View>
</TouchableOpacity>
);
},
[colorScheme, data.preferredCollection?.id, updateData]
);
return (
<View className="flex-1 bg-base-100">
<FlatList
data={filteredCollections}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={renderCollection}
contentContainerStyle={{
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 20,
}}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Input
placeholder="Search collections"
className="mb-4 bg-base-200 h-10"
value={searchQuery}
onChangeText={setSearchQuery}
/>
}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No collections match {searchQuery}
</Text>
}
/>
</View>
);
};
export default PreferredCollectionScreen;

View File

@@ -1,60 +0,0 @@
import { useLinks } from "@linkwarden/router/links";
import { View, StyleSheet, Platform } from "react-native";
import useAuthStore from "@/store/auth";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useEffect } from "react";
import { useTags } from "@linkwarden/router/tags";
import Links from "@/components/Links";
export default function LinksScreen() {
const { auth } = useAuthStore();
const { search, id } = useLocalSearchParams<{
search?: string;
id: string;
}>();
const { links, data } = useLinks(
{
sort: 0,
searchQueryString: decodeURIComponent(search ?? ""),
tagId: Number(id),
},
auth
);
const tags = useTags(auth);
const navigation = useNavigation();
useEffect(() => {
const activeTag = tags.data?.filter((e) => e.id === Number(id))[0];
if (activeTag?.name)
navigation?.setOptions?.({
headerTitle: activeTag?.name,
headerSearchBarOptions: {
placeholder: `Search ${activeTag.name}`,
},
});
}, [navigation]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
<Links links={links} data={data} />
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,44 +0,0 @@
import { Stack, useRouter } from "expo-router";
import { useColorScheme } from "nativewind";
import { rawTheme, ThemeName } from "@/lib/colors";
import { Platform } from "react-native";
export default function Layout() {
const router = useRouter();
const { colorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerTitle: "Tags",
headerLargeTitle: true,
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerBlurEffect:
colorScheme === "dark" ? "systemMaterialDark" : "systemMaterial",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerSearchBarOptions: {
placeholder: "Search Tags",
autoCapitalize: "none",
onChangeText: (e) => {
router.setParams({
search: encodeURIComponent(e.nativeEvent.text),
});
},
headerIconColor: colorScheme === "dark" ? "white" : "black",
},
headerLargeStyle: {
backgroundColor: rawTheme[colorScheme as ThemeName]["base-100"],
},
headerStyle: {
backgroundColor:
Platform.OS === "ios"
? "transparent"
: colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
}}
/>
);
}

View File

@@ -1,92 +0,0 @@
import {
View,
StyleSheet,
FlatList,
Platform,
Text,
ActivityIndicator,
} from "react-native";
import useAuthStore from "@/store/auth";
import TagListing from "@/components/TagListing";
import { useLocalSearchParams } from "expo-router";
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/global";
import { useTags } from "@linkwarden/router/tags";
export default function TagsScreen() {
const { colorScheme } = useColorScheme();
const { auth } = useAuthStore();
const { search } = useLocalSearchParams<{ search?: string }>();
const tags = useTags(auth);
const [filteredTags, setFilteredTags] = useState<TagIncludingLinkCount[]>([]);
useEffect(() => {
const filter =
tags.data?.filter((e) =>
e.name.includes(decodeURIComponent(search || ""))
) || [];
setFilteredTags(filter);
}, [search, tags.data]);
return (
<View
style={styles.container}
className="h-full bg-base-100"
collapsable={false}
collapsableChildren={false}
>
{tags.isLoading ? (
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={filteredTags}
refreshControl={
<Spinner
refreshing={tags.isRefetching}
onRefresh={() => tags.refetch()}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={tags.isRefetching}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => <TagListing tag={item} />}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: Platform.select({
ios: {
paddingBottom: 83,
},
default: {},
}),
});

View File

@@ -1,20 +0,0 @@
import { Link, Stack } from "expo-router";
import { View, StyleSheet } from "react-native";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Oops! This screen doesn't exist." }} />
<View style={styles.container}>
<Link href="/">Go to home screen</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});

View File

@@ -1,322 +0,0 @@
import {
router,
Stack,
usePathname,
useRootNavigationState,
useRouter,
} from "expo-router";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { mmkvPersister } from "@/lib/queryPersister";
import { useState, useEffect } from "react";
import "../styles/global.css";
import { SheetManager, SheetProvider } from "react-native-actions-sheet";
import "@/components/ActionSheets/Sheets";
import { useColorScheme } from "nativewind";
import { lightTheme, darkTheme } from "../lib/theme";
import {
Alert,
Linking,
Platform,
Share,
TouchableOpacity,
View,
} from "react-native";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useShareIntent } from "expo-share-intent";
import useDataStore from "@/store/data";
import useAuthStore from "@/store/auth";
import { KeyboardProvider } from "react-native-keyboard-controller";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Compass, Ellipsis } from "lucide-react-native";
import { Chromium } from "@/components/ui/Icons";
import useTmpStore from "@/store/tmp";
import {
LinkIncludingShortenedCollectionAndTags,
MobileAuth,
} from "@linkwarden/types/global";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { deleteLinkCache } from "@/lib/cache";
import { queryClient } from "@/lib/queryClient";
import getOriginalFormat from "@linkwarden/lib/getOriginalFormat";
import { StatusBar } from "expo-status-bar";
export default function RootLayout() {
const [isLoading, setIsLoading] = useState(true);
const { hasShareIntent, shareIntent, error, resetShareIntent } =
useShareIntent();
const { updateData, setData, data } = useDataStore();
const router = useRouter();
const pathname = usePathname();
const { auth, setAuth } = useAuthStore();
const rootNavState = useRootNavigationState();
useEffect(() => {
setAuth();
setData();
}, []);
useEffect(() => {
if (!rootNavState?.key) return;
if (hasShareIntent && shareIntent.webUrl) {
updateData({
shareIntent: {
hasShareIntent: true,
url: shareIntent.webUrl || "",
},
});
resetShareIntent();
}
const needsRewrite =
((typeof pathname === "string" && pathname.startsWith("/dataUrl=")) ||
hasShareIntent) &&
pathname !== "/incoming";
if (needsRewrite) {
router.replace("/incoming");
}
if (hasShareIntent) {
resetShareIntent();
router.replace("/incoming");
}
}, [
rootNavState?.key,
hasShareIntent,
pathname,
shareIntent?.webUrl,
data.shareIntent,
]);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: mmkvPersister,
maxAge: Infinity,
dehydrateOptions: {
shouldDehydrateMutation: () => true,
shouldDehydrateQuery: () => true,
},
}}
onSuccess={() => {
setIsLoading(false);
queryClient.invalidateQueries();
}}
>
<RootComponent isLoading={isLoading} auth={auth} />
</PersistQueryClientProvider>
);
}
const RootComponent = ({
isLoading,
auth,
}: {
isLoading: boolean;
auth: MobileAuth;
}) => {
const { colorScheme } = useColorScheme();
const updateLink = useUpdateLink({ auth, Alert });
const deleteLink = useDeleteLink({ auth, Alert });
const { tmp } = useTmpStore();
return (
<View
style={[{ flex: 1 }, colorScheme === "dark" ? darkTheme : lightTheme]}
>
<KeyboardProvider>
<SheetProvider>
<StatusBar
style={colorScheme === "dark" ? "light" : "dark"}
backgroundColor={rawTheme[colorScheme as ThemeName]["base-100"]}
/>
{!isLoading && (
<Stack
screenOptions={{
headerShown: false,
contentStyle: {
backgroundColor:
rawTheme[colorScheme as ThemeName]["base-100"],
},
}}
>
{/* <Stack.Screen name="(tabs)" /> */}
<Stack.Screen
name="links/[id]"
options={{
headerShown: true,
headerBackTitle: "Back",
headerTitle: "",
headerTintColor: colorScheme === "dark" ? "white" : "black",
headerStyle: {
backgroundColor:
colorScheme === "dark"
? rawTheme["dark"]["base-100"]
: "white",
},
headerRight: () => (
<View className="flex-row gap-5">
<TouchableOpacity
onPress={() => {
if (tmp.link) {
if (tmp.link.url) {
return Linking.openURL(tmp.link.url);
} else {
const format = getOriginalFormat(tmp.link);
return Linking.openURL(
format !== null
? auth.instance +
`/preserved/${tmp.link.id}?format=${format}`
: tmp.link.url || ""
);
}
}
}}
>
{Platform.OS === "ios" ? (
<Compass
size={21}
color={
rawTheme[colorScheme as ThemeName]["base-content"]
}
/>
) : (
<Chromium
stroke={
rawTheme[colorScheme as ThemeName]["base-content"]
}
/>
)}
</TouchableOpacity>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<Ellipsis
size={21}
color={
rawTheme[colorScheme as ThemeName][
"base-content"
]
}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{tmp.link?.url && (
<DropdownMenu.Item
key="share"
onSelect={async () => {
await Share.share({
...(Platform.OS === "android"
? { message: tmp.link?.url as string }
: { url: tmp.link?.url as string }),
});
}}
>
<DropdownMenu.ItemTitle>
Share
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && tmp.user && (
<DropdownMenu.Item
key="pin-link"
onSelect={() => {
const isAlreadyPinned =
tmp.link?.pinnedBy && tmp.link.pinnedBy[0]
? true
: false;
updateLink.mutateAsync({
...(tmp.link as LinkIncludingShortenedCollectionAndTags),
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: tmp.user?.id }]) as any,
});
}}
>
<DropdownMenu.ItemTitle>
{tmp.link.pinnedBy && tmp.link.pinnedBy[0]
? "Unpin Link"
: "Pin Link"}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && (
<DropdownMenu.Item
key="edit-link"
onSelect={() => {
SheetManager.show("edit-link-sheet", {
payload: {
link: tmp.link as LinkIncludingShortenedCollectionAndTags,
},
});
}}
>
<DropdownMenu.ItemTitle>
Edit Link
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
{tmp.link && (
<DropdownMenu.Item
key="delete-link"
onSelect={() => {
return Alert.alert(
"Delete Link",
"Are you sure you want to delete this link? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
deleteLink.mutate(
tmp.link?.id as number
);
await deleteLinkCache(
tmp.link?.id as number
);
router.back();
},
},
]
);
}}
>
<DropdownMenu.ItemTitle>
Delete
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
),
}}
/>
<Stack.Screen name="login" />
<Stack.Screen name="index" />
<Stack.Screen name="incoming" />
<Stack.Screen name="+not-found" />
</Stack>
)}
</SheetProvider>
</KeyboardProvider>
</View>
);
};

View File

@@ -1,97 +0,0 @@
import React, { useEffect, useState } from "react";
import {
SafeAreaView,
View,
Text,
ActivityIndicator,
Alert,
TouchableOpacity,
} from "react-native";
import { Redirect, useRouter } from "expo-router";
import useAuthStore from "@/store/auth";
import useDataStore from "@/store/data";
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 { 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,
collection: { id: data.preferredCollection?.id },
},
{
onSuccess: (e) => {
setLink(e as unknown as LinkIncludingShortenedCollectionAndTags);
setShowSuccess(true);
setTimeout(() => {
updateData({
shareIntent: {
hasShareIntent: false,
url: "",
},
});
router.replace("/dashboard");
}, 1500);
},
onError: (error) => {
Alert.alert("Error", "There was an error adding the link.");
console.error("Error adding link:", error);
},
}
);
}, [auth, data.shareIntent.url]);
if (auth.status === "unauthenticated") return <Redirect href="/" />;
return (
<SafeAreaView className="flex-1 bg-base-100">
<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>
);
}

View File

@@ -1,77 +0,0 @@
import { Button } from "@/components/ui/Button";
import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect, router } from "expo-router";
import { useColorScheme } from "nativewind";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import Animated, { SlideInDown } from "react-native-reanimated";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
const { auth } = useAuthStore();
const { colorScheme } = useColorScheme();
if (auth.session) {
return <Redirect href="/dashboard" />;
}
return (
<Animated.View
entering={SlideInDown.springify().damping(100).stiffness(300)}
className="flex-col justify-end h-full"
>
<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>
</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>
</Animated.View>
);
}

View File

@@ -1,111 +0,0 @@
import React, { useEffect, useState } from "react";
import { View, ActivityIndicator, Text, Platform } from "react-native";
import { WebView } from "react-native-webview";
import useAuthStore from "@/store/auth";
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/global";
import ReadableFormat from "@/components/Formats/ReadableFormat";
import ImageFormat from "@/components/Formats/ImageFormat";
import PdfFormat from "@/components/Formats/PdfFormat";
import WebpageFormat from "@/components/Formats/WebpageFormat";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function LinkScreen() {
const { auth } = useAuthStore();
const { id, format } = useLocalSearchParams();
const { data: user } = useUser(auth);
const [url, setUrl] = useState<string>();
const [isLoading, setIsLoading] = useState(true);
const { data: link } = useGetLink({ id: Number(id), auth, enabled: true });
const { updateTmp } = useTmpStore();
useEffect(() => {
if (link?.id && user?.id)
updateTmp({
link,
user: {
id: user.id,
},
});
return () =>
updateTmp({
link: null,
});
}, [link, user]);
useEffect(() => {
if (user?.id && link?.id && format) {
setUrl(`${auth.instance}/api/v1/archives/${link.id}?format=${format}`);
} else if (!url) {
if (link?.url) {
setUrl(link.url);
}
}
}, [user, link]);
const insets = useSafeAreaInsets();
return (
<View
className="flex-1"
style={{ paddingBottom: Platform.OS === "android" ? insets.bottom : 0 }}
>
{link?.id && Number(format) === ArchivedFormat.readability ? (
<ReadableFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : link?.id &&
(Number(format) === ArchivedFormat.jpeg ||
Number(format) === ArchivedFormat.png) ? (
<ImageFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
format={Number(format)}
/>
) : link?.id && Number(format) === ArchivedFormat.pdf ? (
<PdfFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : link?.id && Number(format) === ArchivedFormat.monolith ? (
<WebpageFormat
link={link as any}
setIsLoading={(state) => setIsLoading(state)}
/>
) : url ? (
<WebView
className={isLoading ? "opacity-0" : "flex-1"}
source={{
uri: url,
headers:
format || link?.type !== "url"
? { Authorization: `Bearer ${auth.session}` }
: {},
}}
onLoadEnd={() => setIsLoading(false)}
/>
) : (
<View className="flex-1 justify-center items-center bg-base-100 p-5">
<Text className="text-base text-neutral">
No link data available. Please check your network connection or try
again later.
</Text>
</View>
)}
{isLoading && (
<View className="absolute inset-0 flex-1 justify-center items-center bg-base-100 p-5">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
)}
</View>
);
}

View File

@@ -1,197 +0,0 @@
import { Button } from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import { rawTheme, ThemeName } from "@/lib/colors";
import useAuthStore from "@/store/auth";
import { Redirect } from "expo-router";
import { useColorScheme } from "nativewind";
import { useEffect, useState } from "react";
import { View, Text, Dimensions, TouchableOpacity, Image } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import Svg, { Path } from "react-native-svg";
import {
KeyboardStickyView,
KeyboardToolbar,
} from "react-native-keyboard-controller";
import { SafeAreaView } from "react-native-safe-area-context";
export default function HomeScreen() {
const { auth, signIn } = useAuthStore();
const { colorScheme } = useColorScheme();
const [method, setMethod] = useState<"password" | "token">("password");
const [isLoading, setIsLoading] = useState(false);
const [form, setForm] = useState({
user: "",
password: "",
token: "",
instance: auth.instance || "https://cloud.linkwarden.app",
});
const [showInstanceField, setShowInstanceField] = useState(
form.instance !== "https://cloud.linkwarden.app"
);
useEffect(() => {
setForm((prev) => ({
...prev,
instance: auth.instance || "https://cloud.linkwarden.app",
}));
}, [auth.instance]);
useEffect(() => {
setShowInstanceField(form.instance !== "https://cloud.linkwarden.app");
}, [form.instance]);
useEffect(() => {
setForm((prev) => ({
...prev,
token: "",
user: "",
password: "",
}));
}, [method]);
if (auth.status === "authenticated") {
return <Redirect href="/dashboard" />;
}
return (
<>
<KeyboardStickyView className="flex-col justify-end h-full bg-base-100 relative">
<View className="flex-col justify-end h-full bg-primary relative">
<View className="my-auto">
<Image
source={require("@/assets/images/linkwarden.png")}
className="w-[120px] h-[120px] mx-auto"
/>
</View>
<Text className="text-base-100 text-7xl font-bold ml-8">Login</Text>
<View>
<Text
className="text-base-100 text-2xl mx-8 mt-3"
numberOfLines={1}
>
Login to{" "}
{form.instance === "https://cloud.linkwarden.app"
? "cloud.linkwarden.app"
: form.instance}
</Text>
<TouchableOpacity
onPress={() => {
if (showInstanceField) {
setForm({
...form,
instance: "https://cloud.linkwarden.app",
});
}
setShowInstanceField(!showInstanceField);
}}
className="mx-8 mt-1 self-start"
>
<Text className="text-neutral-content text-sm">
{!showInstanceField ? "Change server" : "Use official server"}
</Text>
</TouchableOpacity>
</View>
<Svg
viewBox="0 0 1440 320"
width={Dimensions.get("screen").width}
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"
>
{showInstanceField && (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Instance URL"
selectTextOnFocus={false}
value={form.instance}
onChangeText={(text) => setForm({ ...form, instance: text })}
/>
)}
{method === "password" ? (
<>
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Email or Username"
value={form.user}
onChangeText={(text) => setForm({ ...form, user: text })}
/>
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Password"
secureTextEntry
value={form.password}
onChangeText={(text) => setForm({ ...form, password: text })}
/>
</>
) : (
<Input
className="w-full text-xl p-3 leading-tight h-12"
textAlignVertical="center"
placeholder="Access Token"
secureTextEntry
value={form.token}
onChangeText={(text) => setForm({ ...form, token: text })}
/>
)}
<TouchableOpacity
onPress={() =>
setMethod(method === "password" ? "token" : "password")
}
className="w-fit mx-auto"
>
<Text className="text-primary w-fit text-center">
{method === "password"
? "Login with Access Token"
: "Login with Username/Password"}
</Text>
</TouchableOpacity>
<Button
variant="accent"
size="lg"
isLoading={isLoading}
onPress={async () => {
if (
((form.user && form.password) || form.token) &&
form.instance
) {
setIsLoading(true);
await signIn(
form.user,
form.password,
form.instance,
form.token
);
setIsLoading(false);
}
}}
>
<Text className="text-white text-xl">Login</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>
</KeyboardStickyView>
<KeyboardToolbar />
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 B

View File

@@ -1,9 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -1,72 +0,0 @@
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";
import { Button } from "@/components/ui/Button";
import { useAddLink } from "@linkwarden/router/links";
import useAuthStore from "@/store/auth";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function AddLinkSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const addLink = useAddLink({ auth, Alert });
const [link, setLink] = useState("");
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
display: "none",
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
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"
value={link}
onChangeText={setLink}
/>
<Button
onPress={() => {
addLink.mutate({ url: link });
actionSheetRef.current?.hide();
setLink("");
}}
isLoading={addLink.isPending}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save to Linkwarden</Text>
</Button>
<Button
onPress={() => {
actionSheetRef.current?.hide();
setLink("");
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
</ActionSheet>
);
}

View File

@@ -1,396 +0,0 @@
import { View, Text, Alert, TouchableOpacity } from "react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import ActionSheet, {
FlatList,
Route,
SheetManager,
SheetProps,
useSheetRouteParams,
useSheetRouter,
} from "react-native-actions-sheet";
import Input from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
import { useAddLink, useUpdateLink } from "@linkwarden/router/links";
import useAuthStore from "@/store/auth";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
TagIncludingLinkCount,
} from "@linkwarden/types/global";
import { useCollections } from "@linkwarden/router/collections";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
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();
const params = useSheetRouteParams("edit-link-sheet", "main");
const [link, setLink] = useState<
LinkIncludingShortenedCollectionAndTags | undefined
>(props.payload?.link);
const updateLink = useUpdateLink({ auth, Alert });
const router = useSheetRouter("edit-link-sheet");
const { colorScheme } = useColorScheme();
useEffect(() => {
if (params?.link) {
setLink(params.link);
}
}, [params?.link]);
const { tmp, updateTmp } = useTmpStore();
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"
value={link?.name || ""}
onChangeText={(text) => link?.id && setLink({ ...link, name: text })}
/>
{props.payload?.link?.url && (
<Input
placeholder="URL"
className="mb-4 bg-base-100"
value={link?.url || ""}
onChangeText={(text) => link?.id && setLink({ ...link, url: text })}
/>
)}
<Button
variant="input"
className="mb-4"
onPress={() => router?.navigate("collections", { link })}
>
<View className="flex-row items-center gap-2 w-[90%]">
<Folder
size={20}
fill={link?.collection.color || "gray"}
color={link?.collection.color || "gray"}
/>
<Text numberOfLines={1} className="w-[90%] text-base-content">
{link?.collection.name}
</Text>
</View>
<ChevronRight
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
</Button>
<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-neutral rounded-md h-7 px-2 py-1"
>
<Text numberOfLines={1} className="text-base-100">
{tag.name}
</Text>
</View>
))}
</View>
) : (
<Text className="text-neutral">No tags</Text>
)}
<ChevronRight size={16} color={"gray"} />
</Button>
<Input
multiline
textAlignVertical="top"
placeholder="Description"
className="mb-4 h-28 bg-base-100"
value={link?.description || ""}
onChangeText={(text) =>
link?.id && setLink({ ...link, description: text })
}
/>
<Button
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"
>
<Text className="text-white">Save</Text>
</Button>
<Button
onPress={() => {
SheetManager.hide("edit-link-sheet");
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
);
};
const Collections = () => {
const { auth } = useAuthStore();
const addLink = useAddLink({ auth });
const [searchQuery, setSearchQuery] = useState("");
const router = useSheetRouter("edit-link-sheet");
const { link: currentLink } = useSheetRouteParams<
"edit-link-sheet",
"collections"
>("edit-link-sheet", "collections");
const params = useSheetRouteParams("edit-link-sheet", "collections");
const collections = useCollections(auth);
const { colorScheme } = useColorScheme();
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 renderItem = useCallback(
({
item: collection,
}: {
item: CollectionIncludingMembersAndLinkCount;
}) => {
const onSelect = () => {
const updatedLink = {
...currentLink,
collection,
};
router?.popToTop();
router?.navigate("main", { link: updatedLink });
};
return (
<Button variant="input" className="mb-2" onPress={onSelect}>
<View className="flex-row items-center gap-2 w-[75%]">
<Folder
size={20}
fill={collection.color || "gray"}
color={collection.color || "gray"}
/>
<Text numberOfLines={1} className="w-full text-base-content">
{collection.name}
</Text>
</View>
<View className="flex-row items-center gap-2">
{params.link?.collection.id === collection.id && (
<Check
size={16}
color={rawTheme[colorScheme as ThemeName].primary}
/>
)}
<Text className="text-neutral">
{collection._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: 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 mx-8"
value={searchQuery}
onChangeText={setSearchQuery}
/>
<FlatList
data={[...filteredCollections]}
keyExtractor={(e, i) => i.toString()}
renderItem={renderItem}
ListEmptyComponent={
<Text
style={{ textAlign: "center", marginTop: 20 }}
className="text-neutral"
>
No collections match {searchQuery}
</Text>
}
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>
);
};
const routes: Route[] = [
{
name: "main",
component: Main,
},
{
name: "collections",
component: Collections,
},
{
name: "tags",
component: Tags,
},
];
export default function EditLinkSheet() {
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
gestureEnabled
indicatorStyle={{
display: "none",
}}
routes={routes}
initialRoute="main"
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
safeAreaInsets={insets}
/>
);
}

View File

@@ -1,96 +0,0 @@
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";
import { Button } from "@/components/ui/Button";
import useAuthStore from "@/store/auth";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { useCreateCollection } from "@linkwarden/router/collections";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function NewCollectionSheet() {
const actionSheetRef = useRef<ActionSheetRef>(null);
const { auth } = useAuthStore();
const createCollection = useCreateCollection(auth);
const [collection, setCollection] = useState({
name: "",
description: "",
});
const { colorScheme } = useColorScheme();
const insets = useSafeAreaInsets();
return (
<ActionSheet
ref={actionSheetRef}
gestureEnabled
indicatorStyle={{
display: "none",
}}
containerStyle={{
backgroundColor: rawTheme[colorScheme as ThemeName]["base-200"],
}}
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"
value={collection.name}
onChangeText={(text) => setCollection({ ...collection, name: text })}
/>
<Input
placeholder="Description"
className="mb-4 bg-base-100"
value={collection.description}
onChangeText={(text) =>
setCollection({ ...collection, description: text })
}
/>
<Button
onPress={() =>
createCollection.mutate(
{ name: collection.name, description: collection.description },
{
onSuccess: () => {
actionSheetRef.current?.hide();
setCollection({ name: "", description: "" });
},
onError: (error) => {
Alert.alert(
"Error",
"There was an error creating the collection."
);
console.error("Error creating collection:", error);
},
}
)
}
isLoading={createCollection.isPending}
variant="accent"
className="mb-2"
>
<Text className="text-white">Save Collection</Text>
</Button>
<Button
onPress={() => {
actionSheetRef.current?.hide();
setCollection({ name: "", description: "" });
}}
variant="outline"
className="mb-2"
>
<Text className="text-base-content">Cancel</Text>
</Button>
</View>
</ActionSheet>
);
}

View File

@@ -1,41 +0,0 @@
import {
registerSheet,
RouteDefinition,
SheetDefinition,
} from "react-native-actions-sheet";
import SupportSheet from "./SupportSheet";
import AddLinkSheet from "./AddLinkSheet";
import EditLinkSheet from "./EditLinkSheet";
import NewCollectionSheet from "./NewCollectionSheet";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
registerSheet("support-sheet", SupportSheet);
registerSheet("add-link-sheet", AddLinkSheet);
registerSheet("edit-link-sheet", EditLinkSheet);
registerSheet("new-collection-sheet", NewCollectionSheet);
declare module "react-native-actions-sheet" {
interface Sheets {
"support-sheet": SheetDefinition;
"add-link-sheet": SheetDefinition;
"edit-link-sheet": SheetDefinition<{
payload: {
link: LinkIncludingShortenedCollectionAndTags;
};
routes: {
main: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
collections: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
tags: RouteDefinition<{
link: LinkIncludingShortenedCollectionAndTags;
}>;
};
}>;
"new-collection-sheet": SheetDefinition;
}
}
export {};

View File

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

View File

@@ -1,132 +0,0 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { CollectionIncludingMembersAndLinkCount } from "@linkwarden/types/global";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { cn } from "@linkwarden/lib/utils";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Folder, Link } from "lucide-react-native";
import { useDeleteCollection } from "@linkwarden/router/collections";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
};
const CollectionListing = ({ collection }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useDeleteCollection({ auth, Alert });
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
"bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50"
)}
onLongPress={() => {}}
onPress={() => router.navigate(`/collections/${collection.id}`)}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View className="w-full">
<View className="w-[90%] flex-col justify-between gap-3">
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
<Folder
size={16}
fill={collection.color || ""}
color={collection.color || ""}
/>
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(collection.name)}
</Text>
</View>
{collection.description && (
<Text
numberOfLines={2}
className="font-light text-sm text-base-content"
>
{decode(collection.description)}
</Text>
)}
</View>
<View className="flex-row gap-3">
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(collection.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
)}
</Text>
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{collection._count?.links}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="delete-collection"
onSelect={() => {
return Alert.alert(
"Delete Collection",
"Are you sure you want to delete this collection? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteCollection.mutate(collection.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default CollectionListing;

View File

@@ -1,35 +0,0 @@
import { Text, View } from "react-native";
export default function DashboardItem({
name,
value,
icon,
color,
}: {
name: string;
value: number;
icon: React.ReactNode;
color: string;
}) {
return (
<View className="flex-1 flex-col gap-2 rounded-xl bg-base-200 p-3">
<View className="flex-row justify-between">
<View
className="flex-col gap-2 rounded-full aspect-square flex justify-center items-center"
style={{
backgroundColor: color,
}}
>
{icon}
</View>
<Text
className="text-4xl text-base-content mt-0.5 text-right max-w-[75%]"
numberOfLines={1}
>
{value || 0}
</Text>
</View>
<Text className="font-semibold text-neutral">{name}</Text>
</View>
);
}

View File

@@ -1,346 +0,0 @@
import {
FlatList,
Text,
TouchableOpacity,
View,
ViewToken,
} from "react-native";
import React from "react";
import clsx from "clsx";
import DashboardItem from "@/components/DashboardItem";
import { rawTheme, ThemeName } from "@/lib/colors";
import {
Clock8,
ChevronRight,
Pin,
Folder,
Hash,
Link,
} from "lucide-react-native";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import LinkListing from "@/components/LinkListing";
import { useColorScheme } from "nativewind";
import { useRouter } from "expo-router";
// Don't use prisma client's DashboardSectionType, it'll crash in production (React Native)
type DashboardSectionType =
| "STATS"
| "RECENT_LINKS"
| "PINNED_LINKS"
| "COLLECTION";
type DashboardSectionProps = {
sectionData: { type: DashboardSectionType };
collection?: any;
links?: any[];
tagsLength: number;
numberOfLinks: number;
collectionsLength: number;
numberOfPinnedLinks: number;
dashboardData: {
isLoading: boolean;
refetch: Function;
isRefetching: boolean;
};
collectionLinks?: any[];
};
const DashboardSection: React.FC<DashboardSectionProps> = ({
sectionData,
collection,
links = [],
tagsLength,
numberOfLinks,
collectionsLength,
numberOfPinnedLinks,
dashboardData,
collectionLinks = [],
}) => {
const { colorScheme } = useColorScheme();
const router = useRouter();
switch (sectionData.type) {
case "STATS":
return (
<View className="flex-col gap-4 max-w-full px-5">
<View className="flex-row gap-4">
<DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks}
icon={<Link size={23} color="white" />}
color="#9c00cc"
/>
<DashboardItem
name={collectionsLength === 1 ? "Collection" : "Collections"}
value={collectionsLength}
icon={<Folder size={23} color="white" fill="white" />}
color="#0096cc"
/>
</View>
<View className="flex-row gap-4">
<DashboardItem
name={tagsLength === 1 ? "Tag" : "Tags"}
value={tagsLength}
icon={<Hash size={23} color="white" />}
color="#00cc99"
/>
<DashboardItem
name={"Pinned Links"}
value={numberOfPinnedLinks}
icon={<Pin size={23} color="white" fill="white" />}
color="#cc6d00"
/>
</View>
</View>
);
case "RECENT_LINKS":
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Clock8
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Recent Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() => router.navigate("/(tabs)/dashboard/recent-links")}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
(links.length > 0 && !dashboardData.isLoading) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
directionalLockEnabled
data={links || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (
!dashboardData.isRefetching &&
links.some((e) => e.id && !e.preview)
) {
dashboardData.refetch();
}
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Clock8
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Recent Links
</Text>
{/* <View className="text-center w-full mt-4 flex-row flex-wrap gap-4 justify-center">
<Button onPress={() => setNewLinkModal(true)} variant="accent">
<Icon name="bi-plus-lg" className="text-xl" />
<Text>{t("add_link")}</Text>
</Button>
<ImportDropdown />
</View> */}
</View>
)}
</>
);
case "PINNED_LINKS":
return (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center">
<View className={"flex-row items-center gap-2"}>
<Pin
size={30}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-2xl capitalize text-base-content">
Pinned Links
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1"
onPress={() => router.navigate("/(tabs)/dashboard/pinned-links")}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading ||
links?.some((e: any) => e.pinnedBy && e.pinnedBy[0]) ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={links.filter((e: any) => e.pinnedBy && e.pinnedBy[0]) || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (
!dashboardData.isRefetching &&
links.some((e) => e.id && !e.preview)
) {
dashboardData.refetch();
}
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Pin
size={40}
color={rawTheme[colorScheme as ThemeName].primary}
/>
<Text className="text-center text-xl text-neutral">
No Pinned Links
</Text>
</View>
)}
</>
);
case "COLLECTION":
return collection?.id ? (
<>
<View className="flex-row justify-between items-center px-5">
<View className="flex-row gap-2 items-center max-w-[60%]">
<View className={clsx("flex-row items-center gap-2")}>
<Folder
size={30}
fill={collection.color || "#0ea5e9"}
color={collection.color || "#0ea5e9"}
/>
<Text
className="text-2xl capitalize w-full text-base-content"
numberOfLines={1}
>
{collection.name}
</Text>
</View>
</View>
<TouchableOpacity
className="flex-row items-center text-sm gap-1 whitespace-nowrap"
onPress={() =>
router.navigate(
`/(tabs)/dashboard/collection?collectionId=${collection.id}`
)
}
>
<Text className="text-primary">View All</Text>
<ChevronRight
size={15}
color={rawTheme[colorScheme as ThemeName].primary}
/>
</TouchableOpacity>
</View>
{dashboardData.isLoading || collectionLinks.length > 0 ? (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
data={collectionLinks || []}
refreshing={dashboardData.isLoading}
initialNumToRender={2}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
contentContainerStyle={{
paddingHorizontal: 20,
}}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (
!dashboardData.isRefetching &&
links.some((e) => e.id && !e.preview)
) {
dashboardData.refetch();
}
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
) : (
<View className="flex-col gap-2 justify-center items-center h-40 p-10 rounded-xl bg-base-200 mx-5">
<Text className="text-center text-xl text-neutral">
Empty Collection
</Text>
</View>
)}
</>
) : null;
default:
return null;
}
};
export default DashboardSection;
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} dashboard />;
}
);

View File

@@ -1,18 +0,0 @@
import { View, Text, Button } from "react-native";
export default function ElementNotSupported({
message = "This element is currently not supported in this view.",
buttonTitle = "Open original",
onPress,
}: {
message?: string;
buttonTitle?: string;
onPress: () => void;
}) {
return (
<View className="border-y border-neutral-content my-2 py-5 flex justify-center items-center">
<Text className="text-neutral">{message}</Text>
<Button onPress={onPress} title={buttonTitle} />
</View>
);
}

View File

@@ -1,136 +0,0 @@
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/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
import { Image, Platform, ScrollView } from "react-native";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
format: ArchivedFormat.png | ArchivedFormat.jpeg;
};
export default function ImageFormat({ link, setIsLoading, format }: Props) {
const FORMAT = format;
const extension = format === ArchivedFormat.png ? "png" : "jpeg";
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
const [dimension, setDimension] = useState<{
width: number;
height: number;
}>();
useEffect(() => {
if (content)
Image.getSize(content, (width, height) => {
setDimension({ width, height });
});
}, [content]);
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/${extension}/link_${link.id}.${extension}`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
if (Platform.OS === "ios")
return (
content &&
dimension && (
<ScrollView maximumZoomScale={10}>
<Image
source={{ uri: content }}
onLoadEnd={() => setIsLoading(false)}
style={{
width: "100%",
height: "auto",
aspectRatio: dimension.width / dimension.height,
}}
resizeMode="contain"
/>
</ScrollView>
)
);
else
return (
content && (
<WebView
style={{
flex: 1,
}}
source={{
baseUrl: content,
html: `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<img src="${content}" />
</body>
</html>
`,
}}
scalesPageToFit
originWhitelist={["*"]}
mixedContentMode="always"
javaScriptEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoadEnd={() => setIsLoading(false)}
/>
)
);
}

View File

@@ -1,72 +0,0 @@
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/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import Pdf from "react-native-pdf";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function PdfFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.pdf;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory + `archivedData/pdf/link_${link.id}.pdf`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<Pdf
style={{
flex: 1,
}}
source={{ uri: content }}
onLoadComplete={() => setIsLoading(false)}
onPressLink={(uri) => {
console.log(`Link pressed: ${uri}`);
}}
/>
)
);
}

View File

@@ -1,139 +0,0 @@
import React, { useEffect, useState } from "react";
import { View, Text, ScrollView, TouchableOpacity } from "react-native";
import * as FileSystem from "expo-file-system";
import NetInfo from "@react-native-community/netinfo";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import { useWindowDimensions } from "react-native";
import RenderHtml from "@linkwarden/react-native-render-html";
import ElementNotSupported from "@/components/ElementNotSupported";
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/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function ReadableFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.readability;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
const { width } = useWindowDimensions();
const router = useRouter();
const { colorScheme } = useColorScheme();
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/readable/link_${link.id}.html`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
const rawContent = await FileSystem.readAsStringAsync(filePath);
setContent(rawContent);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const response = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${auth.session}` },
});
const data = (await response.json()).content;
setContent(data);
await FileSystem.writeAsStringAsync(filePath, data, {
encoding: FileSystem.EncodingType.UTF8,
});
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<ScrollView
className="flex-1 bg-base-100"
contentContainerClassName="p-4"
nestedScrollEnabled
>
<Text className="text-2xl font-bold mb-2.5 text-base-content">
{decode(link.name || link.description || link.url || "")}
</Text>
<TouchableOpacity
className="flex-row items-center gap-1 mb-2.5 pr-5"
onPress={() => router.replace(`/links/${link.id}`)}
>
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral flex-1" numberOfLines={1}>
{link.url}
</Text>
</TouchableOpacity>
<View className="flex-row items-center gap-1 mb-2.5">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text className="text-base text-neutral">
{new Date(link?.importDate || link.createdAt).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
)}
</Text>
</View>
<View className="border-t border-neutral-content mt-2.5 mb-5" />
<RenderHtml
contentWidth={width}
source={{ html: content }}
renderers={{
table: () => (
<ElementNotSupported
onPress={() => router.replace(`/links/${link.id}`)}
/>
),
}}
onHTMLLoaded={() => setIsLoading(false)}
tagsStyles={{
p: { fontSize: 18, lineHeight: 28, marginVertical: 10 },
}}
baseStyle={{
color: rawTheme[colorScheme as ThemeName]["base-content"],
}}
/>
</ScrollView>
)
);
}

View File

@@ -1,80 +0,0 @@
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/global";
import { Link as LinkType } from "@linkwarden/prisma/client";
import WebView from "react-native-webview";
type Props = {
link: LinkType;
setIsLoading: (state: boolean) => void;
};
export default function WebpageFormat({ link, setIsLoading }: Props) {
const FORMAT = ArchivedFormat.monolith;
const { auth } = useAuthStore();
const [content, setContent] = useState<string>("");
useEffect(() => {
async function loadCacheOrFetch() {
const filePath =
FileSystem.documentDirectory +
`archivedData/webpage/link_${link.id}.html`;
await FileSystem.makeDirectoryAsync(
filePath.substring(0, filePath.lastIndexOf("/")),
{
intermediates: true,
}
).catch(() => {});
const [info] = await Promise.all([FileSystem.getInfoAsync(filePath)]);
if (info.exists) {
setContent(filePath);
}
const net = await NetInfo.fetch();
if (net.isConnected) {
const apiUrl = `${auth.instance}/api/v1/archives/${link.id}?format=${FORMAT}`;
try {
const result = await FileSystem.downloadAsync(apiUrl, filePath, {
headers: { Authorization: `Bearer ${auth.session}` },
});
setContent(result.uri);
} catch (e) {
console.error("Failed to fetch content", e);
}
}
}
loadCacheOrFetch();
}, [link]);
return (
content && (
<WebView
style={{
flex: 1,
}}
source={{
uri: content,
baseUrl: FileSystem.documentDirectory,
}}
scalesPageToFit
originWhitelist={["*"]}
mixedContentMode="always"
javaScriptEnabled={true}
allowFileAccess={true}
allowFileAccessFromFileURLs={true}
allowUniversalAccessFromFileURLs={true}
onLoadEnd={() => setIsLoading(false)}
/>
)
);
}

View File

@@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs";
import { PlatformPressable } from "@react-navigation/elements";
import * as Haptics from "expo-haptics";
export default function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === "ios") {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -1,339 +0,0 @@
import {
View,
Text,
Image,
Pressable,
Platform,
Alert,
ActivityIndicator,
Linking,
} from "react-native";
import { decode } from "html-entities";
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 {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { useDeleteLink, useUpdateLink } from "@linkwarden/router/links";
import { SheetManager } from "react-native-actions-sheet";
import * as Clipboard from "expo-clipboard";
import { cn } from "@linkwarden/lib/utils";
import { useUser } from "@linkwarden/router/user";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Folder } from "lucide-react-native";
import useDataStore from "@/store/data";
import { useEffect, useState } from "react";
import { deleteLinkCache } from "@/lib/cache";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
dashboard?: boolean;
};
const LinkListing = ({ link, dashboard }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const updateLink = useUpdateLink({ auth, Alert });
const { data: user } = useUser(auth);
const { colorScheme } = useColorScheme();
const { data } = useDataStore();
const deleteLink = useDeleteLink({ auth, Alert });
const [url, setUrl] = useState("");
useEffect(() => {
try {
if (link.url) {
setUrl(new URL(link.url).host.toLowerCase());
}
} catch (error) {
console.log(error);
}
}, [link.url]);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
dashboard ? "bg-base-200" : "bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50",
dashboard && "rounded-xl"
)}
onLongPress={() => {}}
onPress={() => {
if (user) {
const format = getFormatBasedOnPreference({
link,
preference: user.linksRouteTo,
});
data.preferredBrowser === "app"
? router.navigate(
format !== null
? `/links/${link.id}?format=${format}`
: `/links/${link.id}`
)
: Linking.openURL(
format !== null
? auth.instance +
`/preserved/${link?.id}?format=${format}`
: (link.url as string)
);
}
}}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View
className={cn(
"flex-row justify-between",
dashboard ? "w-80" : "w-full"
)}
>
<View className="w-[65%] flex-col justify-between">
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(link.name || link.description || link.url)}
</Text>
{url && (
<Text
numberOfLines={1}
className="mt-1.5 font-light text-sm text-base-content"
>
{url}
</Text>
)}
<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 || "#0ea5e9"}
color={link.collection.color || "#0ea5e9"}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{link.collection.name}
</Text>
</View>
</View>
<View className="flex-col items-end">
<View className="rounded-lg overflow-hidden relative">
{formatAvailable(link, "preview") ? (
<Image
source={{
uri: `${auth.instance}/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`,
headers: {
Authorization: `Bearer ${auth.session}`,
},
}}
className="rounded-md h-[60px] w-[90px] object-cover scale-105"
/>
) : !link.preview ? (
<ActivityIndicator
size="small"
className="h-[60px] w-[90px]"
/>
) : (
<View className="h-[60px] w-[90px]" />
)}
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(link.createdAt as string).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="open-original"
onSelect={() => {
if (link) {
const format = getOriginalFormat(link);
data.preferredBrowser === "app"
? router.navigate(
format !== null
? `/links/${link.id}?format=${format}`
: `/links/${link.id}`
)
: Linking.openURL(
format !== null
? auth.instance +
`/preserved/${link?.id}?format=${format}`
: (link.url as string)
);
}
}}
>
<ContextMenu.ItemTitle>Open Original</ContextMenu.ItemTitle>
</ContextMenu.Item>
{link?.url && (
<>
<ContextMenu.Item
key="copy-url"
onSelect={async () => {
await Clipboard.setStringAsync(link.url as string);
}}
>
<ContextMenu.ItemTitle>Copy URL</ContextMenu.ItemTitle>
</ContextMenu.Item>
</>
)}
<ContextMenu.Item
key="pin-link"
onSelect={() => {
const isAlreadyPinned =
link?.pinnedBy && link.pinnedBy[0] ? true : false;
updateLink.mutateAsync({
...link,
pinnedBy: (isAlreadyPinned
? [{ id: undefined }]
: [{ id: user?.id }]) as any,
});
}}
>
<ContextMenu.ItemTitle>
{link.pinnedBy && link.pinnedBy[0] ? "Unpin Link" : "Pin Link"}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item
key="edit-link"
onSelect={() => {
SheetManager.show("edit-link-sheet", {
payload: { link: link },
});
}}
>
<ContextMenu.ItemTitle>Edit Link</ContextMenu.ItemTitle>
</ContextMenu.Item>
{link.url && atLeastOneFormatAvailable(link) && (
<ContextMenu.Sub>
<ContextMenu.SubTrigger key="preserved-formats">
<ContextMenu.ItemTitle>Preserved Formats</ContextMenu.ItemTitle>
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{formatAvailable(link, "monolith") && (
<ContextMenu.Item
key="preserved-formats-webpage"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.monolith}`
)
}
>
<ContextMenu.ItemTitle>Webpage</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "image") && (
<ContextMenu.Item
key="preserved-formats-screenshot"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${
link.image?.endsWith(".png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`
)
}
>
<ContextMenu.ItemTitle>Screenshot</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "pdf") && (
<ContextMenu.Item
key="preserved-formats-pdf"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.pdf}`
)
}
>
<ContextMenu.ItemTitle>PDF</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
{formatAvailable(link, "readable") && (
<ContextMenu.Item
key="preserved-formats-readable"
onSelect={() =>
router.navigate(
`/links/${link.id}?format=${ArchivedFormat.readability}`
)
}
>
<ContextMenu.ItemTitle>Readable</ContextMenu.ItemTitle>
</ContextMenu.Item>
)}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)}
<ContextMenu.Item
key="delete-link"
onSelect={() => {
return Alert.alert(
"Delete Link",
"Are you sure you want to delete this link? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
deleteLink.mutate(link.id as number);
await deleteLinkCache(link.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default LinkListing;

View File

@@ -1,87 +0,0 @@
import {
View,
FlatList,
Text,
ActivityIndicator,
ViewToken,
} from "react-native";
import LinkListing from "@/components/LinkListing";
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import Spinner from "@/components/ui/Spinner";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
const RenderItem = React.memo(
({ item }: { item: LinkIncludingShortenedCollectionAndTags }) => {
return <LinkListing link={item} />;
}
);
type Props = {
links: LinkIncludingShortenedCollectionAndTags[];
data: any;
};
export default function Links({ links, data }: Props) {
const { colorScheme } = useColorScheme();
const [promptedRefetch, setPromptedRefetch] = useState(false);
return data.isLoading ? (
<View className="flex justify-center h-screen items-center">
<ActivityIndicator size="large" />
<Text className="text-base mt-2.5 text-neutral">Loading...</Text>
</View>
) : (
<FlatList
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={() => <></>}
data={links || []}
refreshControl={
<Spinner
refreshing={data.isRefetching && promptedRefetch}
onRefresh={async () => {
setPromptedRefetch(true);
await data.refetch();
setPromptedRefetch(false);
}}
progressBackgroundColor={
rawTheme[colorScheme as ThemeName]["base-200"]
}
colors={[rawTheme[colorScheme as ThemeName]["base-content"]]}
/>
}
refreshing={data.isRefetching && promptedRefetch}
initialNumToRender={4}
keyExtractor={(item) => item.id?.toString() || ""}
renderItem={({ item }) => (
<RenderItem item={item} key={item.id?.toString()} />
)}
onEndReached={() => data.fetchNextPage()}
onEndReachedThreshold={0.5}
ItemSeparatorComponent={() => (
<View className="bg-neutral-content h-px" />
)}
ListEmptyComponent={
<View className="flex justify-center py-10 items-center">
<Text className="text-center text-xl text-neutral">
Nothing found...
</Text>
</View>
}
onViewableItemsChanged={({
viewableItems,
}: {
viewableItems: ViewToken[];
}) => {
const links = viewableItems.map(
(e) => e.item
) as LinkIncludingShortenedCollectionAndTags[];
if (!data.isRefetching && links.some((e) => e.id && !e.preview))
data.refetch();
}}
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
/>
);
}

View File

@@ -1,120 +0,0 @@
import { View, Text, Pressable, Platform, Alert } from "react-native";
import { decode } from "html-entities";
import { TagIncludingLinkCount } from "@linkwarden/types/global";
import useAuthStore from "@/store/auth";
import { useRouter } from "expo-router";
import * as ContextMenu from "zeego/context-menu";
import { cn } from "@linkwarden/lib/utils";
import { rawTheme, ThemeName } from "@/lib/colors";
import { useColorScheme } from "nativewind";
import { CalendarDays, Hash, Link } from "lucide-react-native";
import { useRemoveTag } from "@linkwarden/router/tags";
type Props = {
tag: TagIncludingLinkCount;
};
const TagListing = ({ tag }: Props) => {
const { auth } = useAuthStore();
const router = useRouter();
const { colorScheme } = useColorScheme();
const deleteCollection = useRemoveTag(auth);
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Pressable
className={cn(
"p-5 flex-row justify-between",
"bg-base-100",
Platform.OS !== "android" && "active:bg-base-200/50"
)}
onLongPress={() => {}}
onPress={() => router.navigate(`/tags/${tag.id}`)}
android_ripple={{
color: colorScheme === "dark" ? "rgba(255,255,255,0.2)" : "#ddd",
borderless: false,
}}
>
<View className="w-full">
<View className="w-[90%] flex-col justify-between gap-3">
<View className="flex flex-row gap-2 items-center pr-1.5 self-start rounded-md">
<Hash
size={16}
color={rawTheme[colorScheme as ThemeName]["primary"]}
/>
<Text
numberOfLines={2}
className="font-medium text-lg text-base-content"
>
{decode(tag.name)}
</Text>
</View>
</View>
<View className="flex-row gap-3">
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<CalendarDays
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{new Date(tag.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})}
</Text>
</View>
<View className="flex flex-row gap-1 items-center mt-5 self-start">
<Link
size={16}
color={rawTheme[colorScheme as ThemeName]["neutral"]}
/>
<Text
numberOfLines={1}
className="font-light text-xs text-base-content"
>
{tag._count?.links}
</Text>
</View>
</View>
</View>
</Pressable>
</ContextMenu.Trigger>
<ContextMenu.Content avoidCollisions>
<ContextMenu.Item
key="delete-tag"
onSelect={() => {
return Alert.alert(
"Delete Tag",
"Are you sure you want to delete this Tag? This action cannot be undone.",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteCollection.mutate(tag.id as number);
},
},
]
);
}}
>
<ContextMenu.ItemTitle>Delete</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
};
export default TagListing;

View File

@@ -1,84 +0,0 @@
import React, { forwardRef } from "react";
import {
TouchableOpacity,
Text,
View,
ActivityIndicator,
type TouchableOpacityProps,
} from "react-native";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@linkwarden/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-lg disabled:opacity-50 disabled:pointer-events-none",
{
variants: {
variant: {
default: "bg-slate-500 text-white",
primary: "bg-primary text-base-content",
accent: "bg-accent border border-violet-400 text-white",
destructive: "bg-destructive text-white",
outline: "border border-base-content",
secondary: "bg-secondary text-secondary-foreground",
input:
"bg-base-100 rounded-lg px-4 justify-between flex-row font-normal",
metal: "bg-neutral-content text-base-content border border-neutral/30",
ghost: "",
simple: "bg-base-200",
link: "text-primary underline-offset-",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-2 py-1 text-xs",
lg: "h-12 px-8",
full: "w-full px-4 py-2",
icon: "h-8 w-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export type ButtonProps = TouchableOpacityProps &
VariantProps<typeof buttonVariants> & {
isLoading?: boolean;
children: React.ReactNode;
className?: string;
};
export const Button = forwardRef<
React.ElementRef<typeof TouchableOpacity>,
ButtonProps
>(
(
{
variant = "default",
size,
className,
isLoading = false,
children,
disabled,
...props
},
ref
) => {
const combinedClasses = cn(buttonVariants({ variant, size }), className);
return (
<TouchableOpacity
ref={ref}
className={combinedClasses}
activeOpacity={0.8}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <ActivityIndicator /> : children}
</TouchableOpacity>
);
}
);
Button.displayName = "Button";

View File

@@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols";
import { StyleProp, ViewStyle } from "react-native";
export function IconSymbol({
name,
size = 24,
color,
style,
weight = "regular",
}: {
name: SymbolViewProps["name"];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -1,50 +0,0 @@
// This file is a fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { SymbolWeight } from "expo-symbols";
import React from "react";
import { OpaqueColorValue, StyleProp, TextStyle } from "react-native";
// Add your SFSymbol to MaterialIcons mappings here.
const MAPPING = {
// See MaterialIcons here: https://icons.expo.fyi
// See SF Symbols in the SF Symbols app on Mac.
"house.fill": "home",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
} as Partial<
Record<
import("expo-symbols").SymbolViewProps["name"],
React.ComponentProps<typeof MaterialIcons>["name"]
>
>;
export type IconSymbolName = keyof typeof MAPPING;
/**
* An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
*
* Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return (
<MaterialIcons
color={color}
size={size}
name={MAPPING[name]}
style={style}
/>
);
}

View File

@@ -1,21 +0,0 @@
import React from "react";
import Svg, { Path, Circle, SvgProps } from "react-native-svg";
export const Chromium = (props: SvgProps) => (
<Svg
width={21}
height={21}
viewBox="0 0 24 24"
fill="none"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<Path d="M10.88 21.94 15.46 14" />
<Path d="M21.17 8H12" />
<Path d="M3.95 6.06 8.54 14" />
<Circle cx={12} cy={12} r={10} />
<Circle cx={12} cy={12} r={4} />
</Svg>
);

View File

@@ -1,20 +0,0 @@
import React, { forwardRef } from "react";
import { TextInput, TextInputProps } from "react-native";
import { cn } from "@linkwarden/lib/utils";
const Input = forwardRef<TextInput, TextInputProps>(
({ className, ...props }, ref) => {
return (
<TextInput
ref={ref}
className={cn(
"bg-base-200 text-base-content rounded-lg px-4 py-2",
className
)}
{...props}
/>
);
}
);
export default Input;

View File

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

View File

@@ -1,22 +0,0 @@
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function BlurTabBarBackground() {
return (
<BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial"
intensity={100}
style={StyleSheet.absoluteFill}
/>
);
}
export function useBottomTabOverflow() {
const tabHeight = useBottomTabBarHeight();
const { bottom } = useSafeAreaInsets();
return tabHeight - bottom;
}

View File

@@ -1,6 +0,0 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
export function useBottomTabOverflow() {
return 0;
}

View File

@@ -1,41 +0,0 @@
{
"cli": {
"version": ">= 16.20.1",
"appVersionSource": "remote"
},
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"development": {
"developmentClient": true,
"distribution": "internal"
},
"simulator": {
"extends": "development",
"ios": {
"simulator": true
}
},
"production": {
"corepack": true,
"distribution": "store",
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "6752550960"
},
"android": {
"serviceAccountKeyPath": "./service-account-file.json",
"track": "internal",
"releaseStatus": "draft"
}
}
}
}

View File

@@ -1,33 +0,0 @@
import * as FileSystem from "expo-file-system";
export const clearCache = async () => {
await Promise.all([
FileSystem.deleteAsync(FileSystem.documentDirectory + "archivedData", {
idempotent: true,
}),
FileSystem.deleteAsync(FileSystem.documentDirectory + "mmkv", {
idempotent: true,
}),
]);
};
export const deleteLinkCache = async (linkId: number) => {
const readablePath =
FileSystem.documentDirectory + `archivedData/readable/link_${linkId}.html`;
const webpagePath =
FileSystem.documentDirectory + `archivedData/webpage/link_${linkId}.html`;
const jpegPath =
FileSystem.documentDirectory + `archivedData/jpeg/link_${linkId}.jpeg`;
const pngPath =
FileSystem.documentDirectory + `archivedData/png/link_${linkId}.png`;
const pdfPath =
FileSystem.documentDirectory + `archivedData/pdf/link_${linkId}.pdf`;
await Promise.all([
FileSystem.deleteAsync(readablePath, { idempotent: true }),
FileSystem.deleteAsync(webpagePath, { idempotent: true }),
FileSystem.deleteAsync(jpegPath, { idempotent: true }),
FileSystem.deleteAsync(pngPath, { idempotent: true }),
FileSystem.deleteAsync(pdfPath, { idempotent: true }),
]);
};

View File

@@ -1,33 +0,0 @@
// lib/theme/colors.ts
export type ThemeName = "light" | "dark";
export const rawTheme = {
light: {
primary: "#0369A1",
secondary: "#0891B2",
accent: "#6D28D9",
neutral: "#6B7280",
"neutral-content": "#D1D5DB",
"base-100": "#FFFFFF",
"base-200": "#F3F4F6",
"base-content": "#0A0A0A",
info: "#A5F3FC",
success: "#22C55E",
warning: "#FACC15",
error: "#DC2626",
},
dark: {
primary: "#7DD3FC",
secondary: "#22D3EE",
accent: "#6D28D9",
neutral: "#9CA3AF",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#FAFAFA",
info: "#009EE4",
success: "#00B17D",
warning: "#EAC700",
error: "#F1293C",
},
};

View File

@@ -1,14 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60 * 24,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
},
});
export { queryClient };

View File

@@ -1,31 +0,0 @@
import { MMKV } from "react-native-mmkv";
import { Persister } from "@tanstack/react-query-persist-client";
const storage = new MMKV({ id: "react-query" });
export const mmkvPersister: Persister = {
persistClient: async (client) => {
try {
const json = JSON.stringify(client);
storage.set("REACT_QUERY_CACHE", json);
} catch (e) {
console.error("Error persisting client:", e);
}
},
restoreClient: async () => {
try {
const json = storage.getString("REACT_QUERY_CACHE");
return json ? JSON.parse(json) : undefined;
} catch (e) {
console.error("Error restoring client:", e);
return undefined;
}
},
removeClient: async () => {
try {
storage.delete("REACT_QUERY_CACHE");
} catch (e) {
console.error("Error removing client:", e);
}
},
};

View File

@@ -1,23 +0,0 @@
import { vars } from "nativewind";
import { rawTheme, ThemeName } from "./colors";
const hexToRgb = (hex: string) => {
const [r, g, b] = hex
.replace(/^#/, "")
.match(/.{2}/g)!
.map((h) => parseInt(h, 16));
return `${r} ${g} ${b}`;
};
const makeVars = (scheme: ThemeName) =>
vars(
Object.fromEntries(
Object.entries(rawTheme[scheme]).map(([key, hex]) => [
`--color-${key}`,
hexToRgb(hex),
])
) as Record<string, string>
);
export const lightTheme = makeVars("light");
export const darkTheme = makeVars("dark");

View File

@@ -1,6 +0,0 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./styles/global.css" });

View File

@@ -1,3 +0,0 @@
/// <reference types="nativewind/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.

View File

@@ -1,87 +0,0 @@
{
"name": "@linkwarden/mobile",
"main": "expo-router/entry",
"version": "0.0.0",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@linkwarden/lib": "*",
"@linkwarden/prisma": "*",
"@linkwarden/react-native-render-html": "^6.3.4",
"@linkwarden/router": "*",
"@linkwarden/types": "*",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "1.2.2",
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-persist-client": "^5.51.15",
"class-variance-authority": "^0.7.1",
"expo": "~52.0.18",
"expo-application": "~6.0.2",
"expo-blur": "~14.0.1",
"expo-build-properties": "~0.13.3",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.3",
"expo-dev-client": "~5.0.6",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.1",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.5",
"expo-router": "4.0.20",
"expo-secure-store": "~14.0.0",
"expo-share-intent": "^3.2.3",
"expo-splash-screen": "~0.29.18",
"expo-status-bar": "~2.0.0",
"expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.6",
"expo-updates": "~0.27.4",
"expo-web-browser": "~14.0.1",
"html-entities": "^2.6.0",
"lucide-react-native": "^0.536.0",
"nativewind": "^4.1.23",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-actions-sheet": "^0.9.7",
"react-native-blob-util": "^0.23.2",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.20.2",
"react-native-ios-context-menu": "3.1.3",
"react-native-ios-utilities": "5.1.7",
"react-native-keyboard-controller": "^1.19.0",
"react-native-mmkv": "^3.2.0",
"react-native-pdf": "^7.0.3",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.1.0",
"react-native-svg": "^15.12.1",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"tailwindcss": "^3.4.17",
"zeego": "^3.0.6",
"zustand": "^5.0.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^29.5.12",
"@types/react": "18.3.1",
"@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1",
"jest-expo": "~52.0.2",
"react-test-renderer": "18.3.1",
"typescript": "^5.8.3"
},
"private": true
}

View File

@@ -1,43 +0,0 @@
const { withAndroidStyles } = require("@expo/config-plugins");
function mutateStylesXml(xml) {
const styles = xml.resources?.style ?? [];
let appTheme = styles.find((s) => s.$?.name === "AppTheme");
if (!appTheme) {
appTheme = {
$: { name: "AppTheme", parent: "Theme.AppCompat.DayNight.NoActionBar" },
item: [],
};
styles.push(appTheme);
}
appTheme.$ = appTheme.$ || {};
appTheme.$.parent = "Theme.AppCompat.DayNight.NoActionBar";
appTheme.item = appTheme.item ?? [];
appTheme.item = appTheme.item.filter(
(i) => i?.$?.name !== "android:textColor"
);
const navItem = appTheme.item.find(
(i) => i.$?.name === "android:navigationBarColor"
);
if (navItem) {
navItem._ = "@android:color/transparent";
} else {
appTheme.item.push({
$: { name: "android:navigationBarColor" },
_: "@android:color/transparent",
});
}
xml.resources.style = styles;
return xml;
}
module.exports = function withDayNightTransparentNav(config) {
return withAndroidStyles(config, (c) => {
c.modResults = mutateStylesXml(c.modResults);
return c;
});
};

View File

@@ -1,157 +0,0 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { MobileAuth } from "@linkwarden/types/global";
import { Alert } from "react-native";
import { queryClient } from "@/lib/queryClient";
import { mmkvPersister } from "@/lib/queryPersister";
import { clearCache } from "@/lib/cache";
type AuthStore = {
auth: MobileAuth;
signIn: (
username: string,
password: string,
instance: string,
token?: string
) => Promise<void>;
signOut: () => Promise<void>;
setAuth: () => Promise<void>;
};
const useAuthStore = create<AuthStore>((set) => ({
auth: {
instance: "",
session: null,
status: "loading" as const,
},
setAuth: async () => {
const session = await SecureStore.getItemAsync("TOKEN");
const instance = await SecureStore.getItemAsync("INSTANCE");
if (session) {
set({
auth: {
instance,
session,
status: "authenticated",
},
});
} else {
set({
auth: {
instance: instance || "https://cloud.linkwarden.app",
session: null,
status: "unauthenticated",
},
});
}
},
signIn: async (username, password, instance, token) => {
if (process.env.EXPO_PUBLIC_SHOW_LOGS === "true")
console.log("Signing into", instance);
if (token) {
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);
set({
auth: {
session: token,
instance,
status: "authenticated",
},
});
router.replace("/(tabs)/dashboard");
} else {
Alert.alert("Error", "Invalid token");
}
} catch (err: any) {
if (err?.message === "TIMEOUT") {
Alert.alert(
"Request timed out",
"Unable to reach the server in time. Please check your network configuration and try again."
);
} else {
Alert.alert(
"Network error",
"Could not connect to the server. Please check your network configuration and try again."
);
}
}
} else {
try {
const res = await Promise.race([
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" } });
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 () => {
await SecureStore.deleteItemAsync("TOKEN");
await SecureStore.deleteItemAsync("INSTANCE");
queryClient.cancelQueries();
queryClient.clear();
mmkvPersister.removeClient?.();
await clearCache();
set({
auth: {
instance: "",
session: null,
status: "unauthenticated",
},
});
router.replace("/");
},
}));
export default useAuthStore;

View File

@@ -1,38 +0,0 @@
import { create } from "zustand";
import { MobileData } from "@linkwarden/types/global";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { colorScheme } from "nativewind";
type DataStore = {
data: MobileData;
updateData: (newData: Partial<MobileData>) => void;
setData: () => void;
};
const useDataStore = create<DataStore>((set, get) => ({
data: {
shareIntent: {
hasShareIntent: false,
url: "",
},
theme: "system",
preferredBrowser: "app",
preferredCollection: null,
},
setData: async () => {
const dataString = JSON.parse((await AsyncStorage.getItem("data")) || "{}");
colorScheme.set(dataString.theme || "system");
if (dataString)
set((state) => ({ data: { ...state.data, ...dataString } }));
},
updateData: async (patch) => {
const merged = { ...get().data, ...patch };
const { shareIntent, ...persistable } = merged;
await AsyncStorage.setItem("data", JSON.stringify(persistable));
set({ data: merged });
},
}));
export default useDataStore;

View File

@@ -1,26 +0,0 @@
import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import { User } from "@linkwarden/prisma/client";
type Tmp = {
link: LinkIncludingShortenedCollectionAndTags | null;
user: Pick<User, "id"> | null;
};
type TmpStore = {
tmp: Tmp;
updateTmp: (newData: Partial<Tmp>) => void;
};
const useTmpStore = create<TmpStore>((set, get) => ({
tmp: {
link: null,
user: null,
},
updateTmp: async (patch) => {
const merged = { ...get().tmp, ...patch };
set({ tmp: merged });
},
}));
export default useTmpStore;

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,39 +0,0 @@
/** @type {import('tailwindcss').Config} */
const { rawTheme } = require("./lib/colors");
const hexToRgb = (hex) => {
const [r, g, b] = hex
.replace(/^#/, "")
.match(/.{2}/g)
.map((h) => parseInt(h, 16));
return `${r} ${g} ${b}`;
};
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
darkMode: "media",
theme: {
extend: {
colors: Object.fromEntries(
Object.keys(rawTheme.light).map((key) => [
key,
`rgb(var(--color-${key}) / <alpha-value>)`,
])
),
},
},
plugins: [
({ addBase }) => {
addBase({
":root": Object.fromEntries(
Object.entries(rawTheme.light).map(([key, hex]) => [
`--color-${key}`,
hexToRgb(hex),
])
),
});
},
],
};

View File

@@ -1,18 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}

View File

@@ -1,43 +0,0 @@
export enum Sort {
DateNewestFirst,
DateOldestFirst,
NameAZ,
NameZA,
DescriptionAZ,
DescriptionZA,
}
export type LinkRequestQuery = {
sort?: Sort;
cursor?: number;
collectionId?: number;
tagId?: number;
pinnedOnly?: boolean;
searchQueryString?: string;
searchByName?: boolean;
searchByUrl?: boolean;
searchByDescription?: boolean;
searchByTextContent?: boolean;
searchByTags?: boolean;
};
export type LinkIncludingShortenedCollectionAndTags = {
id: number;
name: string;
url: string;
description: string;
type: "url" | "image" | "pdf";
preview: string | null;
createdAt: string;
updatedAt: string;
collectionId: number;
tags: { id: number; name: string }[];
};
export enum ArchivedFormat {
png = 0,
jpeg = 1,
pdf = 2,
readability = 3,
monolith = 4,
}

View File

@@ -1 +0,0 @@
/// <reference types="nativewind/types" />

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,125 +0,0 @@
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import Image from "next/image";
export default function AdminSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
setActive(router.asPath);
}, [router]);
return (
<div
className={`bg-base-200 h-screen w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between mb-4">
{user?.theme === "light" ? (
<Image
src={"/linkwarden_light.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
) : (
<Image
src={"/linkwarden_dark.png"}
width={640}
height={136}
alt="Linkwarden"
className="h-9 w-auto cursor-pointer"
onClick={() => router.push("/dashboard")}
priority
/>
)}
</div>
<Link href="/admin/user-administration">
<div
className={`${
active === "/admin/user-administration"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-people text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("user_administration")}
</p>
</div>
</Link>
<Link href="/admin/background-jobs">
<div
className={`${
active === "/admin/background-jobs"
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-gear-wide-connected text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">
{t("background_jobs")}
</p>
</div>
</Link>
</div>
<div className="flex flex-col gap-1">
<Link
href={`https://github.com/linkwarden/linkwarden/releases`}
target="_blank"
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
>
{t("linkwarden_version", { version: LINKWARDEN_VERSION })}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-question-circle text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("help")}</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-github text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("github")}</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-twitter-x text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("twitter")}</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-neutral/20 duration-200 cursor-pointer flex items-center gap-2 rounded-lg px-3 py-1`}
>
<i className="bi-mastodon text-primary text-xl drop-shadow"></i>
<p className="truncate w-full font-semibold text-sm">{t("mastodon")}</p>
</div>
</Link>
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
import { Button } from "@/components/ui/button";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
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">
{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>
</Button>
</div>
</div>
);
}

View File

@@ -1,50 +0,0 @@
import React, { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import Modal from "./Modal";
import { Separator } from "./ui/separator";
type Props = {
toggleModal: Function;
className?: string;
children: ReactNode;
title: string;
onConfirmed: Function;
dismissible?: boolean;
};
export default function ConfirmationModal({
toggleModal,
className,
children,
title,
onConfirmed,
}: Props) {
const { t } = useTranslation();
return (
<Modal toggleModal={() => toggleModal()} className={className}>
<p className="text-xl font-thin">{title}</p>
<Separator className="mb-3 mt-1" />
{children}
<div className="w-full flex items-center justify-end gap-2 mt-3">
<Button
variant="ghost"
className="hover:bg-base-200"
onClick={() => toggleModal()}
>
{t("cancel")}
</Button>
<Button
variant="destructive"
onClick={async () => {
await onConfirmed();
toggleModal();
}}
>
{t("confirm")}
</Button>
</div>
</Modal>
);
}

View File

@@ -1,60 +0,0 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<Button variant="ghost" type="button" size="icon" onClick={handleCopy}>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="h-5 w-5 text-success"
viewBox="0 0 16 16"
>
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="h-5 w-5"
viewBox="0 0 16 16"
>
<path
fillRule="evenodd"
d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2
2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1
1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0
0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0
0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2
2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"
/>
</svg>
)}
</Button>
);
};
export default CopyButton;

View File

@@ -1,411 +0,0 @@
import React, { useState, useMemo, useEffect } from "react";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { useTranslation } from "next-i18next";
import { Button } from "@/components/ui/button";
import TextInput from "./TextInput";
import { useCollections } from "@linkwarden/router/collections";
import {
DashboardSection,
DashboardSectionType,
} from "@linkwarden/prisma/client";
import { useUser } from "@linkwarden/router/user";
import { useUpdateDashboardLayout } from "@linkwarden/router/dashboardData";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
DndContext,
DragEndEvent,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { cn } from "@linkwarden/lib/utils";
import toast from "react-hot-toast";
interface DashboardSectionOption {
type: DashboardSectionType;
name: string;
collectionId?: number;
enabled: boolean;
order?: number;
}
export default function DashboardLayoutDropdown() {
const { t } = useTranslation();
const { data: user } = useUser();
const { data: collections = [] } = useCollections();
const updateDashboardLayout = useUpdateDashboardLayout();
const [searchTerm, setSearchTerm] = useState("");
const mouseSensor = useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
distance: 10,
},
});
const touchSensor = useSensor(TouchSensor, {
// Press delay of 200ms, with tolerance of 5px of movement
activationConstraint: {
delay: 200,
tolerance: 5,
},
});
const sensors = useSensors(mouseSensor, touchSensor);
const [dashboardSections, setDashboardSections] = useState<
DashboardSection[]
>(user?.dashboardSections || []);
useEffect(() => {
setDashboardSections(user?.dashboardSections || []);
}, [user?.dashboardSections]);
const getSectionOrder = (
type: DashboardSectionType,
collectionId?: number
): number | undefined => {
const section = dashboardSections.find(
(section) =>
section.type === type &&
(type === DashboardSectionType.COLLECTION
? section.collectionId === collectionId
: true)
);
return section?.order;
};
const isSectionEnabled = (
type: DashboardSectionType,
collectionId?: number
): boolean => {
return dashboardSections.some(
(section) =>
section.type === type &&
(type === DashboardSectionType.COLLECTION
? section.collectionId === collectionId
: true)
);
};
const defaultSections: DashboardSectionOption[] = useMemo(
() => [
{
type: DashboardSectionType.STATS,
name: t("dashboard_stats"),
enabled: isSectionEnabled(DashboardSectionType.STATS),
order: getSectionOrder(DashboardSectionType.STATS),
},
{
type: DashboardSectionType.RECENT_LINKS,
name: t("recent_links"),
enabled: isSectionEnabled(DashboardSectionType.RECENT_LINKS),
order: getSectionOrder(DashboardSectionType.RECENT_LINKS),
},
{
type: DashboardSectionType.PINNED_LINKS,
name: t("pinned_links"),
enabled: isSectionEnabled(DashboardSectionType.PINNED_LINKS),
order: getSectionOrder(DashboardSectionType.PINNED_LINKS),
},
],
[dashboardSections]
);
const collectionSections = useMemo(
() =>
collections.map((collection) => ({
type: DashboardSectionType.COLLECTION,
name: collection.name,
collectionId: collection.id,
enabled: isSectionEnabled(
DashboardSectionType.COLLECTION,
collection.id
),
order: getSectionOrder(DashboardSectionType.COLLECTION, collection.id),
})),
[collections, dashboardSections]
);
const allSections = useMemo(
() => [...defaultSections, ...collectionSections],
[collectionSections, defaultSections]
);
const filteredSections = useMemo(() => {
let sections = allSections;
if (searchTerm.trim()) {
sections = sections.filter((section) =>
section.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
const enabledSections = sections
.filter((section) => section.enabled)
.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order;
}
if (a.order !== undefined) return -1;
if (b.order !== undefined) return 1;
return 0;
});
const disabledSections = sections.filter((section) => !section.enabled);
return [...enabledSections, ...disabledSections];
}, [allSections, searchTerm]);
const getSectionId = (section: DashboardSectionOption) =>
`${section.type}-${section.collectionId ?? "default"}`;
const handleCheckboxChange = (section: DashboardSectionOption) => {
const enabledSections = allSections.filter((s) => s.enabled);
const highestOrder =
enabledSections.length > 0
? Math.max(...enabledSections.map((s) => s.order ?? 0))
: -1;
const updatedSections = allSections.map((s) => {
if (s.type === section.type && s.collectionId === section.collectionId) {
return {
...s,
enabled: !s.enabled,
order: !s.enabled ? highestOrder + 1 : undefined,
};
}
return s;
});
updateDashboardLayout.mutateAsync(updatedSections, {
onSettled: (data, error) => {
if (error) {
toast.error(error.message);
}
},
});
};
const handleReorder = (sourceId: string, destId: string) => {
if (sourceId === destId) return;
// Get only enabled sections for reordering
const enabledSections = filteredSections.filter((s) => s.enabled);
const sourceIndex = enabledSections.findIndex(
(s) => getSectionId(s) === sourceId
);
const destIndex = enabledSections.findIndex(
(s) => getSectionId(s) === destId
);
if (sourceIndex < 0 || destIndex < 0) return;
// Reorder only the enabled sections
const reorderedEnabled = [...enabledSections];
const [moved] = reorderedEnabled.splice(sourceIndex, 1);
reorderedEnabled.splice(destIndex, 0, moved);
// Assign new order values based on the reordered enabled sections
const reorderedWithNewOrders = reorderedEnabled.map((section, idx) => ({
...section,
order: idx,
}));
// Get disabled sections and combine with reordered enabled sections
const disabledSections = filteredSections.filter((s) => !s.enabled);
const updated = [...reorderedWithNewOrders, ...disabledSections];
updateDashboardLayout.mutateAsync(updated, {
onSettled: (data, error) => {
if (error) {
toast.error(error.message);
}
},
});
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const sourceId = active.id as string;
const destId = over.id as string;
// Only allow reordering enabled sections
const sourceSection = filteredSections.find(
(s) => getSectionId(s) === sourceId
);
const destSection = filteredSections.find(
(s) => getSectionId(s) === destId
);
if (sourceSection?.enabled && destSection?.enabled) {
handleReorder(sourceId, destId);
}
};
// Only include enabled sections in the sortable context
const sortableItems = filteredSections
.filter((section) => section.enabled)
.map(getSectionId);
return (
<DropdownMenu modal>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
<i className="bi-sliders2-vertical text-neutral" />
{t("edit_layout")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-72 pt-1 px-0 pb-0 select-none"
align="end"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 mx-2">
<p className="text-xs font-bold text-neutral mb-1">
{t("display_on_dashboard")}
</p>
<TextInput
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="py-0 bg-base-100"
placeholder={t("search")}
/>
</div>
<DndContext
modifiers={[restrictToParentElement]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={sortableItems}
strategy={verticalListSortingStrategy}
>
<ul className="max-h-60 overflow-y-auto px-2 pb-2">
{filteredSections.map((section) => {
const color =
section.type === "COLLECTION"
? collections.find((c) => c.id === section.collectionId)
?.color
: undefined;
return (
<DraggableListItem
key={getSectionId(section)}
section={{ ...section, color }}
onCheckboxChange={handleCheckboxChange}
/>
);
})}
{filteredSections.length === 0 && (
<li className="text-sm py-2 text-center text-neutral">
{t("no_results_found")}
</li>
)}
</ul>
</SortableContext>
</DndContext>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface DraggableListItemProps {
section: DashboardSectionOption & { color?: string };
onCheckboxChange: (section: DashboardSectionOption) => void;
}
function DraggableListItem({
section,
onCheckboxChange,
}: DraggableListItemProps) {
const sectionId = `${section.type}-${section.collectionId ?? "default"}`;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sectionId,
disabled: !section.enabled,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={cn(
"select-none py-1 px-1 flex items-center justify-between",
section.enabled
? "cursor-grab active:cursor-grabbing"
: "cursor-default",
isDragging && "opacity-50"
)}
>
<div className="flex items-center gap-2">
<input
id={`section-${section.type}-${section.collectionId ?? "default"}`}
className="checkbox checkbox-primary"
type="checkbox"
checked={section.enabled}
onChange={() => onCheckboxChange(section)}
/>
<label
htmlFor={`section-${section.type}-${
section.collectionId ?? "default"
}`}
className={`text-sm pointer-events-none ${
section.enabled ? "opacity-100" : "opacity-50"
}`}
>
<i
className={`bi-${
section.type === "STATS"
? "bar-chart-line"
: section.type === "RECENT_LINKS"
? "clock"
: section.type === "PINNED_LINKS"
? "pin"
: "folder-fill"
} ${section.type !== "COLLECTION" ? "text-primary" : ""} mr-1`}
style={
section.type === "COLLECTION" ? { color: section.color } : {}
}
/>
{section.name}
</label>
</div>
<i
className={`bi-grip-vertical text-neutral ${
section.enabled ? "opacity-100" : "opacity-50"
}`}
/>
</li>
);
}

View File

@@ -1,239 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import useLocalSettingsStore from "@/store/localSettings";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
} from "@linkwarden/types/global";
import { useEffect, useRef, 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 Image from "next/image";
import {
atLeastOneFormatAvailable,
formatAvailable,
} from "@linkwarden/lib/formatStats";
import useOnScreen from "@/hooks/useOnScreen";
import { useCollections } from "@linkwarden/router/collections";
import { useUser } from "@linkwarden/router/user";
import { useGetLink } from "@linkwarden/router/links";
import { useRouter } from "next/router";
import openLink from "@/lib/client/openLink";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import LinkFormats from "./LinkViews/LinkComponents/LinkFormats";
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/utils";
import { useTranslation } from "next-i18next";
export function DashboardLinks({
links,
isLoading,
type,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
isLoading?: boolean;
type?: "collection" | "recent";
}) {
return (
<div
className={`flex gap-5 overflow-x-auto overflow-y-hidden hide-scrollbar w-full min-h-fit`}
>
{isLoading ? (
<div className="flex flex-col gap-4 min-w-60 w-60">
<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>
) : (
links?.map((e, i) => <Card key={i} link={e} dashboardType={type} />)
)}
</div>
);
}
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
editMode?: boolean;
dashboardType?: "collection" | "recent";
};
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,
},
});
const { data: collections = [] } = useCollections();
const { data: user } = useUser();
const {
settings: { show },
} = useLocalSettingsStore();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const { refetch } = useGetLink({ id: link.id as number, isPublicRoute });
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, link]);
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
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]);
return (
<div
ref={setNodeRef}
className={cn(
isDragging ? "opacity-30" : "opacity-100",
"relative group touch-manipulation select-none"
)}
>
<div
ref={ref}
className={`min-w-60 w-60 border border-solid border-neutral-content bg-base-200 duration-100 rounded-xl relative group h-full`}
>
<div
className="rounded-xl cursor-pointer h-full w-full flex flex-col justify-between"
onClick={() =>
!editMode && openLink(link, user, () => setLinkModal(true))
}
{...listeners}
{...attributes}
>
{show.image && (
<div>
<div className={`relative rounded-t-xl h-40 overflow-hidden`}>
{formatAvailable(link, "preview") ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className={`rounded-t-xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105`}
style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false"
onError={(e) => {
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>
) : (
<div
className={`h-40 bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
{show.preserved_formats &&
link.type === "url" &&
atLeastOneFormatAvailable(link) && (
<div className="absolute bottom-0 right-0 m-2 bg-base-200 bg-opacity-60 px-1 rounded-md">
<LinkFormats link={link} />
</div>
)}
</div>
<Separator />
</div>
)}
<div className="flex flex-col justify-between h-full min-h-11">
<div className="p-3 flex flex-col justify-between h-full gap-2">
{show.name && (
<p className="line-clamp-2 w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<div>
<Separator className="mb-1" />
<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}
isPublicRoute={false}
/>
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
</div>
{/* Overlay on hover */}
<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}
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"
/>
{!isPublicRoute && <LinkPin link={link} />}
</div>
</div>
);
}

View File

@@ -1,258 +0,0 @@
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
SensorDescriptor,
SensorOptions,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import { LinkIncludingShortenedCollectionAndTags } from "@linkwarden/types/global";
import toast from "react-hot-toast";
import { useUpdateLink } from "@linkwarden/router/links";
import { useTranslation } from "react-i18next";
import { snapCenterToCursor } from "@dnd-kit/modifiers";
import { customCollisionDetectionAlgorithm } from "@/lib/utils";
import usePinLink from "@/lib/client/pinLink";
import { useQueryClient } from "@tanstack/react-query";
import { useUser } from "@linkwarden/router/user";
interface DragNDropProps {
children: React.ReactNode;
/**
* The currently active link being dragged
*/
activeLink: LinkIncludingShortenedCollectionAndTags | null;
/**
* All links available for drag and drop
*/
setActiveLink: (link: LinkIncludingShortenedCollectionAndTags | null) => void;
/**
* Override the default sensors used for drag and drop.
*/
sensors?: SensorDescriptor<SensorOptions>[];
/**
* Override onDragEnd function.
*/
onDragEnd?: (event: DragEndEvent) => void;
}
/**
* Wrapper component for drag and drop functionality.
*/
export default function DragNDrop({
children,
activeLink,
setActiveLink,
sensors: sensorProp,
onDragEnd: onDragEndProp,
}: DragNDropProps) {
const { t } = useTranslation();
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: {
distance: 10,
},
});
const touchSensor = useSensor(TouchSensor, {
// Press delay of 250ms, with tolerance of 5px of movement
activationConstraint: {
delay: 200,
tolerance: 5,
},
});
const sensors = useSensors(mouseSensor, touchSensor);
const handleDragStart = (event: DragStartEvent) => {
setActiveLink(
(event.active.data.current
?.link as LinkIncludingShortenedCollectionAndTags) ?? null
);
};
const handleDragOverCancel = () => {
setActiveLink(null);
};
const handleDragEnd = async (event: DragEndEvent) => {
if (onDragEndProp) {
onDragEndProp(event);
return;
}
const { over, active } = event;
if (!over || !activeLink) return;
const overData = over.data.current;
const targetId = String(over.id);
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;
}
const allTags: { name: string }[] = (activeLink.tags ?? []).map(
(tag) => ({
name: tag.name,
})
);
const updatedLink: LinkIncludingShortenedCollectionAndTags = {
...activeLink,
tags: [...allTags, { name: tagName }] as any,
};
await mutateWithToast(updatedLink, {
invalidateDashboardOnError: typeof queryClient !== "undefined",
});
return;
}
// 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}
onDragEnd={handleDragEnd}
onDragCancel={handleDragOverCancel}
modifiers={[snapCenterToCursor]}
sensors={sensorProp ? sensorProp : sensors}
collisionDetection={customCollisionDetectionAlgorithm}
>
{!!activeLink && (
// when drag end, immediately hide the overlay
<DragOverlay
style={{
zIndex: 100,
pointerEvents: "none",
}}
>
<div className="w-fit h-fit">
<LinkIcon link={activeLink} />
</div>
</DragOverlay>
)}
{children}
</DndContext>
);
}

View File

@@ -1,50 +0,0 @@
import { cn } from "@/lib/utils";
import { useDroppable } from "@dnd-kit/core";
const Droppable = ({
children,
id,
data,
className,
}: {
children: React.ReactNode;
id: string;
data?: {
/**
* Id of collection or tag to drop into.
*/
id?: string;
/**
* Name of collection or tag to drop into.
*/
name?: string;
ownerId?: string;
type?: "collection" | "tag";
};
className?: string;
}) => {
const { setNodeRef, isOver } = useDroppable({
id,
data,
});
return (
<div
ref={setNodeRef}
className={cn(
isOver &&
"bg-primary/10 outline-2 outline-dashed outline-primary rounded-lg",
className
)}
data-over={isOver ? "true" : undefined}
style={{
position: "relative",
zIndex: isOver ? 1 : "auto",
}}
>
{children}
</div>
);
};
export default Droppable;

View File

@@ -1,104 +0,0 @@
import React from "react";
import Drawer from "./Drawer";
import {
useGetLinkHighlights,
useRemoveHighlight,
} from "@linkwarden/router/highlights";
import { useRouter } from "next/router";
import clsx from "clsx";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { Button } from "./ui/button";
import { Separator } from "./ui/separator";
type Props = {
onClose: Function;
};
const HighlightDrawer = ({ onClose }: Props) => {
const router = useRouter();
const { t } = useTranslation();
const { data } = useGetLinkHighlights(Number(router.query.id));
const removeHighlight = useRemoveHighlight(Number(router.query.id));
return (
<Drawer
toggleDrawer={onClose}
className="sm:h-screen items-center relative"
direction="left"
>
<div>
<h2 className="text-lg font-semibold">{t("notes_highlights")}</h2>
<Separator className="my-5" />
{data && data.length > 0 ? (
data.map((highlight) => {
const formattedDate = new Date(highlight.createdAt).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
return (
<Link key={highlight.id} href={`#highlight-${highlight.id}`}>
<div
className={clsx(
"p-2 mb-4 border-l-2 duration-150 cursor-pointer flex flex-col gap-1 relative group",
highlight.color === "yellow"
? "border-yellow-500"
: highlight.color === "green"
? "border-green-500"
: highlight.color === "blue"
? "border-blue-500"
: "border-red-500"
)}
>
<p
className={clsx(
"w-fit px-2 rounded-md mr-10",
highlight.color === "yellow"
? "bg-yellow-500/70"
: highlight.color === "green"
? "bg-green-500/70"
: highlight.color === "blue"
? "bg-blue-500/70"
: "bg-red-500/70"
)}
>
{highlight.text}
</p>
{highlight.comment && <p>{highlight.comment}</p>}
<p
className="text-xs text-neutral"
title={String(highlight.createdAt)}
>
{formattedDate}
</p>
<Button
className="absolute top-2 right-2 text-neutral hover:text-red-500 group-hover:opacity-100 opacity-0 transition-opacity duration-150"
variant="ghost"
size="icon"
onClick={(e) => {
e.preventDefault();
removeHighlight.mutate(highlight.id);
}}
>
<i className="bi-trash" />
</Button>
</div>
</Link>
);
})
) : (
<div className="text-neutral text-sm">{t("no_notes_highlights")}</div>
)}
</div>
</Drawer>
);
};
export default HighlightDrawer;

View File

@@ -1,18 +0,0 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
Icon.displayName = "Icon";
export default Icon;

Some files were not shown because too many files have changed in this diff Show More