Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test": "npm run lint && npm run typecheck && npm run test:auth && npm run test:formats && npm run test:account && npm run test:security",
"test:security": "node --test src/lib/security.test.mts",
"test:site:browser": "node --env-file=.env.local --test src/lib/site-browser.test.mts",
"test:guest:browser": "node --test src/lib/guest-browser.test.mts",
"test:account": "node --test src/lib/account.test.mts",
"test:account:db": "node --env-file=.env.local --test src/lib/account-integration.test.mts",
"test:account:browser": "node --env-file=.env.local --test src/lib/account-browser.test.mts",
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/app/compress-image/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from "next";

import { ImageCompressor } from "@/components/image-compressor";
import { requireAccount } from "@/lib/account-server";

export const metadata: Metadata = {
title: "Batch Image Compressor | Compress JPG, PNG & WebP",
Expand All @@ -25,8 +24,7 @@ export const metadata: Metadata = {
},
};

export default async function CompressImagePage() {
await requireAccount();
export default function CompressImagePage() {
return (
<>
<main id="main-content">
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/app/compress-pdf/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from "next";

import { PdfCompressor } from "@/components/pdf-compressor";
import { requireAccount } from "@/lib/account-server";

export const metadata: Metadata = {
title: "Compress PDF Online",
Expand All @@ -25,8 +24,7 @@ export const metadata: Metadata = {
},
};

export default async function CompressPdfPage() {
await requireAccount();
export default function CompressPdfPage() {
return (
<>
<main id="main-content">
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/app/merge-pdf/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from "next";

import { PdfMerger } from "@/components/pdf-merger";
import { requireAccount } from "@/lib/account-server";

export const metadata: Metadata = {
title: "Merge PDF Online",
Expand All @@ -25,8 +24,7 @@ export const metadata: Metadata = {
},
};

export default async function MergePdfPage() {
await requireAccount();
export default function MergePdfPage() {
return (
<>
<main id="main-content">
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/app/optimize-svg/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { SvgOptimizer } from "@/components/svg-optimizer";
import { requireAccount } from "@/lib/account-server";
import { FlowButton } from "@/components/ui/flow-button";

export const metadata: Metadata = {
Expand All @@ -25,8 +24,7 @@ export const metadata: Metadata = {
},
};

export default async function OptimizeSvgPage() {
await requireAccount();
export default function OptimizeSvgPage() {
return (
<>
<main id="main-content">
Expand Down
40 changes: 10 additions & 30 deletions apps/web/src/components/conversion-landing-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { ImageToPdfConverter } from "@/components/image-to-pdf-converter";
import { ArrowIcon, RouteIcon } from "@/components/icons";
import { SupportedFormats } from "@/components/supported-formats";
import { FlowButton } from "@/components/ui/flow-button";
import { TypingAnimation } from "@/components/ui/typing-animation";
import { GUIDES } from "@/lib/guides";
import { getConversionContent } from "@/lib/conversion-content";
import {
Expand Down Expand Up @@ -148,35 +147,16 @@ export function ConversionLandingPage({ pair }: ConversionLandingPageProps) {
<main id="main-content">
<section className="hero-section" aria-labelledby="page-title">
<div className="hero-copy hero-copy-benefit">
{pair ? (
<h1 id="page-title">{pageTitle}</h1>
) : (
<h1 id="page-title">
<span className="sr-only">
Convert files online without the fuss.
</span>
<span aria-hidden="true" className="grid">
<TypingAnimation
words={[
"Convert files online",
"Compress PDFs online",
"Optimize images online",
"Convert files online",
]}
typeSpeed={45}
deleteSpeed={25}
pauseDelay={1100}
loop={false}
startOnView={false}
showCursor={true}
blinkCursor={true}
cursorStyle="line"
reducedMotionText="Convert files online"
/>
<span>without the fuss.</span>
</span>
</h1>
)}
<h1 id="page-title">
{pair ? (
pageTitle
) : (
<>
<span className="block">Convert files online</span>
<span className="block">without the fuss.</span>
</>
)}
</h1>
<p>{pageDescription}</p>
</div>
{isImagePdfPair && pair ? (
Expand Down
86 changes: 49 additions & 37 deletions apps/web/src/components/converter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
return;
}
const result = (await response.json()) as { authenticated?: boolean };
setAccountState(result.authenticated ? "authenticated" : "signedOut");
setAccountState(
result.authenticated === true ? "authenticated" : "signedOut",
);
})
.catch(() => {
if (active) setAccountState("signedOut");
Expand Down Expand Up @@ -308,7 +310,6 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
pair &&
pairEnabled &&
readiness.ready &&
accountState === "authenticated" &&
!isBusy &&
state.status !== "completed",
);
Expand Down Expand Up @@ -367,7 +368,12 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
conversion_duration_ms: Math.round(now - timing.startedAt),
};

for (const status of ["uploading", "queued", "starting", "converting"] as const) {
for (const status of [
"uploading",
"queued",
"starting",
"converting",
] as const) {
const duration = timing.durations[status];
if (duration !== undefined) {
properties[`${status}_duration_ms`] = Math.round(duration);
Expand Down Expand Up @@ -554,26 +560,29 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
format_family: FORMATS[state.source].family,
...timingProperties,
});
try {
const historyResponse = await fetch("/api/conversion-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversion_id: response.conversion_id,
original_filename: file.name,
source_format: state.source,
target_format: state.target,
input_size: file.size,
output_size: response.size,
output_key: response.output_key,
}),
});
if (!historyResponse.ok)
console.error("Failed to save conversion history.");
} catch (historyError) {
console.error("Failed to save conversion history:", historyError);
}
// Saving account history must never delay a completed download.
dispatch({ type: "accepted", response });
if (accountState === "authenticated") {
try {
const historyResponse = await fetch("/api/conversion-history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversion_id: response.conversion_id,
original_filename: file.name,
source_format: state.source,
target_format: state.target,
input_size: file.size,
output_size: response.size,
output_key: response.output_key,
}),
});
if (!historyResponse.ok)
console.error("Failed to save conversion history.");
} catch (historyError) {
console.error("Failed to save conversion history:", historyError);
}
}
} catch (error) {
if (
requestSequenceRef.current !== requestId ||
Expand Down Expand Up @@ -720,9 +729,8 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
<div className="converter-prompt" role="status">
<RouteIcon />
<span>
Your file stays on this device until you select Convert file. Sign
in is required so your history stays with your account. Maximum
size: {FREE_FILE_LIMIT_MB} MB.
Your file uploads only when you select Convert file. Maximum size:{" "}
{FREE_FILE_LIMIT_MB} MB.
</span>
</div>
) : null}
Expand Down Expand Up @@ -817,9 +825,9 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
</div>
</div>

{accountState === "signedOut" ? (
<p className="converter-prompt" role="status">
<Link href="/login">Log in</Link> or <Link href="/signup">create an account</Link> to convert files and keep your history across Convertix apps.
{state.target === "gif" && state.status === "ready" ? (
<p className="converter-prompt">
This creates a still GIF with up to 256 colours.
</p>
) : null}

Expand Down Expand Up @@ -850,11 +858,7 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
shape="rounded"
size="lg"
text={
accountState === "checking"
? "Checking your account"
: accountState === "signedOut"
? "Log in to convert"
: !pairEnabled
!pairEnabled
? "Conversion unavailable"
: !readiness.ready
? "Conversion service unavailable"
Expand All @@ -871,6 +875,14 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
/>
)}

{state.status === "completed" && accountState === "signedOut" ? (
<p className="converter-prompt">
Want to save future conversions to your history?{" "}
<Link href="/login">Log in</Link> or{" "}
<Link href="/signup">create an account</Link>.
</p>
) : null}

{state.status === "completed" && state.downloadUrl && state.target ? (
<div
className="post-conversion-panel"
Expand Down Expand Up @@ -1010,12 +1022,12 @@ export function Converter({ initialSource, initialTarget }: ConverterProps) {
<div className="unsupported-conversion-request" aria-live="polite">
<div className="unsupported-request-copy">
<strong>
{unsupportedRequest.sourceLabel} isn&apos;t supported yet. What did
you want to convert it to?
{unsupportedRequest.sourceLabel} isn&apos;t supported yet. What
did you want to convert it to?
</strong>
<span>
This format request helps decide which conversions Convertix should
add next.
This format request helps decide which conversions Convertix
should add next.
</span>
</div>

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/format-mark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const formatGlyphs: Record<FormatId, string> = {
jpg: "JPG",
png: "PNG",
webp: "WEBP",
avif: "AVIF",
gif: "GIF",
heic: "HEIC",
heif: "HEIF",
svg: "SVG",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/supported-formats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const formatCardVariants: Partial<Record<FormatId, FormatFileProps>> = {
jpg: "jpg",
png: "png",
webp: "img",
avif: "img",
gif: "img",
heic: "img",
heif: "img",
svg: "img",
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/lib/format-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,48 @@ export interface FormatContent {
}

export const FORMAT_CONTENT: Partial<Record<FormatId, FormatContent>> = {
avif: {
summary:
"AVIF stores images using AV1 compression, with support for transparency and efficient photographic storage.",
description:
"AVIF can reduce image sizes for modern websites and apps. Convertix creates a still AVIF image from your JPG file.",
useCases: [
"Website photographs",
"Modern image delivery",
"Efficient photo storage",
],
strengths: [
"Efficient compression",
"Transparency support",
"Modern browser support",
],
considerations: [
"Older software may require JPG or PNG",
"Encoding can take longer",
"Lossy conversion cannot restore details lost in the original JPG",
],
},
gif: {
summary:
"GIF is a widely supported image format with a palette of up to 256 colours per frame.",
description:
"GIF is useful for simple graphics and animations. Converting a JPG creates a single still frame; it does not add animation.",
useCases: [
"Simple web graphics",
"Software requiring GIF files",
"Still images with a limited palette",
],
strengths: [
"Broad compatibility",
"Compact for simple graphics",
"Supports animations when multiple frames are supplied",
],
considerations: [
"JPG conversion produces a still image",
"Photographs lose colour detail in a 256-colour palette",
"JPG, WebP or AVIF often suit photographs better",
],
},
pdf: {
summary: "PDF is a document format designed to preserve page layout across devices, browsers and operating systems.",
description: "PDF is commonly used for finished documents such as CVs, invoices, reports and forms because the visual layout is intended to stay consistent when the file is shared or printed.",
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/lib/formats.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,17 @@ test("HEIC and HEIF expose the supported raster targets", () => {
assert.deepEqual(getEnabledTargets("heic"), ["jpg", "png", "webp"]);
assert.deepEqual(getEnabledTargets("heif"), ["jpg", "png", "webp"]);
});

test("JPG and JPEG offer AVIF, still GIF and HEIC outputs", () => {
assert.equal(getFormatFromFileName("photo.JPEG"), "jpg");
assert.deepEqual(getEnabledTargets("jpg"), [
"png",
"webp",
"pdf",
"avif",
"gif",
"heic",
]);
// New output formats must not advertise an unsupported upload route.
assert.doesNotMatch(ACCEPTED_FILE_EXTENSIONS, /\.(avif|gif)/);
});
Loading
Loading