mirror of
https://github.com/pawelmalak/snippet-box.git
synced 2025-12-21 21:33:10 +01:00
Pinned snippets
This commit is contained in:
10
client/package-lock.json
generated
10
client/package-lock.json
generated
@@ -1844,6 +1844,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@mdi/js": {
|
||||||
|
"version": "6.1.95",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-6.1.95.tgz",
|
||||||
|
"integrity": "sha512-e6ZXoNB9uciA4smHHVkZWyYX/RRZsza8XfLvnOuvdLQttpzRKTqR26jG/COL0o4ES9vbAk9PX5mXTEstg0TCsg=="
|
||||||
|
},
|
||||||
|
"@mdi/react": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mdi/react/-/react-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-NztRgUxSYD+ImaKN94Tg66VVVqXj4SmlDGzZoz48H9riJ+Awha56sfXH2fegw819NWo7KI3oeS1Es0lNQqwr0w=="
|
||||||
|
},
|
||||||
"@nodelib/fs.scandir": {
|
"@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@icons-pack/react-simple-icons": "^4.6.1",
|
"@icons-pack/react-simple-icons": "^4.6.1",
|
||||||
|
"@mdi/js": "^6.1.95",
|
||||||
|
"@mdi/react": "^1.5.0",
|
||||||
"@testing-library/jest-dom": "^5.14.1",
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
"@testing-library/react": "^11.2.7",
|
"@testing-library/react": "^11.2.7",
|
||||||
"@testing-library/user-event": "^12.8.3",
|
"@testing-library/user-event": "^12.8.3",
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Snippet } from '../../typescript/interfaces';
|
import { Snippet } from '../../typescript/interfaces';
|
||||||
import { dateParser } from '../../utils';
|
import { dateParser, badgeColor } from '../../utils';
|
||||||
import { Badge, Button, Card } from '../UI';
|
import { Badge, Button, Card } from '../UI';
|
||||||
import { SnippetsContext } from '../../store';
|
import { SnippetsContext } from '../../store';
|
||||||
|
import Icon from '@mdi/react';
|
||||||
|
import { mdiPin } from '@mdi/js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnippetCard = (props: Props): JSX.Element => {
|
export const SnippetCard = (props: Props): JSX.Element => {
|
||||||
const { title, description, language, code, id, updatedAt } = props.snippet;
|
const { title, description, language, code, id, updatedAt, isPinned } =
|
||||||
|
props.snippet;
|
||||||
const { setSnippet } = useContext(SnippetsContext);
|
const { setSnippet } = useContext(SnippetsContext);
|
||||||
|
|
||||||
const copyHandler = () => {
|
const copyHandler = () => {
|
||||||
@@ -18,15 +21,28 @@ export const SnippetCard = (props: Props): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={title}>
|
<Card>
|
||||||
|
{/* TITLE */}
|
||||||
|
<h5 className='card-title d-flex align-items-center justify-content-between'>
|
||||||
|
{title}
|
||||||
|
{isPinned ? <Icon path={mdiPin} size={0.8} color='#212529' /> : ''}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{/* UPDATE DATE */}
|
||||||
<h6 className='card-subtitle mb-2 text-muted'>
|
<h6 className='card-subtitle mb-2 text-muted'>
|
||||||
{dateParser(updatedAt).relative}
|
{dateParser(updatedAt).relative}
|
||||||
</h6>
|
</h6>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
<p className='text-truncate'>
|
<p className='text-truncate'>
|
||||||
{description ? description : 'No description'}
|
{description ? description : 'No description'}
|
||||||
</p>
|
</p>
|
||||||
<Badge text={language} color='success' />
|
|
||||||
|
{/* LANGUAGE */}
|
||||||
|
<Badge text={language} color={badgeColor(language)} />
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
<div className='d-flex justify-content-end'>
|
<div className='d-flex justify-content-end'>
|
||||||
<Link
|
<Link
|
||||||
to={{
|
to={{
|
||||||
|
|||||||
@@ -4,16 +4,27 @@ import { SnippetsContext } from '../../store';
|
|||||||
import { Snippet } from '../../typescript/interfaces';
|
import { Snippet } from '../../typescript/interfaces';
|
||||||
import { dateParser } from '../../utils';
|
import { dateParser } from '../../utils';
|
||||||
import { Button, Card } from '../UI';
|
import { Button, Card } from '../UI';
|
||||||
|
import Icon from '@mdi/react';
|
||||||
|
import { mdiPin } from '@mdi/js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnippetDetails = (props: Props): JSX.Element => {
|
export const SnippetDetails = (props: Props): JSX.Element => {
|
||||||
const { title, language, createdAt, updatedAt, description, code, id } =
|
const {
|
||||||
props.snippet;
|
title,
|
||||||
|
language,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
description,
|
||||||
|
code,
|
||||||
|
id,
|
||||||
|
isPinned
|
||||||
|
} = props.snippet;
|
||||||
|
|
||||||
const { deleteSnippet } = useContext(SnippetsContext);
|
const { deleteSnippet, toggleSnippetPin, setSnippet } =
|
||||||
|
useContext(SnippetsContext);
|
||||||
|
|
||||||
const creationDate = dateParser(createdAt);
|
const creationDate = dateParser(createdAt);
|
||||||
const updateDate = dateParser(updatedAt);
|
const updateDate = dateParser(updatedAt);
|
||||||
@@ -23,7 +34,11 @@ export const SnippetDetails = (props: Props): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title={title}>
|
<Card>
|
||||||
|
<h5 className='card-title d-flex align-items-center justify-content-between'>
|
||||||
|
{title}
|
||||||
|
{isPinned ? <Icon path={mdiPin} size={0.8} color='#212529' /> : ''}
|
||||||
|
</h5>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
|
|
||||||
{/* LANGUAGE */}
|
{/* LANGUAGE */}
|
||||||
@@ -54,14 +69,21 @@ export const SnippetDetails = (props: Props): JSX.Element => {
|
|||||||
state: { from: window.location.pathname }
|
state: { from: window.location.pathname }
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button text='Edit' color='dark' small outline classes='me-3' />
|
<Button
|
||||||
|
text='Edit'
|
||||||
|
color='dark'
|
||||||
|
small
|
||||||
|
outline
|
||||||
|
classes='me-3'
|
||||||
|
handler={() => setSnippet(id)}
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
text='Pin snippet'
|
text={`${isPinned ? 'Unpin snippet' : 'Pin snippet'}`}
|
||||||
color='dark'
|
color='dark'
|
||||||
small
|
small
|
||||||
outline
|
outline
|
||||||
handler={copyHandler}
|
handler={() => toggleSnippetPin(id)}
|
||||||
classes='me-3'
|
classes='me-3'
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export const SnippetForm = (props: Props): JSX.Element => {
|
|||||||
description: '',
|
description: '',
|
||||||
language: '',
|
language: '',
|
||||||
code: '',
|
code: '',
|
||||||
docs: ''
|
docs: '',
|
||||||
|
isPinned: false
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
interface Props {
|
interface Props<T> {
|
||||||
title: string;
|
title: string;
|
||||||
prevDest?: string;
|
prevDest?: string;
|
||||||
|
prevState?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PageHeader = (props: Props): JSX.Element => {
|
export const PageHeader = <T,>(props: Props<T>): JSX.Element => {
|
||||||
const { title, prevDest } = props;
|
const { title, prevDest, prevState } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='col-12'>
|
<div className='col-12'>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
{prevDest && (
|
{prevDest && (
|
||||||
<h6>
|
<h6>
|
||||||
<Link to={prevDest} className='text-decoration-none text-dark'>
|
<Link
|
||||||
|
to={{
|
||||||
|
pathname: prevDest,
|
||||||
|
state: prevState
|
||||||
|
}}
|
||||||
|
className='text-decoration-none text-dark'
|
||||||
|
>
|
||||||
<- Go back
|
<- Go back
|
||||||
</Link>
|
</Link>
|
||||||
</h6>
|
</h6>
|
||||||
|
|||||||
@@ -21,19 +21,21 @@ export const Editor = (): JSX.Element => {
|
|||||||
|
|
||||||
// Set snippet
|
// Set snippet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentSnippet(-1);
|
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
setCurrentSnippet(+id);
|
setCurrentSnippet(+id);
|
||||||
setInEdit(true);
|
setInEdit(true);
|
||||||
}
|
}
|
||||||
}, [id, setCurrentSnippet]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
{inEdit ? (
|
{inEdit ? (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<PageHeader title='Edit snippet' prevDest={from} />
|
<PageHeader<{ from: string }>
|
||||||
|
title='Edit snippet'
|
||||||
|
prevDest={from}
|
||||||
|
prevState={{ from: '/snippets' }}
|
||||||
|
/>
|
||||||
<SnippetForm inEdit />
|
<SnippetForm inEdit />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const SnippetsContext = createContext<Context>({
|
|||||||
createSnippet: (snippet: NewSnippet) => {},
|
createSnippet: (snippet: NewSnippet) => {},
|
||||||
updateSnippet: (snippet: NewSnippet, id: number) => {},
|
updateSnippet: (snippet: NewSnippet, id: number) => {},
|
||||||
deleteSnippet: (id: number) => {},
|
deleteSnippet: (id: number) => {},
|
||||||
|
toggleSnippetPin: (id: number) => {},
|
||||||
countSnippets: () => {}
|
countSnippets: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setSnippet = (id: number): void => {
|
const setSnippet = (id: number): void => {
|
||||||
|
getSnippetById(id);
|
||||||
|
|
||||||
if (id < 0) {
|
if (id < 0) {
|
||||||
setCurrentSnippet(null);
|
setCurrentSnippet(null);
|
||||||
return;
|
return;
|
||||||
@@ -82,7 +85,10 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
|||||||
...snippets.slice(oldSnippetIdx + 1)
|
...snippets.slice(oldSnippetIdx + 1)
|
||||||
]);
|
]);
|
||||||
setCurrentSnippet(res.data.data);
|
setCurrentSnippet(res.data.data);
|
||||||
history.push(`/snippet/${res.data.data.id}`, { from: '/snippets' });
|
history.push({
|
||||||
|
pathname: `/snippet/${res.data.data.id}`,
|
||||||
|
state: { from: '/snippets' }
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(err => console.log(err));
|
.catch(err => console.log(err));
|
||||||
};
|
};
|
||||||
@@ -104,6 +110,14 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSnippetPin = (id: number): void => {
|
||||||
|
const snippet = snippets.find(s => s.id === id);
|
||||||
|
|
||||||
|
if (snippet) {
|
||||||
|
updateSnippet({ ...snippet, isPinned: !snippet.isPinned }, id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const countSnippets = (): void => {
|
const countSnippets = (): void => {
|
||||||
axios
|
axios
|
||||||
.get<Response<LanguageCount[]>>('/api/snippets/statistics/count')
|
.get<Response<LanguageCount[]>>('/api/snippets/statistics/count')
|
||||||
@@ -121,6 +135,7 @@ export const SnippetsContextProvider = (props: Props): JSX.Element => {
|
|||||||
createSnippet,
|
createSnippet,
|
||||||
updateSnippet,
|
updateSnippet,
|
||||||
deleteSnippet,
|
deleteSnippet,
|
||||||
|
toggleSnippetPin,
|
||||||
countSnippets
|
countSnippets
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export interface Context {
|
|||||||
createSnippet: (snippet: NewSnippet) => void;
|
createSnippet: (snippet: NewSnippet) => void;
|
||||||
updateSnippet: (snippet: NewSnippet, id: number) => void;
|
updateSnippet: (snippet: NewSnippet, id: number) => void;
|
||||||
deleteSnippet: (id: number) => void;
|
deleteSnippet: (id: number) => void;
|
||||||
|
toggleSnippetPin: (id: number) => void;
|
||||||
countSnippets: () => void;
|
countSnippets: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface NewSnippet {
|
|||||||
language: string;
|
language: string;
|
||||||
code: string;
|
code: string;
|
||||||
docs?: string;
|
docs?: string;
|
||||||
|
isPinned: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Snippet extends Model, NewSnippet {}
|
export interface Snippet extends Model, NewSnippet {}
|
||||||
|
|||||||
30
client/src/utils/badgeColor.ts
Normal file
30
client/src/utils/badgeColor.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Color } from '../typescript/types';
|
||||||
|
|
||||||
|
export const badgeColor = (language: string): Color => {
|
||||||
|
const code = language.toLowerCase().charCodeAt(0);
|
||||||
|
let color: Color = 'primary';
|
||||||
|
|
||||||
|
switch (code % 6) {
|
||||||
|
case 0:
|
||||||
|
default:
|
||||||
|
color = 'primary';
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
color = 'success';
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
color = 'info';
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
color = 'warning';
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
color = 'danger';
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
color = 'dark';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return color;
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './dateParser';
|
export * from './dateParser';
|
||||||
|
export * from './badgeColor';
|
||||||
|
|||||||
14
src/db/migrations/01_pinned_snippets.ts
Normal file
14
src/db/migrations/01_pinned_snippets.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { DataTypes, QueryInterface } from 'sequelize';
|
||||||
|
const { INTEGER } = DataTypes;
|
||||||
|
|
||||||
|
export const up = async (queryInterface: QueryInterface): Promise<void> => {
|
||||||
|
await queryInterface.addColumn('snippets', 'isPinned', {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = async (queryInterface: QueryInterface): Promise<void> => {
|
||||||
|
await queryInterface.removeColumn('snippets', 'isPinned');
|
||||||
|
};
|
||||||
@@ -36,6 +36,11 @@ export const SnippetModel = sequelize.define<SnippetInstance>('Snippet', {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: ''
|
defaultValue: ''
|
||||||
},
|
},
|
||||||
|
isPinned: {
|
||||||
|
type: INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: 0
|
||||||
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
type: DATE
|
type: DATE
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Snippet extends Model {
|
|||||||
language: string;
|
language: string;
|
||||||
code: string;
|
code: string;
|
||||||
docs: string;
|
docs: string;
|
||||||
|
isPinned: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnippetCreationAttributes
|
export interface SnippetCreationAttributes
|
||||||
|
|||||||
Reference in New Issue
Block a user