🔥 initial commit 🔥
This commit is contained in:
106
assets/css/app.css
Normal file
106
assets/css/app.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* See the Tailwind configuration guide for advanced usage
|
||||
https://tailwindcss.com/docs/configuration */
|
||||
|
||||
@import "tailwindcss" source(none);
|
||||
@source "../../deps/ash_authentication_phoenix";
|
||||
@source "../css";
|
||||
@source "../js";
|
||||
@source "../../lib/mixer_web";
|
||||
|
||||
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
|
||||
The heroicons installation itself is managed by your mix.exs */
|
||||
@plugin "../vendor/heroicons";
|
||||
|
||||
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
|
||||
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
|
||||
@plugin "../vendor/daisyui" {
|
||||
themes: false;
|
||||
}
|
||||
|
||||
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
|
||||
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
|
||||
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: "dark";
|
||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
||||
--color-base-300: oklch(20.15% 0.012 254.09);
|
||||
--color-base-content: oklch(97.807% 0.029 256.847);
|
||||
--color-primary: oklch(58% 0.233 277.117);
|
||||
--color-primary-content: oklch(96% 0.018 272.314);
|
||||
--color-secondary: oklch(58% 0.233 277.117);
|
||||
--color-secondary-content: oklch(96% 0.018 272.314);
|
||||
--color-accent: oklch(60% 0.25 292.717);
|
||||
--color-accent-content: oklch(96% 0.016 293.756);
|
||||
--color-neutral: oklch(37% 0.044 257.287);
|
||||
--color-neutral-content: oklch(98% 0.003 247.858);
|
||||
--color-info: oklch(58% 0.158 241.966);
|
||||
--color-info-content: oklch(97% 0.013 236.62);
|
||||
--color-success: oklch(60% 0.118 184.704);
|
||||
--color-success-content: oklch(98% 0.014 180.72);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.21875rem;
|
||||
--size-field: 0.21875rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@plugin "../vendor/daisyui-theme" {
|
||||
name: "light";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: "light";
|
||||
--color-base-100: oklch(98% 0 0);
|
||||
--color-base-200: oklch(96% 0.001 286.375);
|
||||
--color-base-300: oklch(92% 0.004 286.32);
|
||||
--color-base-content: oklch(21% 0.006 285.885);
|
||||
--color-primary: oklch(70% 0.213 47.604);
|
||||
--color-primary-content: oklch(98% 0.016 73.684);
|
||||
--color-secondary: oklch(55% 0.027 264.364);
|
||||
--color-secondary-content: oklch(98% 0.002 247.839);
|
||||
--color-accent: oklch(0% 0 0);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(44% 0.017 285.786);
|
||||
--color-neutral-content: oklch(98% 0 0);
|
||||
--color-info: oklch(62% 0.214 259.815);
|
||||
--color-info-content: oklch(97% 0.014 254.604);
|
||||
--color-success: oklch(70% 0.14 182.503);
|
||||
--color-success-content: oklch(98% 0.014 180.72);
|
||||
--color-warning: oklch(66% 0.179 58.318);
|
||||
--color-warning-content: oklch(98% 0.022 95.277);
|
||||
--color-error: oklch(58% 0.253 17.585);
|
||||
--color-error-content: oklch(96% 0.015 12.422);
|
||||
--radius-selector: 0.25rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
--size-selector: 0.21875rem;
|
||||
--size-field: 0.21875rem;
|
||||
--border: 1.5px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Add variants based on LiveView classes */
|
||||
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||
|
||||
/* Use the data attribute for dark mode */
|
||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
|
||||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
||||
|
||||
/* This file is for your main application CSS */
|
||||
395
assets/js/animation.ts
Normal file
395
assets/js/animation.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
// AshTypescript landing page animation
|
||||
// Typewriter effect with syntax highlighting, inspired by ash-hq.org
|
||||
|
||||
const HEXDOCS = "https://hexdocs.pm/ash_typescript";
|
||||
|
||||
interface Stage {
|
||||
name: string;
|
||||
description: string;
|
||||
docsPath: string;
|
||||
elixir: string;
|
||||
typescript: string;
|
||||
}
|
||||
|
||||
const stages: Stage[] = [
|
||||
{
|
||||
name: "Type-Safe RPC",
|
||||
description: "Auto-generated TypeScript functions for every Ash action with full type inference.",
|
||||
docsPath: "/first-rpc-action.html",
|
||||
elixir: `use Ash.Domain,
|
||||
extensions: [AshTypescript.Rpc]
|
||||
|
||||
typescript_rpc do
|
||||
resource MyApp.Todo do
|
||||
rpc_action :list_todos, :read
|
||||
rpc_action :create_todo, :create
|
||||
rpc_action :update_todo, :update
|
||||
end
|
||||
end`,
|
||||
typescript: `import { listTodos, createTodo } from "./ash_rpc";
|
||||
|
||||
const result = await listTodos({
|
||||
fields: ["id", "title", { user: ["name"] }],
|
||||
filter: { completed: { eq: false } },
|
||||
sort: [{ field: "insertedAt", order: "desc" }],
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// result.data is fully typed!
|
||||
result.data.forEach(todo => {
|
||||
console.log(todo.title, todo.user.name);
|
||||
});
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "Typed Controllers",
|
||||
description: "Typed route helpers and fetch functions for Phoenix controllers.",
|
||||
docsPath: "/typed-controllers.html",
|
||||
elixir: `use AshTypescript.TypedController
|
||||
|
||||
typed_controller do
|
||||
module_name MyAppWeb.SessionController
|
||||
|
||||
get :current_user do
|
||||
run fn conn, _params ->
|
||||
json(conn, current_user(conn))
|
||||
end
|
||||
end
|
||||
|
||||
post :login do
|
||||
argument :email, :string, allow_nil?: false
|
||||
argument :password, :string, allow_nil?: false
|
||||
run fn conn, params ->
|
||||
# authenticate and respond
|
||||
end
|
||||
end
|
||||
end`,
|
||||
typescript: `import {
|
||||
currentUserPath,
|
||||
login,
|
||||
} from "./routes";
|
||||
|
||||
// Typed path helpers
|
||||
currentUserPath(); // => "/session/current_user"
|
||||
|
||||
// Typed fetch functions for mutations
|
||||
const result = await login({
|
||||
email: "user@example.com",
|
||||
password: "secret",
|
||||
headers: buildCSRFHeaders(),
|
||||
});`,
|
||||
},
|
||||
{
|
||||
name: "Typed Channels",
|
||||
description: "Type-safe Phoenix channel event subscriptions with Ash PubSub.",
|
||||
docsPath: "/typed-channels.html",
|
||||
elixir: `# Resource with PubSub
|
||||
pub_sub do
|
||||
prefix "posts"
|
||||
publish :create, [:id],
|
||||
event: "post_created",
|
||||
transform: :post_summary
|
||||
end
|
||||
|
||||
# Typed channel definition
|
||||
typed_channel do
|
||||
topic "org:*"
|
||||
|
||||
resource MyApp.Post do
|
||||
publish :post_created
|
||||
publish :post_updated
|
||||
end
|
||||
end`,
|
||||
typescript: `import {
|
||||
createOrgChannel,
|
||||
onOrgChannelMessages,
|
||||
} from "./ash_typed_channels";
|
||||
|
||||
const channel = createOrgChannel(socket, orgId);
|
||||
|
||||
const refs = onOrgChannelMessages(channel, {
|
||||
post_created: (payload) => {
|
||||
// payload type inferred from calculation!
|
||||
addPost(payload.id, payload.title);
|
||||
},
|
||||
post_updated: (payload) => {
|
||||
updatePost(payload.id, payload.title);
|
||||
},
|
||||
});`,
|
||||
},
|
||||
{
|
||||
name: "Field Selection",
|
||||
description: "Request exactly the fields you need with full type narrowing.",
|
||||
docsPath: "/field-selection.html",
|
||||
elixir: `# Define your resource with relationships
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :title, :string, public?: true
|
||||
attribute :body, :string, public?: true
|
||||
attribute :view_count, :integer, public?: true
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :author, MyApp.User, public?: true
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :reading_time, :integer,
|
||||
expr(string_length(body) / 200)
|
||||
end`,
|
||||
typescript: `// Only fetch what you need - response is narrowed
|
||||
const posts = await listPosts({
|
||||
fields: [
|
||||
"id",
|
||||
"title",
|
||||
"readingTime",
|
||||
{ author: ["name", "avatarUrl"] },
|
||||
],
|
||||
});
|
||||
|
||||
// TypeScript knows the exact shape:
|
||||
posts.data[0].title; // string ✓
|
||||
posts.data[0].readingTime; // number ✓
|
||||
posts.data[0].author.name; // string ✓
|
||||
posts.data[0].body; // Error! Not selected`,
|
||||
},
|
||||
];
|
||||
|
||||
// Elixir syntax highlighting
|
||||
function highlightElixir(code: string): string {
|
||||
const tokens: { start: number; end: number; cls: string }[] = [];
|
||||
const patterns: [RegExp, string][] = [
|
||||
[/#[^\n]*/g, "text-gray-500"],
|
||||
[/"[^"]*"/g, "text-yellow-400"],
|
||||
[/\b(defmodule|def|defp|do|end|use|fn|if|else|case|cond|with|for|unless|import|alias|require)\b/g, "text-pink-400"],
|
||||
[/\b(true|false|nil)\b/g, "text-purple-400"],
|
||||
[/(:[a-zA-Z_][a-zA-Z0-9_?!]*)/g, "text-cyan-400"],
|
||||
[/\b([A-Z][a-zA-Z0-9]*(\.[A-Z][a-zA-Z0-9]*)*)\b/g, "text-blue-400"],
|
||||
[/(\|>|->|<-|=>)/g, "text-pink-400"],
|
||||
];
|
||||
for (const [re, cls] of patterns) {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(code)) !== null) {
|
||||
const overlaps = tokens.some(t => m!.index < t.end && m!.index + m![0].length > t.start);
|
||||
if (!overlaps) tokens.push({ start: m.index, end: m.index + m[0].length, cls });
|
||||
}
|
||||
}
|
||||
tokens.sort((a, b) => a.start - b.start);
|
||||
let result = "";
|
||||
let pos = 0;
|
||||
for (const t of tokens) {
|
||||
if (t.start > pos) result += esc(code.slice(pos, t.start));
|
||||
result += `<span class="${t.cls}">${esc(code.slice(t.start, t.end))}</span>`;
|
||||
pos = t.end;
|
||||
}
|
||||
if (pos < code.length) result += esc(code.slice(pos));
|
||||
return result;
|
||||
}
|
||||
|
||||
// TypeScript syntax highlighting
|
||||
function highlightTS(code: string): string {
|
||||
const tokens: { start: number; end: number; cls: string }[] = [];
|
||||
const patterns: [RegExp, string][] = [
|
||||
[/\/\/[^\n]*/g, "text-gray-500"],
|
||||
[/"[^"]*"/g, "text-yellow-400"],
|
||||
[/`[^`]*`/g, "text-yellow-400"],
|
||||
[/\b(import|from|export|const|let|var|function|return|if|else|async|await|new|typeof|interface|type)\b/g, "text-pink-400"],
|
||||
[/\b(true|false|null|undefined)\b/g, "text-purple-400"],
|
||||
[/(=>)/g, "text-pink-400"],
|
||||
];
|
||||
for (const [re, cls] of patterns) {
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(code)) !== null) {
|
||||
const overlaps = tokens.some(t => m!.index < t.end && m!.index + m![0].length > t.start);
|
||||
if (!overlaps) tokens.push({ start: m.index, end: m.index + m[0].length, cls });
|
||||
}
|
||||
}
|
||||
tokens.sort((a, b) => a.start - b.start);
|
||||
let result = "";
|
||||
let pos = 0;
|
||||
for (const t of tokens) {
|
||||
if (t.start > pos) result += esc(code.slice(pos, t.start));
|
||||
result += `<span class="${t.cls}">${esc(code.slice(t.start, t.end))}</span>`;
|
||||
pos = t.end;
|
||||
}
|
||||
if (pos < code.length) result += esc(code.slice(pos));
|
||||
return result;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Typewriter engine
|
||||
function typewrite(
|
||||
el: HTMLElement,
|
||||
highlighted: string,
|
||||
onComplete: () => void,
|
||||
signal: { cancelled: boolean },
|
||||
): void {
|
||||
// Build a flat list of characters with their HTML wrapping
|
||||
const chars: string[] = [];
|
||||
let inTag = false;
|
||||
let tagBuffer = "";
|
||||
let openTags: string[] = [];
|
||||
|
||||
for (let i = 0; i < highlighted.length; i++) {
|
||||
const ch = highlighted[i];
|
||||
if (ch === "<") {
|
||||
inTag = true;
|
||||
tagBuffer = "<";
|
||||
continue;
|
||||
}
|
||||
if (inTag) {
|
||||
tagBuffer += ch;
|
||||
if (ch === ">") {
|
||||
inTag = false;
|
||||
if (tagBuffer.startsWith("</")) {
|
||||
openTags.pop();
|
||||
} else {
|
||||
openTags.push(tagBuffer);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Handle HTML entities as single visible characters
|
||||
let visibleChar = ch;
|
||||
if (ch === "&") {
|
||||
const semiIdx = highlighted.indexOf(";", i);
|
||||
if (semiIdx !== -1 && semiIdx - i < 8) {
|
||||
visibleChar = highlighted.slice(i, semiIdx + 1);
|
||||
i = semiIdx;
|
||||
}
|
||||
}
|
||||
let wrapped = visibleChar;
|
||||
for (const tag of openTags) {
|
||||
const cls = tag.match(/class="([^"]*)"/)?.[1] || "";
|
||||
wrapped = `<span class="${cls}">${wrapped}</span>`;
|
||||
}
|
||||
chars.push(wrapped);
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
el.innerHTML = '<span class="inline-block w-[2px] h-[1.1em] bg-primary align-text-bottom animate-pulse"></span>';
|
||||
|
||||
function step() {
|
||||
if (signal.cancelled) return;
|
||||
if (idx >= chars.length) {
|
||||
el.innerHTML = highlighted;
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
// Insert character before cursor
|
||||
const cursor = el.querySelector("span:last-child")!;
|
||||
cursor.insertAdjacentHTML("beforebegin", chars[idx]);
|
||||
idx++;
|
||||
|
||||
const c = chars[idx - 1];
|
||||
const isNewline = c.includes("\n") || c === "\n";
|
||||
const isSpace = c === " " || c.endsWith("> </span>");
|
||||
const delay = isNewline ? 80 : isSpace ? 15 : 25 + Math.random() * 15;
|
||||
setTimeout(step, delay);
|
||||
}
|
||||
step();
|
||||
}
|
||||
|
||||
// Main initialization
|
||||
export function initLandingPage(container: HTMLElement): () => void {
|
||||
let currentStage = 0;
|
||||
let signal = { cancelled: false };
|
||||
let autoTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
container.innerHTML = buildHTML();
|
||||
const elixirEl = container.querySelector<HTMLElement>("#elixir-code")!;
|
||||
const tsEl = container.querySelector<HTMLElement>("#ts-code")!;
|
||||
const descEl = container.querySelector<HTMLElement>("#stage-description")!;
|
||||
const docsLink = container.querySelector<HTMLAnchorElement>("#stage-docs-link")!;
|
||||
const dots = container.querySelectorAll<HTMLElement>(".stage-dot");
|
||||
const tsPanel = container.querySelector<HTMLElement>("#ts-panel")!;
|
||||
|
||||
function showStage(index: number) {
|
||||
signal.cancelled = true;
|
||||
signal = { cancelled: false };
|
||||
if (autoTimer) clearTimeout(autoTimer);
|
||||
currentStage = index;
|
||||
const stage = stages[index];
|
||||
|
||||
// Update dots
|
||||
dots.forEach((dot, i) => {
|
||||
dot.classList.toggle("bg-primary", i === index);
|
||||
dot.classList.toggle("opacity-100", i === index);
|
||||
dot.classList.toggle("bg-base-content", i !== index);
|
||||
dot.classList.toggle("opacity-30", i !== index);
|
||||
dot.classList.toggle("scale-125", i === index);
|
||||
});
|
||||
|
||||
// Update description
|
||||
descEl.textContent = stage.description;
|
||||
docsLink.href = HEXDOCS + stage.docsPath;
|
||||
|
||||
// Reset panels — hide TS panel entirely (no layout space)
|
||||
tsPanel.hidden = true;
|
||||
tsPanel.style.opacity = "0";
|
||||
|
||||
const elixirHL = highlightElixir(stage.elixir);
|
||||
const tsHL = highlightTS(stage.typescript);
|
||||
|
||||
if (reducedMotion) {
|
||||
elixirEl.innerHTML = elixirHL;
|
||||
tsEl.innerHTML = tsHL;
|
||||
tsPanel.hidden = false;
|
||||
tsPanel.style.opacity = "1";
|
||||
autoTimer = setTimeout(() => showStage((currentStage + 1) % stages.length), 6000);
|
||||
} else {
|
||||
typewrite(elixirEl, elixirHL, () => {
|
||||
if (signal.cancelled) return;
|
||||
tsPanel.hidden = false;
|
||||
requestAnimationFrame(() => { tsPanel.style.opacity = "1"; });
|
||||
typewrite(tsEl, tsHL, () => {
|
||||
if (signal.cancelled) return;
|
||||
autoTimer = setTimeout(() => showStage((currentStage + 1) % stages.length), 4000);
|
||||
}, signal);
|
||||
}, signal);
|
||||
}
|
||||
}
|
||||
|
||||
// Dot click handlers
|
||||
dots.forEach((dot, i) => {
|
||||
dot.addEventListener("click", () => showStage(i));
|
||||
});
|
||||
|
||||
// Start
|
||||
showStage(0);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
signal.cancelled = true;
|
||||
if (autoTimer) clearTimeout(autoTimer);
|
||||
};
|
||||
}
|
||||
|
||||
function buildHTML(): string {
|
||||
const dotHTML = stages.map((s, i) =>
|
||||
`<button class="stage-dot w-3 h-3 rounded-full transition-all duration-300 cursor-pointer ${i === 0 ? "bg-primary opacity-100 scale-125" : "bg-base-content opacity-30"}" title="${s.name}"></button>`
|
||||
).join("");
|
||||
|
||||
return `
|
||||
<div class="mb-12">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-2 items-center">${dotHTML}</div>
|
||||
<a id="stage-docs-link" href="${HEXDOCS}" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">View docs →</a>
|
||||
</div>
|
||||
<p id="stage-description" class="text-sm opacity-70 mb-4">${stages[0].description}</p>
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<div class="absolute top-2 right-3 text-xs opacity-40 font-mono select-none">Elixir</div>
|
||||
<pre class="bg-base-300 rounded-lg p-4 pt-8 text-sm font-mono leading-relaxed overflow-x-auto"><code id="elixir-code" class="text-gray-300"></code></pre>
|
||||
</div>
|
||||
<div id="ts-panel" class="relative transition-opacity duration-500" style="opacity:0" hidden>
|
||||
<div class="absolute top-2 right-3 text-xs opacity-40 font-mono select-none">TypeScript</div>
|
||||
<pre class="bg-base-300 rounded-lg p-4 pt-8 text-sm font-mono leading-relaxed overflow-x-auto"><code id="ts-code" class="text-gray-300"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
82
assets/js/app.js
Normal file
82
assets/js/app.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
|
||||
// To load it, simply add a second `<link>` to your `root.html.heex` file.
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import {hooks as colocatedHooks} from "phoenix-colocated/mixer"
|
||||
import topbar from "topbar"
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: {...colocatedHooks},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// The lines below enable quality of life phoenix_live_reload
|
||||
// development features:
|
||||
//
|
||||
// 1. stream server logs to the browser console
|
||||
// 2. click on elements to jump to their definitions in your code editor
|
||||
//
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
|
||||
// Enable server log streaming to client.
|
||||
// Disable with reloader.disableServerLogs()
|
||||
reloader.enableServerLogs()
|
||||
|
||||
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
|
||||
//
|
||||
// * click with "c" key pressed to open at caller location
|
||||
// * click with "d" key pressed to open at function component definition location
|
||||
let keyDown
|
||||
window.addEventListener("keydown", e => keyDown = e.key)
|
||||
window.addEventListener("keyup", _e => keyDown = null)
|
||||
window.addEventListener("click", e => {
|
||||
if(keyDown === "c"){
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtCaller(e.target)
|
||||
} else if(keyDown === "d"){
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtDef(e.target)
|
||||
}
|
||||
}, true)
|
||||
|
||||
window.liveReloader = reloader
|
||||
})
|
||||
}
|
||||
201
assets/js/ash_rpc.ts
Normal file
201
assets/js/ash_rpc.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
// Generated by AshTypescript - RPC Actions
|
||||
// Do not edit this file manually
|
||||
|
||||
|
||||
export type * from "./ash_types";
|
||||
|
||||
// Helper Functions
|
||||
|
||||
/**
|
||||
* Configuration options for action RPC requests
|
||||
*/
|
||||
export interface ActionConfig {
|
||||
// Request data
|
||||
input?: Record<string, any>;
|
||||
identity?: any;
|
||||
fields?: Array<string | Record<string, any>>; // Field selection
|
||||
filter?: Record<string, any>; // Filter options (for reads)
|
||||
sort?: string | string[]; // Sort options
|
||||
page?:
|
||||
| {
|
||||
// Offset-based pagination
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
count?: boolean;
|
||||
}
|
||||
| {
|
||||
// Keyset pagination
|
||||
limit?: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
};
|
||||
|
||||
// Metadata
|
||||
metadataFields?: ReadonlyArray<string>;
|
||||
|
||||
// HTTP customization
|
||||
headers?: Record<string, string>; // Custom headers
|
||||
fetchOptions?: RequestInit; // Fetch options (signal, cache, etc.)
|
||||
customFetch?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
// Multitenancy
|
||||
tenant?: string; // Tenant parameter
|
||||
|
||||
// Hook context
|
||||
hookCtx?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for validation RPC requests
|
||||
*/
|
||||
export interface ValidationConfig {
|
||||
// Request data
|
||||
input?: Record<string, any>;
|
||||
|
||||
// HTTP customization
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
// Hook context
|
||||
hookCtx?: Record<string, any>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Gets the CSRF token from the page's meta tag
|
||||
* Returns null if no CSRF token is found
|
||||
*/
|
||||
export function getPhoenixCSRFToken(): string | null {
|
||||
return document
|
||||
?.querySelector("meta[name='csrf-token']")
|
||||
?.getAttribute("content") || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds headers object with CSRF token for Phoenix applications
|
||||
* Returns headers object with X-CSRF-Token (if available)
|
||||
*/
|
||||
export function buildCSRFHeaders(headers: Record<string, string> = {}): Record<string, string> {
|
||||
const csrfToken = getPhoenixCSRFToken();
|
||||
if (csrfToken) {
|
||||
headers["X-CSRF-Token"] = csrfToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper function for making action RPC requests
|
||||
* Handles hooks, request configuration, fetch execution, and error handling
|
||||
* @param config Configuration matching ActionConfig
|
||||
*/
|
||||
export async function executeActionRpcRequest<T>(
|
||||
payload: Record<string, any>,
|
||||
config: ActionConfig
|
||||
): Promise<T> {
|
||||
const processedConfig = config;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...processedConfig.headers,
|
||||
...config.headers,
|
||||
};
|
||||
|
||||
const fetchFunction = config.customFetch || processedConfig.customFetch || fetch;
|
||||
const fetchOptions: RequestInit = {
|
||||
...processedConfig.fetchOptions,
|
||||
...config.fetchOptions,
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
|
||||
const response = await fetchFunction("/rpc/run", fetchOptions);
|
||||
const result = response.ok ? await response.json() : null;
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
type: "network_error",
|
||||
message: `Network request failed: ${response.statusText}`,
|
||||
shortMessage: "Network error",
|
||||
vars: { statusCode: response.status, statusText: response.statusText },
|
||||
fields: [],
|
||||
path: [],
|
||||
details: { statusCode: response.status }
|
||||
}
|
||||
],
|
||||
} as T;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Internal helper function for making validation RPC requests
|
||||
* Handles hooks, request configuration, fetch execution, and error handling
|
||||
* @param config Configuration matching ValidationConfig
|
||||
*/
|
||||
export async function executeValidationRpcRequest<T>(
|
||||
payload: Record<string, any>,
|
||||
config: ValidationConfig
|
||||
): Promise<T> {
|
||||
const processedConfig = config;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...processedConfig.headers,
|
||||
...config.headers,
|
||||
};
|
||||
|
||||
const fetchFunction = config.customFetch || processedConfig.customFetch || fetch;
|
||||
const fetchOptions: RequestInit = {
|
||||
...processedConfig.fetchOptions,
|
||||
...config.fetchOptions,
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
|
||||
const response = await fetchFunction("/rpc/validate", fetchOptions);
|
||||
const result = response.ok ? await response.json() : null;
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [
|
||||
{
|
||||
type: "network_error",
|
||||
message: `Network request failed: ${response.statusText}`,
|
||||
shortMessage: "Network error",
|
||||
vars: { statusCode: response.status, statusText: response.statusText },
|
||||
fields: [],
|
||||
path: [],
|
||||
details: { statusCode: response.status }
|
||||
}
|
||||
],
|
||||
} as T;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
440
assets/js/ash_types.ts
Normal file
440
assets/js/ash_types.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
// Generated by AshTypescript - Shared Types
|
||||
// Do not edit this file manually
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Utility Types
|
||||
|
||||
// Sort string type — allows optional direction prefix on sort field names
|
||||
// Prefixes per Ash.Query.sort/3: + (asc), - (desc), ++ (asc_nils_first), -- (desc_nils_last)
|
||||
export type SortString<T extends string> = T | `+${T}` | `-${T}` | `++${T}` | `--${T}`;
|
||||
|
||||
// Resource schema constraint
|
||||
export type TypedSchema = {
|
||||
__type: "Resource" | "TypedMap" | "Union";
|
||||
__primitiveFields: string;
|
||||
};
|
||||
|
||||
// Utility type to convert union to intersection
|
||||
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||
k: infer I,
|
||||
) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
// Helper type to infer union field values, avoiding duplication between array and non-array unions
|
||||
export type InferUnionFieldValue<
|
||||
UnionSchema extends { __type: "Union"; __primitiveFields: any },
|
||||
FieldSelection extends any[],
|
||||
> = UnionToIntersection<
|
||||
{
|
||||
[FieldIndex in keyof FieldSelection]: FieldSelection[FieldIndex] extends UnionSchema["__primitiveFields"]
|
||||
? FieldSelection[FieldIndex] extends keyof UnionSchema
|
||||
? { [P in FieldSelection[FieldIndex]]: UnionSchema[FieldSelection[FieldIndex]] }
|
||||
: never
|
||||
: FieldSelection[FieldIndex] extends Record<string, any>
|
||||
? {
|
||||
[UnionKey in keyof FieldSelection[FieldIndex]]: UnionKey extends keyof UnionSchema
|
||||
? NonNullable<UnionSchema[UnionKey]> extends { __array: true; __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
|
||||
? FieldSelection[FieldIndex][UnionKey] extends any[]
|
||||
? Array<
|
||||
UnionToIntersection<
|
||||
{
|
||||
[FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields
|
||||
? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof NonNullable<UnionSchema[UnionKey]>
|
||||
? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: NonNullable<UnionSchema[UnionKey]>[P] }
|
||||
: never
|
||||
: never;
|
||||
}[number]
|
||||
>
|
||||
> | null
|
||||
: never
|
||||
: NonNullable<UnionSchema[UnionKey]> extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
|
||||
? FieldSelection[FieldIndex][UnionKey] extends any[]
|
||||
? UnionToIntersection<
|
||||
{
|
||||
[FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields
|
||||
? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof NonNullable<UnionSchema[UnionKey]>
|
||||
? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: NonNullable<UnionSchema[UnionKey]>[P] }
|
||||
: never
|
||||
: never;
|
||||
}[number]
|
||||
> | null
|
||||
: never
|
||||
: NonNullable<UnionSchema[UnionKey]> extends TypedSchema
|
||||
? InferResult<NonNullable<UnionSchema[UnionKey]>, FieldSelection[FieldIndex][UnionKey]>
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
}[number]
|
||||
>;
|
||||
|
||||
export type HasComplexFields<T extends TypedSchema> = keyof Omit<
|
||||
T,
|
||||
"__primitiveFields" | "__type" | T["__primitiveFields"]
|
||||
> extends never
|
||||
? false
|
||||
: true;
|
||||
|
||||
export type ComplexFieldKeys<T extends TypedSchema> = keyof Omit<
|
||||
T,
|
||||
"__primitiveFields" | "__type" | T["__primitiveFields"]
|
||||
>;
|
||||
|
||||
export type LeafFieldSelection<T extends TypedSchema> = T["__primitiveFields"];
|
||||
|
||||
export type ComplexFieldSelection<T extends TypedSchema> = {
|
||||
[K in ComplexFieldKeys<T>]?: T[K] extends {
|
||||
__type: "Relationship";
|
||||
__resource: infer Resource;
|
||||
}
|
||||
? NonNullable<Resource> extends TypedSchema
|
||||
? UnifiedFieldSelection<NonNullable<Resource>>[]
|
||||
: never
|
||||
: T[K] extends {
|
||||
__type: "ComplexCalculation";
|
||||
__returnType: infer ReturnType;
|
||||
}
|
||||
? T[K] extends { __args: infer Args }
|
||||
? NonNullable<ReturnType> extends TypedSchema
|
||||
? {
|
||||
args: Args;
|
||||
fields: UnifiedFieldSelection<NonNullable<ReturnType>>[];
|
||||
}
|
||||
: { args: Args }
|
||||
: NonNullable<ReturnType> extends TypedSchema
|
||||
? { fields: UnifiedFieldSelection<NonNullable<ReturnType>>[] }
|
||||
: never
|
||||
: T[K] extends { __type: "TypedMap" }
|
||||
? NonNullable<T[K]> extends TypedSchema
|
||||
? UnifiedFieldSelection<NonNullable<T[K]>>[]
|
||||
: never
|
||||
: T[K] extends { __type: "Union"; __primitiveFields: infer PrimitiveFields }
|
||||
? T[K] extends { __array: true }
|
||||
? (PrimitiveFields | {
|
||||
[UnionKey in keyof Omit<T[K], "__type" | "__primitiveFields" | "__array">]?: NonNullable<T[K][UnionKey]> extends { __type: "TypedMap"; __primitiveFields: any }
|
||||
? NonNullable<T[K][UnionKey]>["__primitiveFields"][]
|
||||
: NonNullable<T[K][UnionKey]> extends TypedSchema
|
||||
? UnifiedFieldSelection<NonNullable<T[K][UnionKey]>>[]
|
||||
: never;
|
||||
})[]
|
||||
: (PrimitiveFields | {
|
||||
[UnionKey in keyof Omit<T[K], "__type" | "__primitiveFields">]?: NonNullable<T[K][UnionKey]> extends { __type: "TypedMap"; __primitiveFields: any }
|
||||
? NonNullable<T[K][UnionKey]>["__primitiveFields"][]
|
||||
: NonNullable<T[K][UnionKey]> extends TypedSchema
|
||||
? UnifiedFieldSelection<NonNullable<T[K][UnionKey]>>[]
|
||||
: never;
|
||||
})[]
|
||||
: NonNullable<T[K]> extends TypedSchema
|
||||
? UnifiedFieldSelection<NonNullable<T[K]>>[]
|
||||
: never;
|
||||
};
|
||||
|
||||
// Main type: Use explicit base case detection to prevent infinite recursion
|
||||
export type UnifiedFieldSelection<T extends TypedSchema> =
|
||||
HasComplexFields<T> extends false
|
||||
? LeafFieldSelection<T> // Base case: only primitives, no recursion
|
||||
: LeafFieldSelection<T> | ComplexFieldSelection<T>; // Recursive case
|
||||
|
||||
export type InferFieldValue<
|
||||
T extends TypedSchema,
|
||||
Field,
|
||||
> = Field extends T["__primitiveFields"]
|
||||
? Field extends keyof T
|
||||
? { [K in Field]: T[Field] }
|
||||
: never
|
||||
: Field extends Record<string, any>
|
||||
? {
|
||||
[K in keyof Field]: K extends keyof T
|
||||
? T[K] extends {
|
||||
__type: "Relationship";
|
||||
__resource: infer Resource;
|
||||
}
|
||||
? NonNullable<Resource> extends TypedSchema
|
||||
? T[K] extends { __array: true }
|
||||
? Array<InferResult<NonNullable<Resource>, Field[K]>>
|
||||
: null extends Resource
|
||||
? InferResult<NonNullable<Resource>, Field[K]> | null
|
||||
: InferResult<NonNullable<Resource>, Field[K]>
|
||||
: never
|
||||
: T[K] extends {
|
||||
__type: "ComplexCalculation";
|
||||
__returnType: infer ReturnType;
|
||||
}
|
||||
? NonNullable<ReturnType> extends TypedSchema
|
||||
? null extends ReturnType
|
||||
? InferResult<NonNullable<ReturnType>, Field[K]["fields"]> | null
|
||||
: InferResult<NonNullable<ReturnType>, Field[K]["fields"]>
|
||||
: ReturnType
|
||||
: NonNullable<T[K]> extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
|
||||
? NonNullable<T[K]> extends { __array: true }
|
||||
? Field[K] extends any[]
|
||||
? null extends T[K]
|
||||
? Array<
|
||||
UnionToIntersection<
|
||||
{
|
||||
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||
? E extends TypedMapFields
|
||||
? E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: E extends Record<string, any>
|
||||
? {
|
||||
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||
? null extends NonNullable<T[K]>[NestedKey]
|
||||
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: never;
|
||||
}[number]
|
||||
>
|
||||
> | null
|
||||
: Array<
|
||||
UnionToIntersection<
|
||||
{
|
||||
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||
? E extends TypedMapFields
|
||||
? E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: E extends Record<string, any>
|
||||
? {
|
||||
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||
? null extends NonNullable<T[K]>[NestedKey]
|
||||
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: never;
|
||||
}[number]
|
||||
>
|
||||
>
|
||||
: never
|
||||
: Field[K] extends any[]
|
||||
? null extends T[K]
|
||||
? UnionToIntersection<
|
||||
{
|
||||
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||
? E extends TypedMapFields
|
||||
? E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: E extends Record<string, any>
|
||||
? {
|
||||
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||
? null extends NonNullable<T[K]>[NestedKey]
|
||||
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: never;
|
||||
}[number]
|
||||
> | null
|
||||
: UnionToIntersection<
|
||||
{
|
||||
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
|
||||
? E extends TypedMapFields
|
||||
? E extends keyof T[K]
|
||||
? { [P in E]: T[K][P] }
|
||||
: never
|
||||
: E extends Record<string, any>
|
||||
? {
|
||||
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
|
||||
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
|
||||
? null extends NonNullable<T[K]>[NestedKey]
|
||||
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
|
||||
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: E extends keyof NonNullable<T[K]>
|
||||
? { [P in E]: NonNullable<T[K]>[P] }
|
||||
: never
|
||||
: never;
|
||||
}[number]
|
||||
>
|
||||
: never
|
||||
: T[K] extends { __type: "Union"; __primitiveFields: any }
|
||||
? T[K] extends { __array: true }
|
||||
? Field[K] extends any[]
|
||||
? null extends T[K]
|
||||
? Array<InferUnionFieldValue<T[K], Field[K]>> | null
|
||||
: Array<InferUnionFieldValue<T[K], Field[K]>>
|
||||
: never
|
||||
: Field[K] extends any[]
|
||||
? null extends T[K]
|
||||
? InferUnionFieldValue<T[K], Field[K]> | null
|
||||
: InferUnionFieldValue<T[K], Field[K]>
|
||||
: never
|
||||
: NonNullable<T[K]> extends TypedSchema
|
||||
? null extends T[K]
|
||||
? InferResult<NonNullable<T[K]>, Field[K]> | null
|
||||
: InferResult<NonNullable<T[K]>, Field[K]>
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
: never;
|
||||
|
||||
export type InferResult<
|
||||
T extends TypedSchema,
|
||||
SelectedFields extends UnifiedFieldSelection<T>[] | undefined,
|
||||
> = SelectedFields extends undefined
|
||||
? {}
|
||||
: SelectedFields extends []
|
||||
? {}
|
||||
: SelectedFields extends UnifiedFieldSelection<T>[]
|
||||
? UnionToIntersection<
|
||||
{
|
||||
[K in keyof SelectedFields]: InferFieldValue<T, SelectedFields[K]>;
|
||||
}[number]
|
||||
>
|
||||
: {};
|
||||
|
||||
// Pagination conditional types
|
||||
// Checks if a page configuration object has any pagination parameters
|
||||
export type HasPaginationParams<Page> =
|
||||
Page extends { offset: any } ? true :
|
||||
Page extends { after: any } ? true :
|
||||
Page extends { before: any } ? true :
|
||||
false;
|
||||
|
||||
// Infer which pagination type is being used from the page config
|
||||
export type InferPaginationType<Page> =
|
||||
Page extends { offset: any } ? "offset" :
|
||||
Page extends { after: any } | { before: any } ? "keyset" :
|
||||
never;
|
||||
|
||||
// Returns either non-paginated (array) or paginated result based on page params
|
||||
// For single pagination type support (offset-only or keyset-only)
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export type ConditionalPaginatedResult<
|
||||
Page,
|
||||
RecordType,
|
||||
PaginatedType
|
||||
> = Page extends undefined
|
||||
? RecordType
|
||||
: HasPaginationParams<Page> extends true
|
||||
? PaginatedType
|
||||
: RecordType;
|
||||
|
||||
// For actions supporting both offset and keyset pagination
|
||||
// Infers the specific pagination type based on which params were passed
|
||||
export type ConditionalPaginatedResultMixed<
|
||||
Page,
|
||||
RecordType,
|
||||
OffsetType,
|
||||
KeysetType
|
||||
> = Page extends undefined
|
||||
? RecordType
|
||||
: HasPaginationParams<Page> extends true
|
||||
? InferPaginationType<Page> extends "offset"
|
||||
? OffsetType
|
||||
: InferPaginationType<Page> extends "keyset"
|
||||
? KeysetType
|
||||
: OffsetType | KeysetType // Fallback to union if can't determine
|
||||
: RecordType;
|
||||
|
||||
export type SuccessDataFunc<T extends (...args: any[]) => Promise<any>> = Extract<
|
||||
Awaited<ReturnType<T>>,
|
||||
{ success: true }
|
||||
>["data"];
|
||||
|
||||
|
||||
export type ErrorData<T extends (...args: any[]) => Promise<any>> = Extract<
|
||||
Awaited<ReturnType<T>>,
|
||||
{ success: false }
|
||||
>["errors"];
|
||||
|
||||
/**
|
||||
* Represents an error from an unsuccessful RPC call.
|
||||
*
|
||||
* This type matches the error structure defined in the AshTypescript.Rpc.Error protocol.
|
||||
*
|
||||
* @example
|
||||
* const error: AshRpcError = {
|
||||
* type: "invalid_changes",
|
||||
* message: "Invalid value for field %{field}",
|
||||
* shortMessage: "Invalid changes",
|
||||
* vars: { field: "email" },
|
||||
* fields: ["email"],
|
||||
* path: ["user", "email"],
|
||||
* details: { suggestion: "Provide a valid email address" }
|
||||
* }
|
||||
*/
|
||||
export type AshRpcError = {
|
||||
/** Machine-readable error type (e.g., "invalid_changes", "not_found") */
|
||||
type: string;
|
||||
/** Full error message (may contain template variables like %{key}) */
|
||||
message: string;
|
||||
/** Concise version of the message */
|
||||
shortMessage: string;
|
||||
/** Variables to interpolate into the message template */
|
||||
vars: Record<string, any>;
|
||||
/** List of affected field names (for field-level errors) */
|
||||
fields: string[];
|
||||
/** Path to the error location in the data structure */
|
||||
path: string[];
|
||||
/** Optional map with extra details (e.g., suggestions, hints) */
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the result of a validation RPC call.
|
||||
*
|
||||
* All validation actions return this same structure, indicating either
|
||||
* successful validation or a list of validation errors.
|
||||
*
|
||||
* @example
|
||||
* // Successful validation
|
||||
* const result: ValidationResult = { success: true };
|
||||
*
|
||||
* // Failed validation
|
||||
* const result: ValidationResult = {
|
||||
* success: false,
|
||||
* errors: [
|
||||
* {
|
||||
* type: "required",
|
||||
* message: "is required",
|
||||
* shortMessage: "Required field",
|
||||
* vars: { field: "email" },
|
||||
* fields: ["email"],
|
||||
* path: []
|
||||
* }
|
||||
* ]
|
||||
* };
|
||||
*/
|
||||
export type ValidationResult =
|
||||
| { success: true }
|
||||
| { success: false; errors: AshRpcError[]; };
|
||||
|
||||
|
||||
|
||||
|
||||
86
assets/js/index.tsx
Normal file
86
assets/js/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { initLandingPage } from "./animation";
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
const el = document.getElementById("animation-container");
|
||||
if (el) return initLandingPage(el);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-100 text-base-content">
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/ash-project/ash_typescript/main/logos/ash-typescript.png"
|
||||
alt="AshTypescript"
|
||||
className="w-16 h-16"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">AshTypescript</h1>
|
||||
<p className="text-lg opacity-70">End-to-end type safety from Ash to TypeScript</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mb-10">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-5">
|
||||
<h2 className="text-2xl font-bold">Main Features</h2>
|
||||
<div className="flex-1"></div>
|
||||
<a href="https://hexdocs.pm/ash_typescript" target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Docs
|
||||
</a>
|
||||
<a href="https://github.com/ash-project/ash_typescript" target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<a href="https://hexdocs.pm/ash_typescript/first-rpc-action.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-base">Type-Safe RPC</h3>
|
||||
<p className="text-sm opacity-70">Auto-generated typed functions for every Ash action.</p>
|
||||
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="https://hexdocs.pm/ash_typescript/typed-controllers.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-base">Typed Controllers</h3>
|
||||
<p className="text-sm opacity-70">Typed route helpers for Phoenix controllers.</p>
|
||||
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="https://hexdocs.pm/ash_typescript/typed-channels.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-base">Typed Channels</h3>
|
||||
<p className="text-sm opacity-70">Typed event subscriptions for Phoenix channels.</p>
|
||||
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="https://hexdocs.pm/ash_typescript/form-validation.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title text-base">Zod Validation</h3>
|
||||
<p className="text-sm opacity-70">Generated Zod schemas for form validation.</p>
|
||||
<div className="text-sm text-primary mt-1">View docs →</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="animation-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("app")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
145
assets/package-lock.json
generated
Normal file
145
assets/package-lock.json
generated
Normal file
@@ -0,0 +1,145 @@
|
||||
{
|
||||
"name": "assets",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"phoenix": "file:../deps/phoenix",
|
||||
"phoenix_html": "file:../deps/phoenix_html",
|
||||
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"topbar": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9"
|
||||
}
|
||||
},
|
||||
"../deps/phoenix": {
|
||||
"version": "1.8.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.28.6",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"documentation": "^14.0.3",
|
||||
"eslint": "10.0.2",
|
||||
"eslint-plugin-jest": "29.15.0",
|
||||
"jest": "^30.0.0",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"mock-socket": "^9.3.1"
|
||||
}
|
||||
},
|
||||
"../deps/phoenix_html": {
|
||||
"version": "4.3.0"
|
||||
},
|
||||
"../deps/phoenix_live_view": {
|
||||
"version": "1.1.28",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"morphdom": "2.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "7.27.2",
|
||||
"@babel/core": "7.27.4",
|
||||
"@babel/preset-env": "7.27.2",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"css.escape": "^1.5.1",
|
||||
"eslint": "9.29.0",
|
||||
"eslint-plugin-jest": "28.14.0",
|
||||
"eslint-plugin-playwright": "^2.2.0",
|
||||
"globals": "^16.2.0",
|
||||
"jest": "^30.0.0",
|
||||
"jest-environment-jsdom": "^30.0.0",
|
||||
"jest-monocart-coverage": "^1.1.1",
|
||||
"monocart-reporter": "^2.9.21",
|
||||
"phoenix": "1.7.21",
|
||||
"prettier": "3.5.3",
|
||||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.34.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/phoenix": {
|
||||
"resolved": "../deps/phoenix",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/phoenix_html": {
|
||||
"resolved": "../deps/phoenix_html",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/phoenix_live_view": {
|
||||
"resolved": "../deps/phoenix_live_view",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/topbar": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/topbar/-/topbar-3.0.1.tgz",
|
||||
"integrity": "sha512-3CFWdkUC4KLsyLpW8IcX5EXskFtJtNl9O2/qNO/4Ncd/lWgipY/pmyv0D8bw2bTepl/+MUGhNhu2n3IitUuQNA==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
assets/package.json
Normal file
14
assets/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"phoenix": "file:../deps/phoenix",
|
||||
"phoenix_html": "file:../deps/phoenix_html",
|
||||
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"topbar": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9"
|
||||
}
|
||||
}
|
||||
34
assets/tsconfig.json
Normal file
34
assets/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
// This file is needed on most editors to enable the intelligent autocompletion
|
||||
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
|
||||
//
|
||||
// Note: This file assumes a basic esbuild setup without node_modules.
|
||||
// We include a generic paths alias to deps to mimic how esbuild resolves
|
||||
// the Phoenix and LiveView JavaScript assets.
|
||||
// If you have a package.json in your project, you should remove the
|
||||
// paths configuration and instead add the phoenix dependencies to the
|
||||
// dependencies section of your package.json:
|
||||
//
|
||||
// {
|
||||
// ...
|
||||
// "dependencies": {
|
||||
// ...,
|
||||
// "phoenix": "../deps/phoenix",
|
||||
// "phoenix_html": "../deps/phoenix_html",
|
||||
// "phoenix_live_view": "../deps/phoenix_live_view"
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Feel free to adjust this configuration however you need.
|
||||
{
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["../deps/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["js/**/*"]
|
||||
}
|
||||
124
assets/vendor/daisyui-theme.js
vendored
Normal file
124
assets/vendor/daisyui-theme.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1031
assets/vendor/daisyui.js
vendored
Normal file
1031
assets/vendor/daisyui.js
vendored
Normal file
File diff suppressed because one or more lines are too long
43
assets/vendor/heroicons.js
vendored
Normal file
43
assets/vendor/heroicons.js
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
const plugin = require("tailwindcss/plugin")
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
module.exports = plugin(function({matchComponents, theme}) {
|
||||
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
|
||||
let values = {}
|
||||
let icons = [
|
||||
["", "/24/outline"],
|
||||
["-solid", "/24/solid"],
|
||||
["-mini", "/20/solid"],
|
||||
["-micro", "/16/solid"]
|
||||
]
|
||||
icons.forEach(([suffix, dir]) => {
|
||||
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
|
||||
let name = path.basename(file, ".svg") + suffix
|
||||
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
|
||||
})
|
||||
})
|
||||
matchComponents({
|
||||
"hero": ({name, fullPath}) => {
|
||||
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
|
||||
content = encodeURIComponent(content)
|
||||
let size = theme("spacing.6")
|
||||
if (name.endsWith("-mini")) {
|
||||
size = theme("spacing.5")
|
||||
} else if (name.endsWith("-micro")) {
|
||||
size = theme("spacing.4")
|
||||
}
|
||||
return {
|
||||
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
|
||||
"-webkit-mask": `var(--hero-${name})`,
|
||||
"mask": `var(--hero-${name})`,
|
||||
"mask-repeat": "no-repeat",
|
||||
"background-color": "currentColor",
|
||||
"vertical-align": "middle",
|
||||
"display": "inline-block",
|
||||
"width": size,
|
||||
"height": size
|
||||
}
|
||||
}
|
||||
}, {values})
|
||||
})
|
||||
Reference in New Issue
Block a user