Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/marketing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ are upstream's.
- `vp run dev:marketing` serves it on port 4173.
- `vp run build:marketing` writes `dist/`.
- Deployed by Vercel from this directory; `vercel.ts` carries the install and build commands,
the headers for `/install.sh`, and the rewrite that shows thread prompt links on `/new`. The Vercel project needs Root Directory `apps/marketing` with
the headers for `/install.sh`, and the rewrites that show thread prompt links on `/new` and
copied thread links on `/threads`. The Vercel project needs Root Directory `apps/marketing` with
files outside the root included, because the build reads `FORK.md` and stages
`scripts/install.sh` from the repository root.

Expand Down
26 changes: 26 additions & 0 deletions apps/marketing/src/components/ServerForm.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
// The "Open on my server" form of a link page; `wireServerForm` in lib/serverForm.ts runs it.
interface Props {
label: string;
}

const { label } = Astro.props;
---

<form class="server" id="server-form" novalidate>
<label class="server-label" for="server-address">{label}</label>
<div class="server-row">
<input
class="server-input"
id="server-address"
type="text"
inputmode="url"
autocomplete="url"
autocapitalize="off"
spellcheck="false"
placeholder="localhost:3773"
/>
<button class="btn btn-ghost" type="submit">Open on my server</button>
</div>
<p class="server-error" id="server-error" role="alert" hidden>Enter your server's address, such as localhost:3773 or https://my-server.example.</p>
</form>
21 changes: 21 additions & 0 deletions apps/marketing/src/lib/serverForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { serverPromptLinkUrl } from "./promptLink";

/** Wires a link page's "Open on my server" form to open `appPath` on the address entered. */
export function wireServerForm(appPath: string) {
const form = document.getElementById("server-form") as HTMLFormElement;
const address = document.getElementById("server-address") as HTMLInputElement;
const addressError = document.getElementById("server-error") as HTMLElement;
address.addEventListener("input", () => {
addressError.hidden = true;
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const target = serverPromptLinkUrl(address.value, appPath);
if (target === null) {
addressError.hidden = false;
address.focus();
return;
}
window.location.assign(target);
});
}
38 changes: 38 additions & 0 deletions apps/marketing/src/lib/threadLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vite-plus/test";

import { serverPromptLinkUrl } from "./promptLink";
import { readThreadLinkPage } from "./threadLink";

describe("readThreadLinkPage", () => {
it("opens the thread in the app and on a server", () => {
const page = readThreadLinkPage("https://j5.codes/threads/env-1/thread_2/");
expect(page).toEqual({
ok: true,
value: { appPath: "/env-1/thread_2", deepLink: "j5code://threads/env-1/thread_2" },
});
expect(page.ok && serverPromptLinkUrl("localhost:3773", page.value.appPath)).toBe(
"http://localhost:3773/env-1/thread_2",
);
});

it("keeps escaped ids as the link wrote them", () => {
const page = readThreadLinkPage("https://j5.codes/threads/env%2E1/thread%20two");
expect(page.ok && page.value.deepLink).toBe("j5code://threads/env%2E1/thread%20two");
});

it("refuses a link that doesn't name a thread", () => {
for (const href of [
"https://j5.codes/threads",
"https://j5.codes/threads/env-1",
"https://j5.codes/threads/env-1/thread-1/extra",
"https://j5.codes/threads/env-1/%E0%A4",
"https://j5.codes/threads/%20/thread-1",
"https://j5.codes/threads/env.1/thread-1",
]) {
expect(readThreadLinkPage(href)).toEqual({
ok: false,
error: "This link doesn't name a thread.",
});
}
});
});
31 changes: 31 additions & 0 deletions apps/marketing/src/lib/threadLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type PromptLinkResult, promptLinkDeepLink } from "@t3tools/shared/promptLinks";

export interface ThreadLinkPage {
/** The thread's path in a J5 web app, `/<environment>/<thread>`. */
readonly appPath: string;
readonly deepLink: string;
}

// The characters a copied thread link escapes its ids into (upstream's `hostedThreadLink`).
const THREAD_LINK_PATH = /^\/threads\/([A-Za-z0-9_%-]+)\/([A-Za-z0-9_%-]+)$/;

const isId = (segment: string) => {
try {
return decodeURIComponent(segment).trim().length > 0;
} catch {
return false;
}
};

/** Reads a copied thread link, `j5.codes/threads/<environment>/<thread>`, as the page shows it. */
export function readThreadLinkPage(href: string): PromptLinkResult<ThreadLinkPage> {
// Static hosting serves the page with a trailing slash as well as without.
const match = THREAD_LINK_PATH.exec(new URL(href).pathname.replace(/\/+$/, ""));
if (!match || !isId(match[1]!) || !isId(match[2]!)) {
return { ok: false, error: "This link doesn't name a thread." };
}
// The ids stay escaped as the link wrote them; the app decodes them.
// Every J5 server opens `/<environment>/<thread>`, including those older than `/threads/…`.
const appPath = `/${match[1]}/${match[2]}`;
return { ok: true, value: { appPath, deepLink: promptLinkDeepLink(appPath) } };
}
148 changes: 7 additions & 141 deletions apps/marketing/src/pages/new.astro
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
import Layout from "../layouts/Layout.astro";
import Boat from "../components/Boat.astro";
import ServerForm from "../components/ServerForm.astro";
import "../styles/link-page.css";
---

<Layout title="Open a prompt — J5 Code" description="A prompt shared as a link. It opens in J5 Code as a draft, and nothing runs until you press Send.">
<div class="prompt-page">
<div class="link-page">
<div class="heading-boat"><Boat tone="dark" size={56} /></div>

