Browse Source

feat: Add description for links fix #51 (#342)

*  Add description for links fix #51

* feat: show description under original link

* feat: make advanced options input full-width

* fix: showing newly created link in the table

* fix: improve edit form style

* feat: add description migration

* fix: make description type optional

Co-authored-by: poeti8 <ezzati.upt@gmail.com>
pull/358/head
glenn-louarn 9 months ago
committed by GitHub
parent
commit
e492542428
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 196 additions and 91 deletions
  1. +48
    -7
      client/components/LinksTable.tsx
  2. +103
    -75
      client/components/Shortener.tsx
  3. +5
    -1
      client/store/links.ts
  4. +1
    -0
      global.d.ts
  5. +1
    -0
      package.json
  6. +4
    -2
      server/handlers/links.ts
  7. +1
    -0
      server/handlers/types.d.ts
  8. +12
    -0
      server/handlers/validators.ts
  9. +10
    -0
      server/migrations/20200718124944_description.ts
  10. +1
    -0
      server/models/link.ts
  11. +10
    -6
      server/queries/link.ts

+ 48
- 7
client/components/LinksTable.tsx View File

@ -84,14 +84,14 @@ const Action = (props: React.ComponentProps) => (
);
const ogLinkFlex = { flexGrow: [1, 3, 7], flexShrink: [1, 3, 7] };
const createdFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
const createdFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
const shortLinkFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
const viewsFlex = {
flexGrow: [0.5, 0.5, 1],
flexShrink: [0.5, 0.5, 1],
justifyContent: "flex-end"
};
const actionsFlex = { flexGrow: [1, 1, 2.5], flexShrink: [1, 1, 2.5] };
const actionsFlex = { flexGrow: [1, 1, 3], flexShrink: [1, 1, 3] };
interface RowProps {
index: number;
@ -109,6 +109,7 @@ interface BanForm {
interface EditForm {
target: string;
address: string;
description: string;
}
const Row: FC<RowProps> = ({ index, link, setDeleteModal }) => {
@ -119,7 +120,8 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
const [editFormState, { text, label }] = useFormState<EditForm>(
{
target: link.target,
address: link.address
address: link.address,
description: link.description
},
{ withIds: true }
);
@ -175,7 +177,14 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
<>
<Tr key={link.id}>
<Td {...ogLinkFlex} withFade>
<ALink href={link.target}>{link.target}</ALink>
<Col alignItems="flex-start">
<ALink href={link.target}>{link.target}</ALink>
{link.description && (
<Text fontSize={[13, 14]} color="#888">
{link.description}
</Text>
)}
</Col>
</Td>
<Td {...createdFlex}>{`${formatDistanceToNow(
new Date(link.created_at)
@ -292,9 +301,15 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
</Tr>
{showEdit && (
<EditContent as="tr">
<Col as="td" alignItems="flex-start" px={[3, 3, 24]} py={[3, 3, 24]}>
<Flex alignItems="flex-start">
<Col alignItems="flex-start" mr={[0, 3, 3]}>
<Col
as="td"
alignItems="flex-start"
px={[3, 3, 24]}
py={[3, 3, 24]}
width={1}
>
<Flex alignItems="flex-start" width={1}>
<Col alignItems="flex-start" mr={3}>
<Text
{...label("target")}
as="label"
@ -343,6 +358,32 @@ const Row: FC = ({ index, link, setDeleteModal }) => {
</Flex>
</Col>
</Flex>
<Flex alignItems="flex-start" width={1} mt={3}>
<Col alignItems="flex-start">
<Text
{...label("description")}
as="label"
mb={2}
fontSize={[14, 15]}
bold
>
Description:
</Text>
<Flex as="form">
<TextInput
{...text("description")}
placeholder="description..."
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[1, 300, 420]}
pl={[3, 24]}
pr={[3, 24]}
required
/>
</Flex>
</Col>
</Flex>
<Button
color="blue"
mt={3}

+ 103
- 75
client/components/Shortener.tsx View File

@ -51,6 +51,7 @@ interface Form {
domain?: string;
customurl?: string;
password?: string;
description?: string;
showAdvanced?: boolean;
}
@ -238,81 +239,108 @@ const Shortener = () => {
alignSelf="flex-start"
/>
{formState.values.showAdvanced && (
<Flex mt={4} flexDirection={["column", "row"]}>
<Col mb={[3, 0]}>
<Text
as="label"
{...label("domain")}
fontSize={[14, 15]}
mb={2}
bold
>
Domain
</Text>
<Select
{...select("domain")}
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
fontSize={[14, 15]}
height={[40, 44]}
width={[170, 200]}
options={[
{ key: defaultDomain, value: "" },
...domains.map(d => ({
key: d.address,
value: d.address
}))
]}
/>
</Col>
<Col mb={[3, 0]} ml={[0, 24]}>
<Text
as="label"
{...label("customurl")}
fontSize={[14, 15]}
mb={2}
bold
>
{formState.values.domain || defaultDomain}/
</Text>
<TextInput
{...text("customurl")}
placeholder="Custom address..."
autocomplete="off"
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[210, 240]}
/>
</Col>
<Col ml={[0, 24]}>
<Text
as="label"
{...label("password")}
fontSize={[14, 15]}
mb={2}
bold
>
Password:
</Text>
<TextInput
{...password("password")}
placeholder="Password..."
autocomplete="off"
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[210, 240]}
/>
</Col>
</Flex>
<div>
<Flex mt={4} flexDirection={["column", "row"]}>
<Col mb={[3, 0]}>
<Text
as="label"
{...label("domain")}
fontSize={[14, 15]}
mb={2}
bold
>
Domain
</Text>
<Select
{...select("domain")}
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
fontSize={[14, 15]}
height={[40, 44]}
width={[1, 210, 240]}
options={[
{ key: defaultDomain, value: "" },
...domains.map(d => ({
key: d.address,
value: d.address
}))
]}
/>
</Col>
<Col mb={[3, 0]} ml={[0, 24]}>
<Text
as="label"
{...label("customurl")}
fontSize={[14, 15]}
mb={2}
bold
>
{formState.values.domain || defaultDomain}/
</Text>
<TextInput
{...text("customurl")}
placeholder="Custom address..."
autocomplete="off"
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[1, 210, 240]}
/>
</Col>
<Col ml={[0, 24]}>
<Text
as="label"
{...label("password")}
fontSize={[14, 15]}
mb={2}
bold
>
Password:
</Text>
<TextInput
{...password("password")}
placeholder="Password..."
autocomplete="off"
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={[1, 210, 240]}
/>
</Col>
</Flex>
<Flex mt={[3]} flexDirection={["column", "row"]}>
<Col width={1}>
<Text
as="description"
{...label("description")}
fontSize={[14, 15]}
mb={2}
bold
>
Description
</Text>
<TextInput
{...text("description")}
placeholder="Description"
data-lpignore
pl={[3, 24]}
pr={[3, 24]}
placeholderSize={[13, 14]}
fontSize={[14, 15]}
height={[40, 44]}
width={1}
maxWidth="100%"
/>
</Col>
</Flex>
</div>
)}
</Col>
);

+ 5
- 1
client/store/links.ts View File

@ -15,6 +15,7 @@ export interface Link {
domain?: string;
domain_id?: number;
password?: string;
description?: string;
target: string;
updated_at: string;
user_id?: number;
@ -42,6 +43,7 @@ export interface EditLink {
id: string;
target: string;
address: string;
description: string;
}
export interface LinksQuery {
@ -118,7 +120,9 @@ export const links: Links = {
actions.update(res.data);
}),
add: action((state, payload) => {
state.items.pop();
if (state.items.length >= 10) {
state.items.pop();
}
state.items.unshift(payload);
}),
set: action((state, payload) => {

+ 1
- 0
global.d.ts View File

@ -76,6 +76,7 @@ interface Link {
created_at: string;
domain_id?: number;
password?: string;
description?: string;
target: string;
updated_at: string;
user_id?: number;

+ 1
- 0
package.json View File

@ -11,6 +11,7 @@
"build": "next build client/ && rimraf production-server && tsc --project tsconfig.json && copyfiles -f \"server/mail/*.html\" production-server/mail",
"start": "npm run migrate && NODE_ENV=production node production-server/server.js",
"migrate": "knex migrate:latest --env production",
"migrate:make": "knex migrate:make --env production",
"lint": "eslint server/ --ext .js,.ts --fix",
"lint:nofix": "eslint server/ --ext .js,.ts",
"docs:build": "cd docs/api && tsc generate.ts --resolveJsonModule && node generate && cd ../.."

+ 4
- 2
server/handlers/links.ts View File

@ -42,7 +42,7 @@ export const get: Handler = async (req, res) => {
};
export const create: Handler = async (req: CreateLinkReq, res) => {
const { reuse, password, customurl, target, domain } = req.body;
const { reuse, password, customurl, description, target, domain } = req.body;
const domain_id = domain ? domain.id : null;
const targetDomain = URL.parse(target).hostname;
@ -85,6 +85,7 @@ export const create: Handler = async (req: CreateLinkReq, res) => {
password,
address,
domain_id,
description,
target,
user_id: req.user && req.user.id
});
@ -99,7 +100,7 @@ export const create: Handler = async (req: CreateLinkReq, res) => {
};
export const edit: Handler = async (req, res) => {
const { address, target } = req.body;
const { address, target, description } = req.body;
if (!address && !target) {
throw new CustomError("Should at least update one field.");
@ -142,6 +143,7 @@ export const edit: Handler = async (req, res) => {
},
{
...(address && { address }),
...(description && { description }),
...(target && { target })
}
);

+ 1
- 0
server/handlers/types.d.ts View File

@ -5,6 +5,7 @@ export interface CreateLinkReq extends Request {
reuse?: boolean;
password?: string;
customurl?: string;
description?: string;
domain?: Domain;
target: string;
};

+ 12
- 0
server/handlers/validators.ts View File

@ -81,6 +81,12 @@ export const createLink = [
.withMessage("Only users can use this field.")
.isBoolean()
.withMessage("Reuse must be boolean."),
body("description")
.optional()
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
body("domain")
.optional()
.custom(checkUser)
@ -132,6 +138,12 @@ export const editLink = [
.withMessage("Custom URL is not valid")
.custom(value => !preservedUrls.some(url => url.toLowerCase() === value))
.withMessage("You can't use this custom URL."),
body("description")
.optional()
.isString()
.trim()
.isLength({ min: 0, max: 2040 })
.withMessage("Description length must be between 0 and 2040."),
param("id", "ID is invalid.")
.exists({ checkFalsy: true, checkNull: true })
.isLength({ min: 36, max: 36 })

+ 10
- 0
server/migrations/20200718124944_description.ts View File

@ -0,0 +1,10 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<any> {
const hasDescription = await knex.schema.hasColumn("links", "description");
if (!hasDescription) {
await knex.schema.alterTable("links", table => {
table.string("description");
});
}
}

+ 1
- 0
server/models/link.ts View File

@ -9,6 +9,7 @@ export async function createLinkTable(knex: Knex) {
knex.raw('create extension if not exists "uuid-ossp"');
table.increments("id").primary();
table.string("address").notNullable();
table.string("description");
table
.boolean("banned")
.notNullable()

+ 10
- 6
server/queries/link.ts View File

@ -12,6 +12,7 @@ const selectable = [
"links.domain_id",
"links.updated_at",
"links.password",
"links.description",
"links.target",
"links.visit_count",
"links.user_id",
@ -52,9 +53,10 @@ export const total = async (match: Match, params: TotalParams = {}) => {
});
if (params.search) {
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
params.search
]);
query.andWhereRaw(
"links.description || ' ' || links.address || ' ' || target ILIKE '%' || ? || '%'",
[params.search]
);
}
const [{ count }] = await query.count("id");
@ -77,9 +79,10 @@ export const get = async (match: Partial, params: GetParams) => {
.orderBy("created_at", "desc");
if (params.search) {
query.andWhereRaw("links.address || ' ' || target ILIKE '%' || ? || '%'", [
params.search
]);
query.andWhereRaw(
"links.description || ' ' || links.address || ' ' || target ILIKE '%' || ? || '%'",
[params.search]
);
}
query.leftJoin("domains", "links.domain_id", "domains.id");
@ -131,6 +134,7 @@ export const create = async (params: Create) => {
domain_id: params.domain_id || null,
user_id: params.user_id || null,
address: params.address,
description: params.description || null,
target: params.target
},
"*"

Loading…
Cancel
Save