Compare commits
2 Commits
e74b02e3a2
...
8d1f91d636
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d1f91d636 | |||
| 7e4c48469a |
@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
|
|||||||
NEXTAUTH_SECRET=SECRET
|
NEXTAUTH_SECRET=SECRET
|
||||||
#openssl rand -base64 32
|
#openssl rand -base64 32
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
|
||||||
|
# STRIPSTREAM_URL=https://librarian.example.com
|
||||||
|
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx
|
||||||
23
Dockerfile
23
Dockerfile
@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
|
|||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
|
||||||
# Copy configuration files
|
# Copy configuration files
|
||||||
COPY tsconfig.json .eslintrc.json ./
|
COPY tsconfig.json .eslintrc.json next.config.js ./
|
||||||
COPY tailwind.config.ts postcss.config.js ./
|
COPY tailwind.config.ts postcss.config.js ./
|
||||||
|
|
||||||
# Install dependencies with pnpm using cache mount for store
|
# Install dependencies with pnpm using cache mount for store
|
||||||
@@ -43,22 +43,20 @@ WORKDIR /app
|
|||||||
# Install OpenSSL (required by Prisma)
|
# Install OpenSSL (required by Prisma)
|
||||||
RUN apk add --no-cache openssl libc6-compat
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
# Copy package files and prisma schema
|
# Copy standalone output (server.js + minimal node_modules)
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY prisma ./prisma
|
|
||||||
|
|
||||||
# Enable pnpm
|
# Copy static assets and public directory
|
||||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
# Copy the entire node_modules from builder (includes Prisma Client)
|
# Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy prisma schema and init scripts
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY prisma ./prisma
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder /app/next-env.d.ts ./
|
|
||||||
COPY --from=builder /app/tailwind.config.ts ./
|
|
||||||
COPY --from=builder /app/scripts ./scripts
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
# Copy entrypoint script
|
# Copy entrypoint script
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
@@ -76,6 +74,7 @@ USER nextjs
|
|||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose the port the app runs on
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
|
|||||||
## 🛠 Prerequisites
|
## 🛠 Prerequisites
|
||||||
|
|
||||||
- Node.js 20.x or higher
|
- Node.js 20.x or higher
|
||||||
- Yarn 1.22.x or higher
|
- pnpm 9.x or higher
|
||||||
- Docker and Docker Compose (optional)
|
- Docker and Docker Compose (optional)
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
@@ -91,7 +91,7 @@ cd stripstream
|
|||||||
2. Install dependencies
|
2. Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Copy the example environment file and adjust it to your needs
|
3. Copy the example environment file and adjust it to your needs
|
||||||
@@ -103,7 +103,7 @@ cp .env.example .env.local
|
|||||||
4. Start the development server
|
4. Start the development server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Docker (Build Local)
|
### With Docker (Build Local)
|
||||||
@@ -121,7 +121,7 @@ cd stripstream
|
|||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be accessible at `http://localhost:3000`
|
The application will be accessible at `http://localhost:3020`
|
||||||
|
|
||||||
### With Docker (DockerHub Image)
|
### With Docker (DockerHub Image)
|
||||||
|
|
||||||
@@ -130,18 +130,24 @@ You can also use the pre-built image from DockerHub without cloning the reposito
|
|||||||
1. Create a `docker-compose.yml` file:
|
1. Create a `docker-compose.yml` file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: julienfroidefond32/stripstream:latest
|
image: julienfroidefond32/stripstream:latest
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
# Required
|
||||||
# Add your environment variables here or use an .env file
|
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
|
||||||
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Optional — defaults shown
|
||||||
|
# - NODE_ENV=production
|
||||||
|
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
|
||||||
|
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
|
||||||
|
# - AUTH_TRUST_HOST=true
|
||||||
|
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/prisma/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -155,11 +161,10 @@ The application will be accessible at `http://localhost:3000`
|
|||||||
|
|
||||||
## 🔧 Available Scripts
|
## 🔧 Available Scripts
|
||||||
|
|
||||||
- `yarn dev` - Starts the development server
|
- `pnpm dev` - Starts the development server
|
||||||
- `yarn build` - Creates a production build
|
- `pnpm build` - Creates a production build
|
||||||
- `yarn start` - Runs the production version
|
- `pnpm start` - Runs the production version
|
||||||
- `yarn lint` - Checks code with ESLint
|
- `pnpm lint` - Checks code with ESLint
|
||||||
- `yarn format` - Formats code with Prettier
|
|
||||||
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
|
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
|
||||||
|
|
||||||
### Docker Push Script
|
### Docker Push Script
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "📁 Ensuring data directory exists..."
|
echo "🔄 Applying database migrations..."
|
||||||
mkdir -p /app/data
|
./node_modules/.bin/prisma migrate deploy
|
||||||
|
|
||||||
echo "🔄 Pushing Prisma schema to database..."
|
|
||||||
npx prisma db push --skip-generate --accept-data-loss
|
|
||||||
|
|
||||||
echo "🔧 Initializing database..."
|
echo "🔧 Initializing database..."
|
||||||
node scripts/init-db.mjs
|
node scripts/init-db.mjs
|
||||||
|
|
||||||
echo "🚀 Starting application..."
|
echo "🚀 Starting application..."
|
||||||
exec pnpm start
|
exec node server.js
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback,
|
...config.resolve.fallback,
|
||||||
|
|||||||
77
prisma/migrations/20260311203728_init/migration.sql
Normal file
77
prisma/migrations/20260311203728_init/migration.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"roles" JSONB NOT NULL DEFAULT ["ROLE_USER"],
|
||||||
|
"authenticated" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"activeProvider" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "komgaconfigs" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"authHeader" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "komgaconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "stripstreamconfigs" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "stripstreamconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "preferences" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"showThumbnails" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showOnlyUnread" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"displayMode" JSONB NOT NULL,
|
||||||
|
"background" JSONB NOT NULL,
|
||||||
|
"readerPrefetchCount" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "favorites" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"seriesId" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL DEFAULT 'komga',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "komgaconfigs_userId_key" ON "komgaconfigs"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "stripstreamconfigs_userId_key" ON "stripstreamconfigs"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "preferences_userId_key" ON "preferences"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "favorites_userId_idx" ON "favorites"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "favorites_userId_provider_seriesId_key" ON "favorites"("userId", "provider", "seriesId");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentUser } from "@/lib/auth-utils";
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
|
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
|
||||||
|
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { ProviderType } from "@/lib/providers/types";
|
import type { ProviderType } from "@/lib/providers/types";
|
||||||
@@ -82,7 +83,7 @@ export async function setActiveProvider(
|
|||||||
return { success: false, message: "Komga n'est pas encore configuré" };
|
return { success: false, message: "Komga n'est pas encore configuré" };
|
||||||
}
|
}
|
||||||
} else if (provider === "stripstream") {
|
} else if (provider === "stripstream") {
|
||||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
const config = await getResolvedStripstreamConfig(userId);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return { success: false, message: "Stripstream n'est pas encore configuré" };
|
return { success: false, message: "Stripstream n'est pas encore configuré" };
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,8 @@ export async function setActiveProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère la configuration Stripstream de l'utilisateur
|
* Récupère la configuration Stripstream de l'utilisateur (affichage settings).
|
||||||
|
* Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN.
|
||||||
*/
|
*/
|
||||||
export async function getStripstreamConfig(): Promise<{
|
export async function getStripstreamConfig(): Promise<{
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -119,13 +121,9 @@ export async function getStripstreamConfig(): Promise<{
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
const userId = parseInt(user.id, 10);
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
const config = await prisma.stripstreamConfig.findUnique({
|
const resolved = await getResolvedStripstreamConfig(userId);
|
||||||
where: { userId },
|
if (!resolved) return null;
|
||||||
select: { url: true },
|
return { url: resolved.url, hasToken: true };
|
||||||
});
|
|
||||||
|
|
||||||
if (!config) return null;
|
|
||||||
return { url: config.url, hasToken: true };
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -166,15 +164,15 @@ export async function getProvidersStatus(): Promise<{
|
|||||||
}
|
}
|
||||||
const userId = parseInt(user.id, 10);
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([
|
const [dbUser, komgaConfig, stripstreamResolved] = await Promise.all([
|
||||||
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
|
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
|
||||||
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
|
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||||
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }),
|
getResolvedStripstreamConfig(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
komgaConfigured: !!komgaConfig,
|
komgaConfigured: !!komgaConfig,
|
||||||
stripstreamConfigured: !!stripstreamConfig,
|
stripstreamConfigured: !!stripstreamResolved,
|
||||||
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
|
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth-utils";
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
import prisma from "@/lib/prisma";
|
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||||
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
@@ -23,7 +23,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = parseInt(user.id, 10);
|
const userId = parseInt(user.id, 10);
|
||||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
const config = await getResolvedStripstreamConfig(userId);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getCurrentUser } from "@/lib/auth-utils";
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
import prisma from "@/lib/prisma";
|
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||||
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
@@ -20,7 +20,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = parseInt(user.id, 10);
|
const userId = parseInt(user.id, 10);
|
||||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
const config = await getResolvedStripstreamConfig(userId);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -16,7 +15,6 @@ interface LoginFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({ from }: LoginFormProps) {
|
export function LoginForm({ from }: LoginFormProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<AppErrorType | null>(null);
|
const [error, setError] = useState<AppErrorType | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -57,8 +55,7 @@ export function LoginForm({ from }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectPath = getSafeRedirectPath(from);
|
const redirectPath = getSafeRedirectPath(from);
|
||||||
window.location.assign(redirectPath);
|
window.location.href = redirectPath;
|
||||||
router.refresh();
|
|
||||||
} catch {
|
} catch {
|
||||||
setError({
|
setError({
|
||||||
code: "AUTH_FETCH_ERROR",
|
code: "AUTH_FETCH_ERROR",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -16,7 +15,6 @@ interface RegisterFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ from }: RegisterFormProps) {
|
export function RegisterForm({ from }: RegisterFormProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<AppErrorType | null>(null);
|
const [error, setError] = useState<AppErrorType | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -77,8 +75,7 @@ export function RegisterForm({ from }: RegisterFormProps) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const redirectPath = getSafeRedirectPath(from);
|
const redirectPath = getSafeRedirectPath(from);
|
||||||
window.location.assign(redirectPath);
|
window.location.href = redirectPath;
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError({
|
setError({
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
const {
|
const {
|
||||||
loadedImages,
|
loadedImages,
|
||||||
imageBlobUrls,
|
imageBlobUrls,
|
||||||
|
prefetchImage,
|
||||||
prefetchPages,
|
prefetchPages,
|
||||||
prefetchNextBook,
|
prefetchNextBook,
|
||||||
cancelAllPrefetches,
|
cancelAllPrefetches,
|
||||||
handleForceReload,
|
handleForceReload,
|
||||||
getPageUrl,
|
getPageUrl,
|
||||||
prefetchCount,
|
prefetchCount,
|
||||||
|
isPageLoading,
|
||||||
} = useImageLoader({
|
} = useImageLoader({
|
||||||
pageUrlBuilder: bookPageUrlBuilder,
|
pageUrlBuilder: bookPageUrlBuilder,
|
||||||
pages,
|
pages,
|
||||||
@@ -87,8 +89,26 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
|
|
||||||
// Prefetch current and next pages
|
// Prefetch current and next pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prefetch pages starting from current page
|
// Determine visible pages that need to be loaded immediately
|
||||||
prefetchPages(currentPage, prefetchCount);
|
const visiblePages: number[] = [];
|
||||||
|
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) {
|
||||||
|
visiblePages.push(currentPage, currentPage + 1);
|
||||||
|
} else {
|
||||||
|
visiblePages.push(currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load visible pages first (priority) to avoid duplicate requests from <img> tags
|
||||||
|
// These will populate imageBlobUrls so <img> tags use blob URLs instead of making HTTP requests
|
||||||
|
const loadVisiblePages = async () => {
|
||||||
|
await Promise.all(visiblePages.map((page) => prefetchImage(page)));
|
||||||
|
};
|
||||||
|
loadVisiblePages().catch(() => {
|
||||||
|
// Silently fail - will fallback to direct HTTP requests
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then prefetch other pages, excluding visible ones to avoid duplicates
|
||||||
|
const concurrency = isDoublePage && shouldShowDoublePage(currentPage, pages.length) ? 2 : 4;
|
||||||
|
prefetchPages(currentPage, prefetchCount, visiblePages, concurrency);
|
||||||
|
|
||||||
// If double page mode, also prefetch additional pages for smooth double page navigation
|
// If double page mode, also prefetch additional pages for smooth double page navigation
|
||||||
if (
|
if (
|
||||||
@@ -96,7 +116,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
shouldShowDoublePage(currentPage, pages.length) &&
|
shouldShowDoublePage(currentPage, pages.length) &&
|
||||||
currentPage + prefetchCount < pages.length
|
currentPage + prefetchCount < pages.length
|
||||||
) {
|
) {
|
||||||
prefetchPages(currentPage + prefetchCount, 1);
|
prefetchPages(currentPage + prefetchCount, 1, visiblePages, concurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're near the end of the book, prefetch the next book
|
// If we're near the end of the book, prefetch the next book
|
||||||
@@ -108,6 +128,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
currentPage,
|
currentPage,
|
||||||
isDoublePage,
|
isDoublePage,
|
||||||
shouldShowDoublePage,
|
shouldShowDoublePage,
|
||||||
|
prefetchImage,
|
||||||
prefetchPages,
|
prefetchPages,
|
||||||
prefetchNextBook,
|
prefetchNextBook,
|
||||||
prefetchCount,
|
prefetchCount,
|
||||||
@@ -229,6 +250,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
imageBlobUrls={imageBlobUrls}
|
imageBlobUrls={imageBlobUrls}
|
||||||
getPageUrl={getPageUrl}
|
getPageUrl={getPageUrl}
|
||||||
isRTL={isRTL}
|
isRTL={isRTL}
|
||||||
|
isPageLoading={isPageLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavigationBar
|
<NavigationBar
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface PageDisplayProps {
|
|||||||
imageBlobUrls: Record<number, string>;
|
imageBlobUrls: Record<number, string>;
|
||||||
getPageUrl: (pageNum: number) => string;
|
getPageUrl: (pageNum: number) => string;
|
||||||
isRTL: boolean;
|
isRTL: boolean;
|
||||||
|
isPageLoading?: (pageNum: number) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageDisplay({
|
export function PageDisplay({
|
||||||
@@ -19,6 +20,7 @@ export function PageDisplay({
|
|||||||
imageBlobUrls,
|
imageBlobUrls,
|
||||||
getPageUrl,
|
getPageUrl,
|
||||||
isRTL,
|
isRTL,
|
||||||
|
isPageLoading,
|
||||||
}: PageDisplayProps) {
|
}: PageDisplayProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
@@ -102,7 +104,10 @@ export function PageDisplay({
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
|
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
|
||||||
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
|
src={
|
||||||
|
imageBlobUrls[currentPage] ||
|
||||||
|
(isPageLoading && isPageLoading(currentPage) ? undefined : getPageUrl(currentPage))
|
||||||
|
}
|
||||||
alt={`Page ${currentPage}`}
|
alt={`Page ${currentPage}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
||||||
@@ -166,7 +171,12 @@ export function PageDisplay({
|
|||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
|
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
|
||||||
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
|
src={
|
||||||
|
imageBlobUrls[currentPage + 1] ||
|
||||||
|
(isPageLoading && isPageLoading(currentPage + 1)
|
||||||
|
? undefined
|
||||||
|
: getPageUrl(currentPage + 1))
|
||||||
|
}
|
||||||
alt={`Page ${currentPage + 1}`}
|
alt={`Page ${currentPage + 1}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export function useImageLoader({
|
|||||||
// Track ongoing fetch requests to prevent duplicates
|
// Track ongoing fetch requests to prevent duplicates
|
||||||
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
|
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
|
||||||
const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map());
|
const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map());
|
||||||
|
// Track promises for pages being loaded so we can await them
|
||||||
|
const loadingPromisesRef = useRef<Map<ImageKey, Promise<void>>>(new Map());
|
||||||
|
|
||||||
// Keep refs in sync with state
|
// Keep refs in sync with state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,12 +46,14 @@ export function useImageLoader({
|
|||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
const abortControllers = abortControllersRef.current;
|
const abortControllers = abortControllersRef.current;
|
||||||
const pendingFetches = pendingFetchesRef.current;
|
const pendingFetches = pendingFetchesRef.current;
|
||||||
|
const loadingPromises = loadingPromisesRef.current;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
abortControllers.forEach((controller) => controller.abort());
|
abortControllers.forEach((controller) => controller.abort());
|
||||||
abortControllers.clear();
|
abortControllers.clear();
|
||||||
pendingFetches.clear();
|
pendingFetches.clear();
|
||||||
|
loadingPromises.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -57,6 +61,7 @@ export function useImageLoader({
|
|||||||
abortControllersRef.current.forEach((controller) => controller.abort());
|
abortControllersRef.current.forEach((controller) => controller.abort());
|
||||||
abortControllersRef.current.clear();
|
abortControllersRef.current.clear();
|
||||||
pendingFetchesRef.current.clear();
|
pendingFetchesRef.current.clear();
|
||||||
|
loadingPromisesRef.current.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runWithConcurrency = useCallback(
|
const runWithConcurrency = useCallback(
|
||||||
@@ -92,16 +97,18 @@ export function useImageLoader({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this page is already being fetched
|
// Check if this page is already being fetched - if so, wait for it
|
||||||
if (pendingFetchesRef.current.has(pageNum)) {
|
const existingPromise = loadingPromisesRef.current.get(pageNum);
|
||||||
return;
|
if (existingPromise) {
|
||||||
|
return existingPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as pending
|
// Mark as pending and create promise
|
||||||
pendingFetchesRef.current.add(pageNum);
|
pendingFetchesRef.current.add(pageNum);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllersRef.current.set(pageNum, controller);
|
abortControllersRef.current.set(pageNum, controller);
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
// Use browser cache if available - the server sets Cache-Control headers
|
// Use browser cache if available - the server sets Cache-Control headers
|
||||||
const response = await fetch(getPageUrl(pageNum), {
|
const response = await fetch(getPageUrl(pageNum), {
|
||||||
@@ -117,9 +124,13 @@ export function useImageLoader({
|
|||||||
|
|
||||||
// Create image to get dimensions
|
// Create image to get dimensions
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
|
|
||||||
|
// Wait for image to load before resolving promise
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
if (!isMountedRef.current || controller.signal.aborted) {
|
if (!isMountedRef.current || controller.signal.aborted) {
|
||||||
URL.revokeObjectURL(blobUrl);
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
reject(new Error("Aborted"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,32 +144,49 @@ export function useImageLoader({
|
|||||||
...prev,
|
...prev,
|
||||||
[pageNum]: blobUrl,
|
[pageNum]: blobUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
URL.revokeObjectURL(blobUrl);
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
reject(new Error("Image load error"));
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = blobUrl;
|
img.src = blobUrl;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail prefetch
|
// Silently fail prefetch
|
||||||
} finally {
|
} finally {
|
||||||
// Remove from pending set
|
// Remove from pending set and promise map
|
||||||
pendingFetchesRef.current.delete(pageNum);
|
pendingFetchesRef.current.delete(pageNum);
|
||||||
abortControllersRef.current.delete(pageNum);
|
abortControllersRef.current.delete(pageNum);
|
||||||
|
loadingPromisesRef.current.delete(pageNum);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Store promise so other calls can await it
|
||||||
|
loadingPromisesRef.current.set(pageNum, promise);
|
||||||
|
|
||||||
|
return promise;
|
||||||
},
|
},
|
||||||
[getPageUrl]
|
[getPageUrl]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch multiple pages starting from a given page
|
// Prefetch multiple pages starting from a given page
|
||||||
const prefetchPages = useCallback(
|
const prefetchPages = useCallback(
|
||||||
async (startPage: number, count: number = prefetchCount) => {
|
async (
|
||||||
|
startPage: number,
|
||||||
|
count: number = prefetchCount,
|
||||||
|
excludePages: number[] = [],
|
||||||
|
concurrency?: number
|
||||||
|
) => {
|
||||||
const pagesToPrefetch = [];
|
const pagesToPrefetch = [];
|
||||||
|
const excludeSet = new Set(excludePages);
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const pageNum = startPage + i;
|
const pageNum = startPage + i;
|
||||||
if (pageNum <= _pages.length) {
|
if (pageNum <= _pages.length && !excludeSet.has(pageNum)) {
|
||||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||||
const isPending = pendingFetchesRef.current.has(pageNum);
|
const isPending = pendingFetchesRef.current.has(pageNum);
|
||||||
@@ -170,10 +198,13 @@ export function useImageLoader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use provided concurrency or default
|
||||||
|
const effectiveConcurrency = concurrency ?? PREFETCH_CONCURRENCY;
|
||||||
|
|
||||||
// Let all prefetch requests run - the server queue will manage concurrency
|
// Let all prefetch requests run - the server queue will manage concurrency
|
||||||
// The browser cache and our deduplication prevent redundant requests
|
// The browser cache and our deduplication prevent redundant requests
|
||||||
if (pagesToPrefetch.length > 0) {
|
if (pagesToPrefetch.length > 0) {
|
||||||
runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => {
|
runWithConcurrency(pagesToPrefetch, prefetchImage, effectiveConcurrency).catch(() => {
|
||||||
// Silently fail - prefetch is non-critical
|
// Silently fail - prefetch is non-critical
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -340,6 +371,14 @@ export function useImageLoader({
|
|||||||
};
|
};
|
||||||
}, []); // Empty dependency array - only cleanup on unmount
|
}, []); // Empty dependency array - only cleanup on unmount
|
||||||
|
|
||||||
|
// Check if a page is currently being loaded
|
||||||
|
const isPageLoading = useCallback(
|
||||||
|
(pageNum: number) => {
|
||||||
|
return pendingFetchesRef.current.has(pageNum);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadedImages,
|
loadedImages,
|
||||||
imageBlobUrls,
|
imageBlobUrls,
|
||||||
@@ -350,5 +389,6 @@ export function useImageLoader({
|
|||||||
handleForceReload,
|
handleForceReload,
|
||||||
getPageUrl,
|
getPageUrl,
|
||||||
prefetchCount,
|
prefetchCount,
|
||||||
|
isPageLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getCurrentUser } from "@/lib/auth-utils";
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
|
import { getResolvedStripstreamConfig } from "./stripstream/stripstream-config-resolver";
|
||||||
import type { IMediaProvider } from "./provider.interface";
|
import type { IMediaProvider } from "./provider.interface";
|
||||||
|
|
||||||
export async function getProvider(): Promise<IMediaProvider | null> {
|
export async function getProvider(): Promise<IMediaProvider | null> {
|
||||||
@@ -13,7 +14,7 @@ export async function getProvider(): Promise<IMediaProvider | null> {
|
|||||||
select: {
|
select: {
|
||||||
activeProvider: true,
|
activeProvider: true,
|
||||||
config: { select: { url: true, authHeader: true } },
|
config: { select: { url: true, authHeader: true } },
|
||||||
stripstreamConfig: { select: { url: true, token: true } },
|
stripstreamConfig: { select: { id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,12 +22,12 @@ export async function getProvider(): Promise<IMediaProvider | null> {
|
|||||||
|
|
||||||
const activeProvider = dbUser.activeProvider ?? "komga";
|
const activeProvider = dbUser.activeProvider ?? "komga";
|
||||||
|
|
||||||
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) {
|
if (activeProvider === "stripstream") {
|
||||||
|
const resolved = await getResolvedStripstreamConfig(userId);
|
||||||
|
if (resolved) {
|
||||||
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
|
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
|
||||||
return new StripstreamProvider(
|
return new StripstreamProvider(resolved.url, resolved.token);
|
||||||
dbUser.stripstreamConfig.url,
|
}
|
||||||
dbUser.stripstreamConfig.token
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeProvider === "komga" || !dbUser.activeProvider) {
|
if (activeProvider === "komga" || !dbUser.activeProvider) {
|
||||||
|
|||||||
26
src/lib/providers/stripstream/stripstream-config-resolver.ts
Normal file
26
src/lib/providers/stripstream/stripstream-config-resolver.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export interface ResolvedStripstreamConfig {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
source: "db" | "env";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout la config Stripstream : d'abord en base (par utilisateur), sinon depuis les env STRIPSTREAM_URL et STRIPSTREAM_TOKEN.
|
||||||
|
*/
|
||||||
|
export async function getResolvedStripstreamConfig(
|
||||||
|
userId: number
|
||||||
|
): Promise<ResolvedStripstreamConfig | null> {
|
||||||
|
const fromDb = await prisma.stripstreamConfig.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: { url: true, token: true },
|
||||||
|
});
|
||||||
|
if (fromDb) return { ...fromDb, source: "db" };
|
||||||
|
|
||||||
|
const url = process.env.STRIPSTREAM_URL?.trim();
|
||||||
|
const token = process.env.STRIPSTREAM_TOKEN?.trim();
|
||||||
|
if (url && token) return { url, token, source: "env" };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
4
src/types/env.d.ts
vendored
4
src/types/env.d.ts
vendored
@@ -3,5 +3,9 @@ declare namespace NodeJS {
|
|||||||
NEXT_PUBLIC_APP_URL: string;
|
NEXT_PUBLIC_APP_URL: string;
|
||||||
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
|
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
|
||||||
NEXT_PUBLIC_APP_VERSION: string;
|
NEXT_PUBLIC_APP_VERSION: string;
|
||||||
|
/** URL Stripstream Librarian (fallback si pas de config en base) */
|
||||||
|
STRIPSTREAM_URL?: string;
|
||||||
|
/** Token API Stripstream (fallback si pas de config en base) */
|
||||||
|
STRIPSTREAM_TOKEN?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user