diff options
Diffstat (limited to 'examples/ssr/src')
20 files changed, 636 insertions, 0 deletions
diff --git a/examples/ssr/src/api.ts b/examples/ssr/src/api.ts new file mode 100644 index 000000000..74e09eb73 --- /dev/null +++ b/examples/ssr/src/api.ts @@ -0,0 +1,79 @@ +export interface Product { + id: number; + name: string; + price: number; + image: string; +} + +interface User { + id: number; +} + +interface Cart { + items: Array<{ + id: number; + name: string; + count: number; + }>; +} + +async function get<T>( + incomingReq: Request, + endpoint: string, + cb: (response: Response) => Promise<T> +): Promise<T> { + const origin = new URL(incomingReq.url).origin; + const response = await fetch(`${origin}${endpoint}`, { + credentials: 'same-origin', + headers: incomingReq.headers, + }); + if (!response.ok) { + // TODO make this better... + throw new Error('Fetch failed'); + } + return cb(response); +} + +export async function getProducts(incomingReq: Request): Promise<Product[]> { + return get<Product[]>(incomingReq, '/api/products', async (response) => { + const products: Product[] = await response.json(); + return products; + }); +} + +export async function getProduct(incomingReq: Request, id: number): Promise<Product> { + return get<Product>(incomingReq, `/api/products/${id}`, async (response) => { + const product: Product = await response.json(); + return product; + }); +} + +export async function getUser(incomingReq: Request): Promise<User> { + return get<User>(incomingReq, `/api/user`, async (response) => { + const user: User = await response.json(); + return user; + }); +} + +export async function getCart(incomingReq: Request): Promise<Cart> { + return get<Cart>(incomingReq, `/api/cart`, async (response) => { + const cart: Cart = await response.json(); + return cart; + }); +} + +export async function addToUserCart(id: number | string, name: string): Promise<void> { + await fetch(`${location.origin}/api/cart`, { + credentials: 'same-origin', + method: 'POST', + mode: 'no-cors', + headers: { + 'Content-Type': 'application/json', + Cache: 'no-cache', + }, + body: JSON.stringify({ + id, + name, + }), + }); +} diff --git a/examples/ssr/src/components/AddToCart.svelte b/examples/ssr/src/components/AddToCart.svelte new file mode 100644 index 000000000..bae888b6b --- /dev/null +++ b/examples/ssr/src/components/AddToCart.svelte @@ -0,0 +1,53 @@ +<script> + import { addToUserCart } from '../api'; + let { id, name } = $props() + + function notifyCartItem(id) { + window.dispatchEvent(new CustomEvent('add-to-cart', { + detail: id + })); + } + + async function addToCart() { + await addToUserCart(id, name); + notifyCartItem(id); + } +</script> +<style> + button { + display:block; + padding:0.5em 1em 0.5em 1em; + border-radius:100px; + border:none; + font-size: 1.4em; + position:relative; + background:#0652DD; + cursor:pointer; + height:2em; + width:10em; + overflow:hidden; + transition:transform 0.1s; + z-index:1; +} +button:hover { + transform:scale(1.1); +} + +.pretext { + color:#fff; + background:#0652DD; + position:absolute; + top:0; + left:0; + height:100%; + width:100%; + display:flex; + justify-content:center; + align-items:center; + font-family: 'Quicksand', sans-serif; + text-transform: uppercase; +} +</style> +<button click={addToCart}> + <span class="pretext">Add to cart</span> +</button> diff --git a/examples/ssr/src/components/Cart.svelte b/examples/ssr/src/components/Cart.svelte new file mode 100644 index 000000000..5d4b7d251 --- /dev/null +++ b/examples/ssr/src/components/Cart.svelte @@ -0,0 +1,34 @@ +<script> + let { count } = $props() + let items = new Set(); + + function onAddToCart(ev) { + const id = ev.detail; + items.add(id); + count++; + } +</script> +<style> + .cart { + display: flex; + align-items: center; + text-decoration: none; + color: inherit; + } + .cart :first-child { + margin-right: 5px; + } + + .cart-icon { + font-size: 36px; + } + + .count { + font-size: 24px; + } +</style> +<svelte:window onadd-to-cart={onAddToCart}/> +<a href="/cart" class="cart"> + <span class="material-icons cart-icon">shopping_cart</span> + <span class="count">{count}</span> +</a> diff --git a/examples/ssr/src/components/Container.astro b/examples/ssr/src/components/Container.astro new file mode 100644 index 000000000..f1741156c --- /dev/null +++ b/examples/ssr/src/components/Container.astro @@ -0,0 +1,13 @@ +--- +const { tag = 'div' } = Astro.props; +const Tag = tag; +--- + +<style> + .container { + width: 1248px; /** TODO: responsive */ + margin-left: auto; + margin-right: auto; + } +</style> +<Tag class="container"><slot /></Tag> diff --git a/examples/ssr/src/components/Header.astro b/examples/ssr/src/components/Header.astro new file mode 100644 index 000000000..d266733e9 --- /dev/null +++ b/examples/ssr/src/components/Header.astro @@ -0,0 +1,49 @@ +--- +import TextDecorationSkip from './TextDecorationSkip.astro'; +import Cart from './Cart.svelte'; +import { getCart } from '../api'; + +const cart = await getCart(Astro.request); +const cartCount = cart.items.reduce((sum, item) => sum + item.count, 0); +--- + +<style> + @import url('https://fonts.googleapis.com/css2?family=Lobster&display=swap'); + + header { + margin: 1rem 2rem; + display: flex; + justify-content: space-between; + } + + h1 { + margin: 0; + font-family: 'Lobster', cursive; + color: black; + } + + a, + a:visited { + color: inherit; + text-decoration: none; + } + + .right-pane { + display: flex; + } + + .material-icons { + font-size: 36px; + margin-right: 1rem; + } +</style> +<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> +<header> + <h1><a href="/"><TextDecorationSkip text="Online Store" /></a></h1> + <div class="right-pane"> + <a href="/login"> + <span class="material-icons"> login</span> + </a> + <Cart client:idle count={cartCount} /> + </div> +</header> diff --git a/examples/ssr/src/components/ProductListing.astro b/examples/ssr/src/components/ProductListing.astro new file mode 100644 index 000000000..14e6e1d8c --- /dev/null +++ b/examples/ssr/src/components/ProductListing.astro @@ -0,0 +1,70 @@ +--- +import type { Product } from '../api'; + +interface Props { + products: Product[]; +} + +const { products } = Astro.props; +--- + +<style> + ul { + list-style-type: none; + margin: 0; + padding: 0; + display: flex; + } + + figure { + width: 200px; + padding: 7px; + border: 1px solid black; + display: flex; + flex-direction: column; + } + + figure figcaption { + text-align: center; + line-height: 1.6; + } + + figure img { + width: 100%; + height: 250px; + object-fit: cover; + } + + .product a { + display: block; + text-decoration: none; + color: inherit; + } + + .name { + font-weight: 500; + } + + .price { + font-size: 90%; + color: #787878; + } +</style> +<slot name="title" /> +<ul> + { + products.map((product) => ( + <li class="product"> + <a href={`/products/${product.id}`}> + <figure> + <img src={product.image} /> + <figcaption> + <div class="name">{product.name}</div> + <div class="price">${product.price}</div> + </figcaption> + </figure> + </a> + </li> + )) + } +</ul> diff --git a/examples/ssr/src/components/TextDecorationSkip.astro b/examples/ssr/src/components/TextDecorationSkip.astro new file mode 100644 index 000000000..707027763 --- /dev/null +++ b/examples/ssr/src/components/TextDecorationSkip.astro @@ -0,0 +1,23 @@ +--- +interface Props { + text: string; +} + +const { text } = Astro.props; +const words = text.split(' '); +const last = words.length - 1; +--- + +<style> + span { + text-decoration: underline; + } +</style> +{ + words.map((word, i) => ( + <Fragment> + <span>{word}</span> + {i !== last && <Fragment> </Fragment>} + </Fragment> + )) +} diff --git a/examples/ssr/src/models/db.json b/examples/ssr/src/models/db.json new file mode 100644 index 000000000..76f9e4da3 --- /dev/null +++ b/examples/ssr/src/models/db.json @@ -0,0 +1,28 @@ +{ + "products": [ + { + "id": 1, + "name": "Cereal", + "price": 3.99, + "image": "/images/products/cereal.jpg" + }, + { + "id": 2, + "name": "Yogurt", + "price": 3.97, + "image": "/images/products/yogurt.jpg" + }, + { + "id": 3, + "name": "Rolled Oats", + "price": 2.89, + "image": "/images/products/oats.jpg" + }, + { + "id": 4, + "name": "Muffins", + "price": 4.39, + "image": "/images/products/muffins.jpg" + } + ] +} diff --git a/examples/ssr/src/models/db.ts b/examples/ssr/src/models/db.ts new file mode 100644 index 000000000..0ec181f9a --- /dev/null +++ b/examples/ssr/src/models/db.ts @@ -0,0 +1,6 @@ +import db from './db.json'; + +const products = db.products; +const productMap = new Map(products.map((product) => [product.id, product])); + +export { products, productMap }; diff --git a/examples/ssr/src/models/session.ts b/examples/ssr/src/models/session.ts new file mode 100644 index 000000000..16dce00b4 --- /dev/null +++ b/examples/ssr/src/models/session.ts @@ -0,0 +1,2 @@ +// Normally this would be in a database. +export const userCartItems = new Map(); diff --git a/examples/ssr/src/pages/api/cart.ts b/examples/ssr/src/pages/api/cart.ts new file mode 100644 index 000000000..8d64ec7d8 --- /dev/null +++ b/examples/ssr/src/pages/api/cart.ts @@ -0,0 +1,38 @@ +import type { APIContext } from 'astro'; +import { userCartItems } from '../../models/session'; + +export function GET({ cookies }: APIContext) { + let userId = cookies.get('user-id')?.value; + + if (!userId || !userCartItems.has(userId)) { + return Response.json({ items: [] }); + } + let items = userCartItems.get(userId); + let array = Array.from(items.values()); + + return Response.json({ items: array }); +} + +interface AddToCartItem { + id: number; + name: string; +} + +export async function POST({ cookies, request }: APIContext) { + const item: AddToCartItem = await request.json(); + + let userId = cookies.get('user-id')?.value; + + if (!userCartItems.has(userId)) { + userCartItems.set(userId, new Map()); + } + + let cart = userCartItems.get(userId); + if (cart.has(item.id)) { + cart.get(item.id).count++; + } else { + cart.set(item.id, { id: item.id, name: item.name, count: 1 }); + } + + return Response.json({ ok: true }); +} diff --git a/examples/ssr/src/pages/api/products.ts b/examples/ssr/src/pages/api/products.ts new file mode 100644 index 000000000..8bf02a03d --- /dev/null +++ b/examples/ssr/src/pages/api/products.ts @@ -0,0 +1,5 @@ +import { products } from '../../models/db'; + +export function GET() { + return new Response(JSON.stringify(products)); +} diff --git a/examples/ssr/src/pages/api/products/[id].ts b/examples/ssr/src/pages/api/products/[id].ts new file mode 100644 index 000000000..f0f6fa89f --- /dev/null +++ b/examples/ssr/src/pages/api/products/[id].ts @@ -0,0 +1,16 @@ +import { productMap } from '../../../models/db'; +import type { APIContext } from 'astro'; + +export function GET({ params }: APIContext) { + const id = Number(params.id); + if (productMap.has(id)) { + const product = productMap.get(id); + + return new Response(JSON.stringify(product)); + } else { + return new Response(null, { + status: 400, + statusText: 'Not found', + }); + } +} diff --git a/examples/ssr/src/pages/cart.astro b/examples/ssr/src/pages/cart.astro new file mode 100644 index 000000000..40e5cf126 --- /dev/null +++ b/examples/ssr/src/pages/cart.astro @@ -0,0 +1,51 @@ +--- +import Header from '../components/Header.astro'; +import Container from '../components/Container.astro'; +import { getCart } from '../api'; + +if (!Astro.cookies.get('user-id')) { + return Astro.redirect('/'); +} + +// They must be logged in. + +const user = { name: 'test' }; // getUser? +const cart = await getCart(Astro.request); +--- + +<html lang="en"> + <head> + <title>Cart | Online Store</title> + <style> + h1 { + font-size: 36px; + } + </style> + </head> + <body> + <Header /> + + <Container tag="main"> + <h1>Cart</h1> + <p>Hi {user.name}! Here are your cart items:</p> + <table> + <thead> + <tr> + <th>Item</th> + <th>Count</th> + </tr> + </thead> + <tbody> + { + cart.items.map((item) => ( + <tr> + <td>{item.name}</td> + <td>{item.count}</td> + </tr> + )) + } + </tbody> + </table> + </Container> + </body> +</html> diff --git a/examples/ssr/src/pages/index.astro b/examples/ssr/src/pages/index.astro new file mode 100644 index 000000000..1ce70bc81 --- /dev/null +++ b/examples/ssr/src/pages/index.astro @@ -0,0 +1,33 @@ +--- +import Header from '../components/Header.astro'; +import Container from '../components/Container.astro'; +import ProductListing from '../components/ProductListing.astro'; +import { getProducts } from '../api'; +import '../styles/common.css'; + +const products = await getProducts(Astro.request); +--- + +<html lang="en"> + <head> + <title>Online Store</title> + <style> + h1 { + font-size: 36px; + } + + .product-listing-title { + text-align: center; + } + </style> + </head> + <body> + <Header /> + + <Container tag="main"> + <ProductListing products={products}> + <h2 class="product-listing-title" slot="title">Product Listing</h2> + </ProductListing> + </Container> + </body> +</html> diff --git a/examples/ssr/src/pages/login.astro b/examples/ssr/src/pages/login.astro new file mode 100644 index 000000000..030838a64 --- /dev/null +++ b/examples/ssr/src/pages/login.astro @@ -0,0 +1,58 @@ +--- +import Header from '../components/Header.astro'; +import Container from '../components/Container.astro'; +--- + +<html lang="en"> + <head> + <title>Online Store</title> + <style> + h1 { + font-size: 36px; + } + </style> + + <script type="module" is:inline> + document.addEventListener('DOMContentLoaded', () => { + const form = document.querySelector('form'); + if (!form) throw new Error('Form not found'); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const formData = new FormData(form); + const data = Object.fromEntries(formData); + + fetch('/login.form.async', { + method: 'POST', + body: JSON.stringify(data), + }) + .then((res) => res.json()) + .then(() => { + const result = document.querySelector('#result'); + if (result) { + result.innerHTML = + 'Progressive login was successful! you will be redirected to the store in 3 seconds'; + setTimeout(() => (location.href = '/'), 3000); + } + }); + }); + }); + </script> + </head> + <body> + <Header /> + + <Container tag="main"> + <h1>Login</h1> + <form action="/login.form" method="POST"> + <label for="name">Name</label> + <input type="text" name="name" /> + + <label for="password">Password</label> + <input type="password" name="password" /> + + <input type="submit" value="Submit" /> + </form> + <div id="result"></div> + </Container> + </body> +</html> diff --git a/examples/ssr/src/pages/login.form.async.ts b/examples/ssr/src/pages/login.form.async.ts new file mode 100644 index 000000000..94020d9c9 --- /dev/null +++ b/examples/ssr/src/pages/login.form.async.ts @@ -0,0 +1,14 @@ +import type { APIContext, APIRoute } from 'astro'; + +export const POST: APIRoute = ({ cookies }: APIContext) => { + // add a new cookie + cookies.set('user-id', '1', { + path: '/', + maxAge: 2592000, + }); + + return Response.json({ + ok: true, + user: 1, + }); +}; diff --git a/examples/ssr/src/pages/login.form.ts b/examples/ssr/src/pages/login.form.ts new file mode 100644 index 000000000..f3cd50db4 --- /dev/null +++ b/examples/ssr/src/pages/login.form.ts @@ -0,0 +1,16 @@ +import type { APIContext } from 'astro'; + +export function POST({ cookies }: APIContext) { + // add a new cookie + cookies.set('user-id', '1', { + path: '/', + maxAge: 2592000, + }); + + return new Response(null, { + status: 301, + headers: { + Location: '/', + }, + }); +} diff --git a/examples/ssr/src/pages/products/[id].astro b/examples/ssr/src/pages/products/[id].astro new file mode 100644 index 000000000..e90900e45 --- /dev/null +++ b/examples/ssr/src/pages/products/[id].astro @@ -0,0 +1,45 @@ +--- +import Header from '../../components/Header.astro'; +import Container from '../../components/Container.astro'; +import AddToCart from '../../components/AddToCart.svelte'; +import { getProduct } from '../../api'; +import '../../styles/common.css'; + +const id = Number(Astro.params.id); +const product = await getProduct(Astro.request, id); +--- + +<html lang="en"> + <head> + <title>{product.name} | Online Store</title> + <style> + h2 { + text-align: center; + font-size: 3.5rem; + } + + figure { + display: grid; + grid-template-columns: 1fr 1fr; + } + + img { + width: 400px; + } + </style> + </head> + <body> + <Header /> + + <Container tag="article"> + <h2>{product.name}</h2> + <figure> + <img src={product.image} /> + <figcaption> + <AddToCart client:idle id={id} name={product.name} /> + <p>Description here...</p> + </figcaption> + </figure> + </Container> + </body> +</html> diff --git a/examples/ssr/src/styles/common.css b/examples/ssr/src/styles/common.css new file mode 100644 index 000000000..9d73ad1a4 --- /dev/null +++ b/examples/ssr/src/styles/common.css @@ -0,0 +1,3 @@ +body { + font-family: 'GT America Standard', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} |