<section class="panel" id="prompt-link" hidden>
Expand All @@ -24,23 +26,7 @@ import Boat from "../components/Boat.astro";
<a class="btn btn-ghost" href="/download">Get J5 Code</a>
</div>

<form class="server" id="server-form" novalidate>
<label class="server-label" for="server-address">Using J5 Code in a browser? Open the prompt on your server.</label>
<div class="server-row">
<input
class="server-input"
id="server-address"
type="text"
inputmode="url"
autocomplete="url"
autocapitalize="off"
spellcheck="false"
placeholder="localhost:3773"
/>
<button class="btn btn-ghost" type="submit">Open on my server</button>
</div>
<p class="server-error" id="server-error" role="alert" hidden>Enter your server's address, such as localhost:3773 or https://my-server.example.</p>
</form>
<ServerForm label="Using J5 Code in a browser? Open the prompt on your server." />
</section>

<section class="panel" id="prompt-link-error" hidden>
Expand All @@ -58,7 +44,8 @@ import Boat from "../components/Boat.astro";
</Layout>

<script>
import { readPromptLinkPage, serverPromptLinkUrl } from "../lib/promptLink";
import { readPromptLinkPage } from "../lib/promptLink";
import { wireServerForm } from "../lib/serverForm";

const byId = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const page = readPromptLinkPage(window.location.href);
Expand All @@ -80,66 +67,12 @@ import Boat from "../components/Boat.astro";
byId("prompt-details").textContent = details.join(" · ");
byId<HTMLAnchorElement>("open-app").href = deepLink;

const address = byId<HTMLInputElement>("server-address");
const addressError = byId("server-error");
address.addEventListener("input", () => {
addressError.hidden = true;
});
byId<HTMLFormElement>("server-form").addEventListener("submit", (event) => {
event.preventDefault();
const target = serverPromptLinkUrl(address.value, appPath);
if (target === null) {
addressError.hidden = false;
address.focus();
return;
}
window.location.assign(target);
});
wireServerForm(appPath);
byId("prompt-link").hidden = false;
}
</script>

<style>
.prompt-page {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8vh 1.5rem 4rem;
background:
radial-gradient(ellipse 70% 50% at 50% 0%, rgba(59, 143, 174, 0.14), transparent 70%),
var(--navy-950);
}

.heading-boat {
margin-bottom: 0.5rem;
}

.panel {
width: 100%;
max-width: 680px;
display: flex;
flex-direction: column;
align-items: center;
}

.panel[hidden] {
display: none;
}

.heading {
font-size: clamp(2rem, 4.4vw, 3rem);
text-align: center;
margin-bottom: 0.5rem;
}

.subheading {
font-size: 0.95rem;
color: var(--foam-dim);
text-align: center;
margin-bottom: 2rem;
}

.warning {
align-self: stretch;
display: flex;
Expand Down Expand Up @@ -189,71 +122,4 @@ import Boat from "../components/Boat.astro";
font-size: 0.8rem;
color: var(--foam-dim);
}

.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem;
margin-top: 1.75rem;
}

.server {
align-self: stretch;
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 0.6rem;
}

.server-label {
font-size: 0.85rem;
color: var(--foam-muted);
}

.server-row {
display: flex;
gap: 0.5rem;
}

.server-input {
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.18);
color: var(--foam);
font-family: var(--font-mono);
font-size: 13px;
}

.server-input::placeholder {
color: var(--foam-dim);
}

.server-input:focus-visible {
border-color: var(--line-strong);
}

.server-error {
font-size: 0.8rem;
color: #e8836f;
}

.server-error[hidden] {
display: none;
}

@media (max-width: 640px) {
.prompt-page {
padding: 5vh 1.25rem 4rem;
}

.server-row {
flex-direction: column;
}
}
</style>
53 changes: 53 additions & 0 deletions apps/marketing/src/pages/threads.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
import Layout from "../layouts/Layout.astro";
import Boat from "../components/Boat.astro";
import ServerForm from "../components/ServerForm.astro";
import "../styles/link-page.css";
---

<Layout title="Open a thread — J5 Code" description="A link to a thread in J5 Code. It opens for people connected to the thread's server who can see it.">
<div class="link-page">
<div class="heading-boat"><Boat tone="dark" size={56} /></div>

<section class="panel" id="thread-link" hidden>
<h1 class="heading display">Open this thread</h1>
<p class="subheading">It opens in J5 Code if you're connected to the server it runs on and can see the thread.</p>

<div class="actions">
<a class="btn btn-primary" id="open-app" href="/download">Open in J5 Code</a>
<a class="btn btn-ghost" href="/download">Get J5 Code</a>
</div>

<ServerForm label="Using J5 Code in a browser? Open the thread on your server." />
</section>

<section class="panel" id="thread-link-error" hidden>
<h1 class="heading display">Could not open the link</h1>
<p class="subheading" id="error-text"></p>
<div class="actions">
<a class="btn btn-ghost" href="/download">Get J5 Code</a>
</div>
</section>

<noscript>
<p class="subheading">This page needs JavaScript to read the link.</p>
</noscript>
</div>
</Layout>

<script>
import { wireServerForm } from "../lib/serverForm";
import { readThreadLinkPage } from "../lib/threadLink";

const byId = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const page = readThreadLinkPage(window.location.href);

if (!page.ok) {
byId("error-text").textContent = page.error;
byId("thread-link-error").hidden = false;
} else {
byId<HTMLAnchorElement>("open-app").href = page.value.deepLink;
wireServerForm(page.value.appPath);
byId("thread-link").hidden = false;
}
</script>
Loading
Loading