rogasper commited on
Commit
81d2e69
·
1 Parent(s): d3f3189

feat: update SEO metadata and enhance Open Graph support across the web application. Add sitemap.xml for improved search engine indexing, introduce a script for compressing the Open Graph image, and refine user session handling in hooks. Update package.json for new compression script and bump Bun version.

Browse files
apps/web/index.html CHANGED
@@ -3,19 +3,26 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="description" content="Labas AI-powered multi-language test practice platform. Practice JLPT, TOPIK, TOAFL, and more with AI-generated questions." />
7
  <meta name="theme-color" content="#0c0c0c" />
8
- <meta property="og:title" content="Labas — AI Exam Practice" />
9
- <meta property="og:description" content="AI-powered multi-language test practice platform" />
10
- <meta property="og:url" content="https://labas.rogasper.com" />
11
- <meta property="og:image" content="https://labas.rogasper.com/og_image.png" />
12
  <meta property="og:type" content="website" />
 
 
 
 
 
 
13
  <meta name="twitter:card" content="summary_large_image" />
14
- <meta name="twitter:title" content="Labas — AI Exam Practice" />
15
- <meta name="twitter:description" content="AI-powered multi-language test practice platform" />
16
  <meta name="twitter:image" content="https://labas.rogasper.com/og_image.png" />
17
- <link rel="canonical" href="https://labas.rogasper.com" />
18
- <title>labas</title>
 
 
19
  </head>
20
 
21
  <body>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." />
7
  <meta name="theme-color" content="#0c0c0c" />
8
+ <meta property="og:title" content="Labas — AI-Powered Exam Practice Platform" />
9
+ <meta property="og:description" content="Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." />
10
+ <meta property="og:url" content="https://labas.rogasper.com/" />
 
11
  <meta property="og:type" content="website" />
12
+ <meta property="og:image" content="https://labas.rogasper.com/og_image.png" />
13
+ <meta property="og:image:secure_url" content="https://labas.rogasper.com/og_image.png" />
14
+ <meta property="og:image:type" content="image/png" />
15
+ <meta property="og:image:width" content="1200" />
16
+ <meta property="og:image:height" content="630" />
17
+ <meta property="og:image:alt" content="Labas — platform latihan ujian bahasa dengan AI Generator dan simulasi tes" />
18
  <meta name="twitter:card" content="summary_large_image" />
19
+ <meta name="twitter:title" content="Labas — AI-Powered Exam Practice Platform" />
20
+ <meta name="twitter:description" content="Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." />
21
  <meta name="twitter:image" content="https://labas.rogasper.com/og_image.png" />
22
+ <meta name="twitter:image:alt" content="Labas — platform latihan ujian bahasa dengan AI Generator dan simulasi tes" />
23
+ <meta name="robots" content="index, follow" />
24
+ <link rel="canonical" href="https://labas.rogasper.com/" />
25
+ <title>Labas — AI-Powered Exam Practice Platform</title>
26
  </head>
27
 
28
  <body>
apps/web/public/og_image.png CHANGED

Git LFS Details

  • SHA256: 59c312288ab9c3d5e5a2f0f301e40704816d1c139606ff8b0aa462de319aa9ea
  • Pointer size: 132 Bytes
  • Size of remote file: 1.75 MB

Git LFS Details

  • SHA256: 485a28961923c2e52527589ae19876071c1ed6da648b919f7af0d9a9fd212720
  • Pointer size: 131 Bytes
  • Size of remote file: 175 kB
apps/web/public/sitemap.xml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://labas.rogasper.com/</loc>
5
+ <changefreq>weekly</changefreq>
6
+ <priority>1.0</priority>
7
+ </url>
8
+ <url>
9
+ <loc>https://labas.rogasper.com/login</loc>
10
+ <changefreq>monthly</changefreq>
11
+ <priority>0.5</priority>
12
+ </url>
13
+ <url>
14
+ <loc>https://labas.rogasper.com/bank</loc>
15
+ <changefreq>weekly</changefreq>
16
+ <priority>0.8</priority>
17
+ </url>
18
+ <url>
19
+ <loc>https://labas.rogasper.com/packages</loc>
20
+ <changefreq>weekly</changefreq>
21
+ <priority>0.8</priority>
22
+ </url>
23
+ <url>
24
+ <loc>https://labas.rogasper.com/leaderboard</loc>
25
+ <changefreq>daily</changefreq>
26
+ <priority>0.6</priority>
27
+ </url>
28
+ </urlset>
apps/web/src/hooks/use-generation-jobs.ts CHANGED
@@ -10,8 +10,8 @@ import type { GenerationResult } from "@labas/ai";
10
  export type { ActiveJob, CompletedResult } from "./use-job-shared";
