Skip to content

Commit ed968a1

Browse files
committed
Links: add/edit/delete working
1 parent a0c8d49 commit ed968a1

33 files changed

+2616
-826
lines changed

app/components/AdminIcon.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { PiLockBold } from "react-icons/pi";
2+
3+
export function AdminIcon() {
4+
return <PiLockBold />;
5+
}

app/components/AlertWidget.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
import type { AlertMessage } from "~/types/AlertMessage";
3+
4+
5+
type AlertWidgetProps = {
6+
alert: AlertMessage | null;
7+
};
8+
9+
export function AlertWidget({ alert }:AlertWidgetProps) {
10+
11+
return (
12+
alert ?
13+
<div className={`alert alert-${alert.type}`}>
14+
{alert.message}
15+
</div>
16+
: null
17+
);
18+
}

app/components/ColorSchemeToggle.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
1-
'use client';
2-
31
import React from "react";
42

5-
6-
7-
83
function getColorScheme() {
94

105
if (typeof window === 'undefined') {
@@ -20,13 +15,11 @@ function getColorScheme() {
2015
} else {
2116
return 'light';
2217
}
23-
2418
}
2519

2620
export function ColorSchemeToggle() {
2721
const [ currentScheme, setColorScheme ] = React.useState(getColorScheme());
2822

29-
3023
const onClick = (scheme: 'light' | 'dark' | 'auto') => {
3124
if (scheme == 'auto') {
3225
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
@@ -45,6 +38,4 @@ export function ColorSchemeToggle() {
4538
<button className={`btn btn-sm ${ currentScheme == 'dark' ? 'btn-primary' : 'btn-outline-primary'}`} onClick={() => onClick('dark')}>Dark</button>
4639
</div>
4740
);
48-
}
49-
50-
// <button onClick={() => setColorScheme('auto')}>Auto</button>
41+
}

app/components/Tag.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Link as RemixLink } from "@remix-run/react";
2+
3+
type TagProps = {
4+
tag: string,
5+
url: string,
6+
}
7+
8+
export function Tag({ tag, url }: TagProps) {
9+
return (
10+
<RemixLink
11+
key={tag}
12+
to={url}
13+
className="badge text-bg-primary text-decoration-none me-2"
14+
>
15+
{tag.replace('-', ' ')}
16+
</RemixLink>
17+
);
18+
}

app/components/TagList.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { Link as RemixLink } from "@remix-run/react";
22

3-
export function TagList(tags: string[]) {
3+
type TagListProps = {
4+
tags: string[],
5+
urlBuilder: (tag: string) => string,
6+
}
7+
8+
export function TagList( {tags, urlBuilder }: TagListProps) {
49
return (
510
<>
611
{tags.map((tag) => (
712
<RemixLink
813
key={tag}
9-
to={`/patterns/tags.html?tag=${tag}`}
14+
to={urlBuilder(tag)}
1015
className="badge text-bg-primary text-decoration-none me-2"
1116
>
1217
{tag.replace('-', ' ')}

app/db/connection.server.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as schema from "./schema";
2+
import { drizzle } from "drizzle-orm/postgres-js";
3+
import postgres from "postgres";
4+
5+
import { processEnvOrThrow } from "~/util/processEnvOrThrow.server";
6+
7+
function connect() {
8+
const url_str = processEnvOrThrow("DB_URL");
9+
const password = processEnvOrThrow("DB_PASSWORD");
10+
11+
const url = new URL(url_str);
12+
url.password = password;
13+
14+
return postgres(url.toString(), {
15+
ssl: "require",
16+
connection: {
17+
application_name: "regex-zone",
18+
}
19+
});
20+
}
21+
22+
23+
const db = drizzle(connect(), { schema });
24+
25+
export { connect, db };

app/db/schema.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { sql } from "drizzle-orm";
2+
import {
3+
timestamp,
4+
index,
5+
jsonb,
6+
pgTable,
7+
text,
8+
uniqueIndex,
9+
} from "drizzle-orm/pg-core";
10+
11+
export const regex_link = pgTable(
12+
"regex_link",
13+
{
14+
rxl_id: text("rxl_id")
15+
.notNull()
16+
.primaryKey()
17+
.default(sql`CONCAT('rxl_', gen_random_uuid()::VARCHAR)`),
18+
rxl_created_at: timestamp("rxl_created_at", { withTimezone: true })
19+
.notNull()
20+
.default(sql`now()`),
21+
rxl_updated_at: timestamp("rxl_updated_at", { withTimezone: true })
22+
.notNull()
23+
.default(sql`now()`),
24+
rxl_title: text("rxl_title").notNull(),
25+
rxl_url: text("rxl_url").notNull(),
26+
rxl_cached: jsonb("rxl_cached")
27+
.notNull()
28+
.default(sql`'{}'::jsonb`),
29+
rxl_tags: text("rxl_tags")
30+
.array()
31+
.notNull()
32+
.default(sql`ARRAY[]::text[]`).$type<string[]>(),
33+
},
34+
(table) => ({
35+
rxl_created_at_idx: index("created_at_idx").on(table.rxl_created_at),
36+
rxl_url_idx: uniqueIndex("rxl_url_idx").on(table.rxl_url),
37+
})
38+
);

app/routes/links._index.tsx

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
import type { MetaFunction } from "@remix-run/node";
1+
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
2+
import { json, Link as RemixLink, useLoaderData, useRouteLoaderData } from "@remix-run/react";
3+
import { cookieStorage } from "~/services/session.server";
4+
import { User } from "~/types/User";
5+
import { AlertWidget } from "~/components/AlertWidget";
6+
import type { AlertMessage } from "~/types/AlertMessage";
7+
import { db } from "~/db/connection.server";
8+
import { regex_link } from "~/db/schema";
9+
import { LinkTagUrlBuilder } from "~/util/LinkTagUrlBuilder";
10+
import { TagList } from "~/components/TagList";
11+
import { authenticator } from "~/services/auth.server";
12+
import { PiLockKey } from "react-icons/pi";
13+
import { AdminIcon } from "~/components/AdminIcon";
214

315
export const meta: MetaFunction = () => {
416
return [
@@ -7,13 +19,77 @@ export const meta: MetaFunction = () => {
719
];
820
};
921

22+
export async function loader({ request }: LoaderFunctionArgs) {
23+
// Retrieves the current session from the incoming request's Cookie header
24+
const session = await cookieStorage.getSession(request.headers.get("Cookie"));
25+
26+
// Retrieve the session value set in the previous request
27+
const message = session.get("message");
28+
console.log("loader message", JSON.stringify(message));
29+
30+
const links = await db.select().from(regex_link);
31+
32+
const user = authenticator.isAuthenticated(request);
33+
34+
35+
// Commit the session and return the message
36+
return json(
37+
{ links, message, user },
38+
{
39+
headers: {
40+
"Set-Cookie": await cookieStorage.commitSession(session),
41+
},
42+
}
43+
);
44+
}
1045
export default function Index() {
46+
const user = useRouteLoaderData<User | null>("root");
47+
const data = useLoaderData<typeof loader>();
48+
49+
console.log("func message", JSON.stringify(data));
50+
51+
const message = data.message as AlertMessage | undefined;
52+
53+
const links = data.links;
54+
1155
return (
1256
<>
13-
<h1 className="py-2">Links</h1>
14-
<div className="alert alert-info">
15-
Coming soon...
57+
<div className="d-flex justify-content-between align-items-center">
58+
<h1 className="py-2">Links</h1>
59+
{ user && user.isAdmin ?
60+
<div>
61+
<RemixLink to="/links/add.html" className="btn btn-primary mx-1"><AdminIcon /> Add</RemixLink>
62+
<RemixLink to="/links/import.html" className="btn btn-primary mx-1"><AdminIcon /> Import</RemixLink>
63+
</div>
64+
: null }
1665
</div>
66+
{message ? <AlertWidget alert={message}/> : null}
67+
{links.length == 0 ? <div className="alert alert-warning">No links found</div> :
68+
<table className="table table-striped table-hover">
69+
<thead className="d-none">
70+
<tr>
71+
<th>Description</th>
72+
<th>Tags</th>
73+
</tr>
74+
</thead>
75+
<tbody>
76+
{links.map(link => (
77+
<tr key={link.rxl_id}>
78+
<td><a href={link.rxl_url}>{link.rxl_title}</a></td>
79+
<td className="text-end">
80+
<TagList tags={link.rxl_tags} urlBuilder={LinkTagUrlBuilder} />
81+
{ user && user.isAdmin ?
82+
<>
83+
<RemixLink to={`/links/edit.html?rxl_id=${link.rxl_id}`} className="btn btn-sm btn-secondary mx-1"><AdminIcon /> Edit</RemixLink>
84+
<RemixLink to={`/links/delete.html?rxl_id=${link.rxl_id}`} className="btn btn-sm btn-secondary mx-1"><AdminIcon /> Delete</RemixLink>
85+
</>
86+
: null }
87+
</td>
88+
</tr>
89+
))}
90+
</tbody>
91+
</table>
92+
}
1793
</>
1894
);
1995
}

app/routes/links.add[.]html.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
2+
import { json, Link as RemixLink, redirect, useLoaderData } from "@remix-run/react";
3+
import { eq } from "drizzle-orm"
4+
5+
import { db } from "~/db/connection.server";
6+
import { regex_link } from "~/db/schema";
7+
import { authenticator } from "~/services/auth.server";
8+
import { getFormString } from "~/util/getFormString";
9+
10+
export const meta: MetaFunction = () => {
11+
return [
12+
{ title: "Add Link - Regex Zone" },
13+
];
14+
};
15+
16+
export async function loader({ request }: LoaderFunctionArgs) {
17+
18+
const user = await authenticator.isAuthenticated(request);
19+
if (!user || !user.isAdmin) {
20+
//LATER: flash error
21+
return redirect("/links/");
22+
}
23+
24+
// Commit the session and return the message
25+
return json(
26+
{ user },
27+
);
28+
}
29+
30+
export async function action({ request }: ActionFunctionArgs) {
31+
const formData = await request.formData();
32+
33+
await db.insert(regex_link).values({
34+
rxl_url: getFormString(formData.get("rxl_url")),
35+
rxl_title: getFormString(formData.get("rxl_title")),
36+
rxl_tags: getFormString(formData.get("rxl_tags")).split(' '),
37+
});
38+
39+
//LATER: flash success
40+
return redirect("/links/");
41+
}
42+
43+
export default function Index() {
44+
45+
return (
46+
<>
47+
<h1 className="py-2">Add Link</h1>
48+
<form method="post">
49+
<div className="mb-3">
50+
<label htmlFor="rxl_url" className="form-label">URL</label>
51+
<input type="text" className="form-control" id="rxl_url" name="rxl_url" defaultValue="" />
52+
</div>
53+
<div className="mb-3">
54+
<label htmlFor="rxl_title" className="form-label">Text</label>
55+
<input type="text" className="form-control" id="rxl_title" name="rxl_title" defaultValue="" />
56+
</div>
57+
<div className="mb-3">
58+
<label htmlFor="rxl_tags" className="form-label">Tags</label>
59+
<input type="text" className="form-control" id="rxl_tags" name="rxl_tags" defaultValue="" />
60+
</div>
61+
<input type="submit" className="btn btn-primary" value="Save" />
62+
<RemixLink className="btn btn-outline-primary mx-2" to="/links/">Cancel</RemixLink>
63+
</form>
64+
</>
65+
);
66+
}

0 commit comments

Comments
 (0)