11
 
12
  export function useGenerationJobs() {
13
- const { data: session } = authClient.useSession();
14
- const isAuthenticated = !!session;
15
 
16
  const {
17
  jobIds,
 
10
  export type { ActiveJob, CompletedResult } from "./use-job-shared";
11
 
12
  export function useGenerationJobs() {
13
+ const { data: session, isPending: isSessionPending } = authClient.useSession();
14
+ const isAuthenticated = Boolean(session?.user?.id) && !isSessionPending;
15
 
16
  const {
17
  jobIds,
apps/web/src/lib/site-seo.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const SITE_URL = "https://labas.rogasper.com";
2
+
3
+ export const OG_IMAGE_PATH = "/og_image.png";
4
+ export const OG_IMAGE_URL = `${SITE_URL}${OG_IMAGE_PATH}`;
5
+ export const OG_IMAGE_WIDTH = 1200;
6
+ export const OG_IMAGE_HEIGHT = 630;
7
+ export const OG_IMAGE_ALT =
8
+ "Labas — platform latihan ujian bahasa dengan AI Generator dan simulasi tes";
9
+
10
+ export const DEFAULT_SITE_TITLE = "Labas — AI-Powered Exam Practice Platform";
11
+ export const DEFAULT_SITE_DESCRIPTION =
12
+ "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation.";
13
+
14
+ type SiteHeadInput = {
15
+ title?: string;
16
+ description?: string;
17
+ url?: string;
18
+ };
19
+
20
+ /** Shared Open Graph + Twitter tags for TanStack Router `head()`. */
21
+ export function buildSocialMeta(input: SiteHeadInput = {}) {
22
+ const title = input.title ?? DEFAULT_SITE_TITLE;
23
+ const description = input.description ?? DEFAULT_SITE_DESCRIPTION;
24
+ const url = input.url ?? `${SITE_URL}/`;
25
+
26
+ return [
27
+ { name: "description", content: description },
28
+ { property: "og:title", content: title },
29
+ { property: "og:description", content: description },
30
+ { property: "og:url", content: url },
31
+ { property: "og:type", content: "website" },
32
+ { property: "og:image", content: OG_IMAGE_URL },
33
+ { property: "og:image:secure_url", content: OG_IMAGE_URL },
34
+ { property: "og:image:type", content: "image/png" },
35
+ { property: "og:image:width", content: String(OG_IMAGE_WIDTH) },
36
+ { property: "og:image:height", content: String(OG_IMAGE_HEIGHT) },
37
+ { property: "og:image:alt", content: OG_IMAGE_ALT },
38
+ { name: "twitter:card", content: "summary_large_image" },
39
+ { name: "twitter:title", content: title },
40
+ { name: "twitter:description", content: description },
41
+ { name: "twitter:image", content: OG_IMAGE_URL },
42
+ { name: "twitter:image:alt", content: OG_IMAGE_ALT },
43
+ ];
44
+ }
apps/web/src/routes/__root.tsx CHANGED
@@ -10,6 +10,12 @@ import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
11
  import { ErrorFallback } from "@/components/ErrorFallback";
12
  import type { RouteShell } from "@/lib/route-shell";
 
 
 
 
 
 
13
  import type { trpc } from "@/utils/trpc";
14
 
15
  function SkipLink() {
@@ -42,25 +48,16 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
42
  component: RootComponent,
43
  errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
44
  head: () => {
45
- const siteUrl = "https://labas.rogasper.com";
46
  return {
47
  meta: [
48
- { title: "Labas — AI Exam Practice" },
49
- { name: "description", content: "AI-powered multi-language test practice platform" },
50
- { property: "og:title", content: "Labas — AI Exam Practice" },
51
- { property: "og:description", content: "AI-powered multi-language test practice platform" },
52
- { property: "og:url", content: siteUrl },
53
- { property: "og:image", content: `${siteUrl}/og_image.png` },
54
- { property: "og:type", content: "website" },
55
- { name: "twitter:card", content: "summary_large_image" },
56
- { name: "twitter:title", content: "Labas — AI Exam Practice" },
57
- { name: "twitter:description", content: "AI-powered multi-language test practice platform" },
58
- { name: "twitter:image", content: `${siteUrl}/og_image.png` },
59
  { name: "robots", content: "index, follow" },
60
  ],
61
  links: [
62
  { rel: "icon", href: "/labas_icon.png" },
63
- { rel: "canonical", href: siteUrl },
64
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
65
  { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" as const },
66
  { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
@@ -75,11 +72,11 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
75
  "@context": "https://schema.org",
76
  "@type": "WebSite",
77
  name: "Labas",
78
- url: siteUrl,
79
- description: "AI-powered multi-language test practice platform",
80
  potentialAction: {
81
  "@type": "SearchAction",
82
- target: `${siteUrl}/bank?search={search_term_string}`,
83
  "query-input": "required name=search_term_string",
84
  },
85
  }),
@@ -123,7 +120,7 @@ function RootComponent() {
123
  </main>
124
  </div>
125
  )}
126
- <GlobalGenerationProgress />
127
  <Toaster richColors />
128
  </ThemeProvider>
129
  {import.meta.env.DEV && (
 
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
11
  import { ErrorFallback } from "@/components/ErrorFallback";
12
  import type { RouteShell } from "@/lib/route-shell";
13
+ import {
14
+ buildSocialMeta,
15
+ DEFAULT_SITE_DESCRIPTION,
16
+ DEFAULT_SITE_TITLE,
17
+ SITE_URL,
18
+ } from "@/lib/site-seo";
19
  import type { trpc } from "@/utils/trpc";
20
 
21
  function SkipLink() {
 
48
  component: RootComponent,
49
  errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
50
  head: () => {
 
51
  return {
52
  meta: [
53
+ { title: DEFAULT_SITE_TITLE },
54
+ { name: "description", content: DEFAULT_SITE_DESCRIPTION },
55
+ ...buildSocialMeta(),
 
 
 
 
 
 
 
 
56
  { name: "robots", content: "index, follow" },
57
  ],
58
  links: [
59
  { rel: "icon", href: "/labas_icon.png" },
60
+ { rel: "canonical", href: `${SITE_URL}/` },
61
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
62
  { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" as const },
63
  { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap" },
 
72
  "@context": "https://schema.org",
73
  "@type": "WebSite",
74
  name: "Labas",
75
+ url: SITE_URL,
76
+ description: DEFAULT_SITE_DESCRIPTION,
77
  potentialAction: {
78
  "@type": "SearchAction",
79
+ target: `${SITE_URL}/bank?search={search_term_string}`,
80
  "query-input": "required name=search_term_string",
81
  },
82
  }),
 
120
  </main>
121
  </div>
122
  )}
123
+ {shell !== "public" && <GlobalGenerationProgress />}
124
  <Toaster richColors />
125
  </ThemeProvider>
126
  {import.meta.env.DEV && (
apps/web/src/routes/index.tsx CHANGED
@@ -1,29 +1,26 @@
1
- import { createFileRoute, redirect } from "@tanstack/react-router";
2
  import { authClient } from "@/lib/auth-client";
3
  import { routeShell } from "@/lib/route-shell";
 
4
 
5
  export const Route = createFileRoute("/")({
6
  staticData: routeShell.public,
7
  head: () => ({
8
  meta: [
9
- { title: "Labas — AI-Powered Exam Practice Platform" },
10
- { name: "description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
11
- { property: "og:title", content: "Labas — AI-Powered Exam Practice Platform" },
12
- { property: "og:description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
13
- { property: "og:url", content: "https://labas.rogasper.com/" },
14
- ],
15
- links: [
16
- { rel: "canonical", href: "https://labas.rogasper.com/" },
17
  ],
 
18
  }),
19
  beforeLoad: async () => {
20
  try {
21
  const session = await authClient.getSession();
22
- if (session?.data) {
23
  throw redirect({ to: "/dashboard", replace: true });
24
  }
25
  } catch (e) {
26
- if (e instanceof Response) throw e;
 
27
  }
28
  },
29
  });
 
1
+ import { createFileRoute, isRedirect, redirect } from "@tanstack/react-router";
2
  import { authClient } from "@/lib/auth-client";
3
  import { routeShell } from "@/lib/route-shell";
4
+ import { buildSocialMeta, DEFAULT_SITE_TITLE, SITE_URL } from "@/lib/site-seo";
5
 
6
  export const Route = createFileRoute("/")({
7
  staticData: routeShell.public,
8
  head: () => ({
9
  meta: [
10
+ { title: DEFAULT_SITE_TITLE },
11
+ ...buildSocialMeta({ url: `${SITE_URL}/` }),
 
 
 
 
 
 
12
  ],
13
+ links: [{ rel: "canonical", href: `${SITE_URL}/` }],
14
  }),
15
  beforeLoad: async () => {
16
  try {
17
  const session = await authClient.getSession();
18
+ if (session.data?.user) {
19
  throw redirect({ to: "/dashboard", replace: true });
20
  }
21
  } catch (e) {
22
+ if (isRedirect(e)) throw e;
23
+ // Offline / auth endpoint unavailable — still show public landing.
24
  }
25
  },
26
  });
apps/web/src/utils/trpc.ts CHANGED
@@ -1,13 +1,20 @@
1
  import type { AppRouter } from "@labas/api/routers/index";
2
  import { env } from "@labas/env/web";
3
  import { QueryCache, QueryClient } from "@tanstack/react-query";
4
- import { createTRPCClient, httpBatchLink } from "@trpc/client";
5
  import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
6
  import { toast } from "sonner";
7
 
8
  export const queryClient = new QueryClient({
9
  queryCache: new QueryCache({
10
  onError: (error, query) => {
 
 
 
 
 
 
 
11
  toast.error(error.message, {
12
  action: {
13
  label: "retry",
 
1
  import type { AppRouter } from "@labas/api/routers/index";
2
  import { env } from "@labas/env/web";
3
  import { QueryCache, QueryClient } from "@tanstack/react-query";
4
+ import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client";
5
  import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
6
  import { toast } from "sonner";
7
 
8
  export const queryClient = new QueryClient({
9
  queryCache: new QueryCache({
10
  onError: (error, query) => {
11
+ if (
12
+ error instanceof TRPCClientError &&
13
+ (error.data?.code === "UNAUTHORIZED" ||
14
+ error.message.toLowerCase().includes("authentication required"))
15
+ ) {
16
+ return;
17
+ }
18
  toast.error(error.message, {
19
  action: {
20
  label: "retry",
package.json CHANGED
@@ -26,6 +26,7 @@
26
  "build": "turbo build",
27
  "test": "turbo test",
28
  "check-types": "turbo check-types",
 
29
  "dev:web": "turbo -F web dev",
30
  "dev:server": "turbo -F server dev",
31
  "db:push": "turbo -F @labas/db db:push",
@@ -50,6 +51,6 @@
50
  "turbo": "^2.8.12",
51
  "typescript": "catalog:"
52
  },
53
- "packageManager": "bun@1.3.11",
54
  "license": "AGPL-3.0"
55
  }
 
26
  "build": "turbo build",
27
  "test": "turbo test",
28
  "check-types": "turbo check-types",
29
+ "compress:og": "bun scripts/compress-og-image.ts",
30
  "dev:web": "turbo -F web dev",
31
  "dev:server": "turbo -F server dev",
32
  "db:push": "turbo -F @labas/db db:push",
 
51
  "turbo": "^2.8.12",
52
  "typescript": "catalog:"
53
  },
54
+ "packageManager": "bun@1.3.14",
55
  "license": "AGPL-3.0"
56
  }
scripts/compress-og-image.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Compress apps/web/public/og_image.png for social previews.
3
+ * Requires Bun >= 1.3.14 (Bun.Image API).
4
+ *
5
+ * Usage: bun scripts/compress-og-image.ts
6
+ */
7
+
8
+ import { join } from "node:path";
9
+
10
+ const ROOT = join(import.meta.dir, "..");
11
+ const TARGET = join(ROOT, "apps/web/public/og_image.png");
12
+ const WIDTH = 1200;
13
+ const HEIGHT = 630;
14
+
15
+ function requireBunImage() {
16
+ const file = Bun.file(TARGET);
17
+ if (typeof file.image !== "function") {
18
+ console.error(
19
+ "Bun.Image is not available. Upgrade Bun to >= 1.3.14:\n" +
20
+ " bun upgrade\n" +
21
+ " # or: curl -fsSL https://bun.com/install | bash",
22
+ );
23
+ process.exit(1);
24
+ }
25
+ return file.image();
26
+ }
27
+
28
+ function formatKb(bytes: number) {
29
+ return `${(bytes / 1024).toFixed(1)} KB`;
30
+ }
31
+
32
+ async function main() {
33
+ const before = (await Bun.file(TARGET).arrayBuffer()).byteLength;
34
+ console.log(`Source: ${TARGET}`);
35
+ console.log(`Before: ${formatKb(before)} (${WIDTH}×${HEIGHT} target)`);
36
+
37
+ const pipeline = requireBunImage()
38
+ .resize(WIDTH, HEIGHT, { fit: "fill", filter: "lanczos3" })
39
+ .png({
40
+ compressionLevel: 9,
41
+ palette: true,
42
+ colors: 256,
43
+ dither: true,
44
+ });
45
+
46
+ await pipeline.write(TARGET);
47
+
48
+ const after = (await Bun.file(TARGET).arrayBuffer()).byteLength;
49
+ const meta = await Bun.file(TARGET).image().metadata();
50
+
51
+ console.log(`After: ${formatKb(after)} (${meta.width}×${meta.height}, ${meta.format})`);
52
+ console.log(`Saved: ${formatKb(before - after)} (${(((before - after) / before) * 100).toFixed(1)}%)`);
53
+ }
54
+
55
+ main().catch((err) => {
56
+ console.error(err);
57
+ process.exit(1);
58
+ });