m33n4n-site 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +5 -0
  2. package/package.json +82 -0
  3. package/src/app/fonts.css +8 -0
  4. package/src/app/globals.css +192 -0
  5. package/src/components/fonts/welcome.ttf +0 -0
  6. package/src/components/layout/header.tsx +50 -0
  7. package/src/components/layout/protected-layout.tsx +39 -0
  8. package/src/components/layout/sidebar-nav.tsx +150 -0
  9. package/src/components/logo.tsx +43 -0
  10. package/src/components/markdown-renderer.tsx +19 -0
  11. package/src/components/page-transition.tsx +45 -0
  12. package/src/components/password-gate.tsx +178 -0
  13. package/src/components/portal-page.tsx +471 -0
  14. package/src/components/profile-card.tsx +107 -0
  15. package/src/components/ui/accordion.tsx +58 -0
  16. package/src/components/ui/alert-dialog.tsx +141 -0
  17. package/src/components/ui/alert.tsx +59 -0
  18. package/src/components/ui/avatar.tsx +50 -0
  19. package/src/components/ui/badge.tsx +36 -0
  20. package/src/components/ui/button.tsx +56 -0
  21. package/src/components/ui/calendar.tsx +70 -0
  22. package/src/components/ui/card.tsx +79 -0
  23. package/src/components/ui/carousel.tsx +262 -0
  24. package/src/components/ui/chart.tsx +365 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/collapsible.tsx +11 -0
  27. package/src/components/ui/dialog.tsx +122 -0
  28. package/src/components/ui/dropdown-menu.tsx +200 -0
  29. package/src/components/ui/form.tsx +178 -0
  30. package/src/components/ui/index.ts +38 -0
  31. package/src/components/ui/input.tsx +22 -0
  32. package/src/components/ui/label.tsx +26 -0
  33. package/src/components/ui/menubar.tsx +256 -0
  34. package/src/components/ui/popover.tsx +31 -0
  35. package/src/components/ui/progress.tsx +28 -0
  36. package/src/components/ui/radio-group.tsx +44 -0
  37. package/src/components/ui/scroll-area.tsx +48 -0
  38. package/src/components/ui/select.tsx +160 -0
  39. package/src/components/ui/separator.tsx +31 -0
  40. package/src/components/ui/sheet.tsx +140 -0
  41. package/src/components/ui/sidebar.tsx +790 -0
  42. package/src/components/ui/skeleton.tsx +15 -0
  43. package/src/components/ui/slider.tsx +28 -0
  44. package/src/components/ui/switch.tsx +29 -0
  45. package/src/components/ui/table.tsx +117 -0
  46. package/src/components/ui/tabs.tsx +55 -0
  47. package/src/components/ui/textarea.tsx +21 -0
  48. package/src/components/ui/toast.tsx +129 -0
  49. package/src/components/ui/toaster.tsx +35 -0
  50. package/src/components/ui/tooltip.tsx +30 -0
  51. package/src/components/upload-form.tsx +243 -0
  52. package/src/components/writeup-card.tsx +53 -0
  53. package/src/components/writeups-list.tsx +60 -0
  54. package/src/lib/actions.ts +21 -0
  55. package/src/lib/placeholder-images.json +88 -0
  56. package/src/lib/placeholder-images.ts +14 -0
  57. package/src/lib/types.ts +14 -0
  58. package/src/lib/utils.ts +6 -0
  59. package/src/lib/writeups.ts +76 -0
  60. package/tailwind.config.ts +110 -0
@@ -0,0 +1,243 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useForm, useFieldArray } from 'react-hook-form';
5
+ import { zodResolver } from '@hookform/resolvers/zod';
6
+ import * as z from 'zod';
7
+ import { suggestCategoriesAction } from '@/lib/actions';
8
+
9
+ import { Button } from '@/components/ui/button';
10
+ import {
11
+ Form,
12
+ FormControl,
13
+ FormDescription,
14
+ FormField,
15
+ FormItem,
16
+ FormLabel,
17
+ FormMessage,
18
+ } from '@/components/ui/form';
19
+ import { Input } from '@/components/ui/input';
20
+ import { Textarea } from '@/components/ui/textarea';
21
+ import { Bot, Loader2, Tag, X } from 'lucide-react';
22
+ import { Badge } from './ui/badge';
23
+ import { useToast } from '@/hooks/use-toast';
24
+
25
+ const formSchema = z.object({
26
+ title: z.string().min(1, 'Title is required.'),
27
+ content: z.string().min(50, 'Content must be at least 50 characters.'),
28
+ categories: z.array(z.string()).min(1, 'At least one category is required.'),
29
+ tags: z.array(z.string()).min(1, 'At least one tag is required.'),
30
+ });
31
+
32
+ type FormData = z.infer<typeof formSchema>;
33
+
34
+ export default function UploadForm() {
35
+ const [isSuggesting, setIsSuggesting] = useState(false);
36
+ const [newTag, setNewTag] = useState('');
37
+ const { toast } = useToast();
38
+
39
+ const form = useForm<FormData>({
40
+ resolver: zodResolver(formSchema),
41
+ defaultValues: {
42
+ title: '',
43
+ content: '',
44
+ categories: [],
45
+ tags: [],
46
+ },
47
+ });
48
+
49
+ const {
50
+ fields: tagFields,
51
+ append: appendTag,
52
+ remove: removeTag,
53
+ } = useFieldArray({ control: form.control, name: 'tags' });
54
+
55
+ const {
56
+ fields: categoryFields,
57
+ append: appendCategory,
58
+ remove: removeCategory,
59
+ } = useFieldArray({ control: form.control, name: 'categories' });
60
+
61
+ async function handleSuggestion() {
62
+ const content = form.getValues('content');
63
+ if (content.length < 50) {
64
+ form.setError('content', {
65
+ message: 'Content must be at least 50 characters for suggestions.',
66
+ });
67
+ return;
68
+ }
69
+ setIsSuggesting(true);
70
+ try {
71
+ const result = await suggestCategoriesAction({ writeupContent: content });
72
+ if (result.suggestedCategories) {
73
+ form.setValue('categories', result.suggestedCategories);
74
+ }
75
+ if (result.suggestedTags) {
76
+ form.setValue('tags', result.suggestedTags);
77
+ }
78
+ toast({
79
+ title: "Suggestions Generated",
80
+ description: "AI has suggested categories and tags for your write-up."
81
+ })
82
+ } catch (error) {
83
+ console.error(error);
84
+ toast({
85
+ variant: "destructive",
86
+ title: "Suggestion Failed",
87
+ description: "Could not generate suggestions. Please try again."
88
+ })
89
+ } finally {
90
+ setIsSuggesting(false);
91
+ }
92
+ }
93
+
94
+ function onSubmit(values: FormData) {
95
+ console.log('Submitting:', values);
96
+ toast({
97
+ title: "Write-up Submitted!",
98
+ description: "Check the console for the submitted data (mocked).",
99
+ });
100
+ form.reset();
101
+ }
102
+
103
+ const handleAddTag = () => {
104
+ if (newTag.trim() !== '') {
105
+ appendTag(newTag.trim());
106
+ setNewTag('');
107
+ }
108
+ };
109
+
110
+ return (
111
+ <Form {...form}>
112
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
113
+ <FormField
114
+ control={form.control}
115
+ name="title"
116
+ render={({ field }) => (
117
+ <FormItem>
118
+ <FormLabel className="text-lg">Title</FormLabel>
119
+ <FormControl>
120
+ <Input placeholder="e.g., HTB - Lame Walkthrough" {...field} />
121
+ </FormControl>
122
+ <FormMessage />
123
+ </FormItem>
124
+ )}
125
+ />
126
+
127
+ <FormField
128
+ control={form.control}
129
+ name="content"
130
+ render={({ field }) => (
131
+ <FormItem>
132
+ <FormLabel className="text-lg">Markdown Content</FormLabel>
133
+ <FormControl>
134
+ <Textarea
135
+ placeholder="Write your markdown content here..."
136
+ className="min-h-[300px] font-code"
137
+ {...field}
138
+ />
139
+ </FormControl>
140
+ <FormMessage />
141
+ </FormItem>
142
+ )}
143
+ />
144
+
145
+ <div className="rounded-lg border border-border bg-card p-4 space-y-4">
146
+ <div className="flex justify-between items-start">
147
+ <div>
148
+ <h3 className="text-lg font-semibold text-primary">AI Assistant</h3>
149
+ <p className="text-sm text-muted-foreground">Generate categories and tags automatically.</p>
150
+ </div>
151
+ <Button
152
+ type="button"
153
+ onClick={handleSuggestion}
154
+ disabled={isSuggesting}
155
+ variant="outline"
156
+ className="border-primary text-primary hover:bg-primary hover:text-primary-foreground"
157
+ >
158
+ {isSuggesting ? (
159
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
160
+ ) : (
161
+ <Bot className="mr-2 h-4 w-4" />
162
+ )}
163
+ Suggest
164
+ </Button>
165
+ </div>
166
+
167
+ <FormField
168
+ control={form.control}
169
+ name="categories"
170
+ render={() => (
171
+ <FormItem>
172
+ <FormLabel>Categories</FormLabel>
173
+ <div className="flex flex-wrap gap-2">
174
+ {categoryFields.map((field, index) => (
175
+ <Badge key={field.id} variant="secondary" className="text-md">
176
+ {form.getValues(`categories.${index}`)}
177
+ <button
178
+ type="button"
179
+ onClick={() => removeCategory(index)}
180
+ className="ml-2 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
181
+ >
182
+ <X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
183
+ </button>
184
+ </Badge>
185
+ ))}
186
+ </div>
187
+ <FormMessage />
188
+ </FormItem>
189
+ )}
190
+ />
191
+
192
+ <FormField
193
+ control={form.control}
194
+ name="tags"
195
+ render={() => (
196
+ <FormItem>
197
+ <FormLabel>Tags</FormLabel>
198
+ <div className="flex flex-wrap gap-2">
199
+ {tagFields.map((field, index) => (
200
+ <Badge key={field.id} variant="secondary" className="text-md font-code">
201
+ {form.getValues(`tags.${index}`)}
202
+ <button
203
+ type="button"
204
+ onClick={() => removeTag(index)}
205
+ className="ml-2 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
206
+ >
207
+ <X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
208
+ </button>
209
+ </Badge>
210
+ ))}
211
+ </div>
212
+ <div className="flex items-center gap-2 mt-2">
213
+ <Input
214
+ value={newTag}
215
+ onChange={(e) => setNewTag(e.target.value)}
216
+ onKeyDown={(e) => {
217
+ if (e.key === 'Enter') {
218
+ e.preventDefault();
219
+ handleAddTag();
220
+ }
221
+ }}
222
+ placeholder="Add a new tag"
223
+ className="w-48 h-8 font-code"
224
+ />
225
+ <Button type="button" onClick={handleAddTag} size="sm" variant="ghost">
226
+ <Tag className="mr-2 h-4 w-4"/>
227
+ Add Tag
228
+ </Button>
229
+ </div>
230
+ <FormMessage />
231
+ </FormItem>
232
+ )}
233
+ />
234
+ </div>
235
+
236
+
237
+ <Button type="submit" size="lg" className="w-full">
238
+ Submit Write-up
239
+ </Button>
240
+ </form>
241
+ </Form>
242
+ );
243
+ }
@@ -0,0 +1,53 @@
1
+
2
+ import Link from 'next/link';
3
+ import { format } from 'date-fns';
4
+ import { Card } from './ui/card';
5
+ import type { Writeup } from '@/lib/types';
6
+ import { Calendar, Folder } from 'lucide-react';
7
+ import Image from 'next/image';
8
+ import { getPlaceholderImage } from '@/lib/placeholder-images';
9
+
10
+ export default function WriteupCard({ writeup }: { writeup: Writeup }) {
11
+ const image = getPlaceholderImage(writeup.image);
12
+
13
+ return (
14
+ <Link href={`/writeups/${writeup.slug}`} className="group">
15
+ <Card className="flex flex-col md:flex-row bg-card/50 backdrop-blur-sm hover:border-primary/50 transition-all duration-300 ease-in-out transform hover:-translate-y-1 shadow-lg hover:shadow-primary/20 overflow-hidden h-full border-black/10">
16
+ <div className="flex flex-col p-6 w-full md:w-2/3">
17
+ <div className="flex-grow">
18
+ <h3 className="text-xl font-bold font-headline text-primary/90 group-hover:text-primary transition-colors">
19
+ {writeup.title}
20
+ </h3>
21
+ <p className="text-muted-foreground mt-2 text-sm">
22
+ {writeup.excerpt}
23
+ </p>
24
+ </div>
25
+ <div className="flex items-center text-xs text-muted-foreground mt-4 gap-4">
26
+ <div className="flex items-center">
27
+ <Calendar className="mr-1.5 h-3.5 w-3.5" />
28
+ <time dateTime={writeup.date}>
29
+ {format(new Date(writeup.date), 'MMM d, yyyy')}
30
+ </time>
31
+ </div>
32
+ <div className="flex items-center">
33
+ <Folder className="mr-1.5 h-3.5 w-3.5" />
34
+ <span>{writeup.categories.join(', ')}</span>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ {image && (
39
+ <div className="relative w-full md:w-1/3 h-48 md:h-auto">
40
+ <Image
41
+ src={image.imageUrl}
42
+ alt={writeup.title}
43
+ fill
44
+ className="object-cover group-hover:scale-105 transition-transform duration-300"
45
+ data-ai-hint={image.imageHint}
46
+ />
47
+ <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent md:bg-gradient-to-l"></div>
48
+ </div>
49
+ )}
50
+ </Card>
51
+ </Link>
52
+ );
53
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ import { useSearchParams } from 'next/navigation';
4
+ import { useMemo } from 'react';
5
+ import type { Writeup } from '@/lib/types';
6
+ import WriteupCard from './writeup-card';
7
+
8
+ export default function WriteupsList({ writeups }: { writeups: Writeup[] }) {
9
+ const searchParams = useSearchParams();
10
+ const searchQuery = searchParams.get('q');
11
+ const categoryQuery = searchParams.get('category');
12
+ const pathQuery = searchParams.get('path');
13
+
14
+ const filteredWriteups = useMemo(() => {
15
+ let filtered = writeups;
16
+
17
+ if (pathQuery) {
18
+ filtered = filtered.filter((writeup) => writeup.path === pathQuery);
19
+ }
20
+
21
+ if (categoryQuery) {
22
+ filtered = filtered.filter((writeup) =>
23
+ writeup.categories.includes(categoryQuery)
24
+ );
25
+ }
26
+
27
+ if (searchQuery) {
28
+ const lowercasedQuery = searchQuery.toLowerCase();
29
+ filtered = filtered.filter(
30
+ (writeup) =>
31
+ writeup.title.toLowerCase().includes(lowercasedQuery) ||
32
+ writeup.excerpt.toLowerCase().includes(lowercasedQuery) ||
33
+ writeup.tags.some((tag) =>
34
+ tag.toLowerCase().includes(lowercasedQuery)
35
+ )
36
+ );
37
+ }
38
+
39
+ return filtered;
40
+ }, [writeups, searchQuery, categoryQuery, pathQuery]);
41
+
42
+ if (filteredWriteups.length === 0) {
43
+ return (
44
+ <div className="flex flex-col items-center justify-center text-center py-16">
45
+ <p className="text-2xl font-bold text-primary">No Results Found</p>
46
+ <p className="text-muted-foreground mt-2">
47
+ Try adjusting your search or filter.
48
+ </p>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
55
+ {filteredWriteups.map((writeup, i) => (
56
+ <WriteupCard key={writeup.slug} writeup={writeup} />
57
+ ))}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,21 @@
1
+ 'use server';
2
+
3
+ import 'dotenv/config';
4
+ import {
5
+ suggestWriteupCategories,
6
+ type SuggestWriteupCategoriesInput,
7
+ type SuggestWriteupCategoriesOutput,
8
+ } from '@/ai/flows/suggest-writeup-categories';
9
+
10
+ export async function suggestCategoriesAction(
11
+ input: SuggestWriteupCategoriesInput
12
+ ): Promise<SuggestWriteupCategoriesOutput> {
13
+ try {
14
+ const result = await suggestWriteupCategories(input);
15
+ return result;
16
+ } catch (error) {
17
+ console.error('Error in suggestCategoriesAction:', error);
18
+ // In a real app, you might want to return a structured error response
19
+ throw new Error('Failed to get suggestions from AI');
20
+ }
21
+ }
@@ -0,0 +1,88 @@
1
+ {
2
+ "placeholderImages": [
3
+ {
4
+ "id": "profile-pic",
5
+ "description": "A placeholder for the user's profile picture.",
6
+ "imageUrl": "https://cdn.discordapp.com/avatars/717270343865073694/e6c0846a51a2c93ae405d083751e5f05.webp",
7
+ "imageHint": "hacker emblem"
8
+ },
9
+ {
10
+ "id": "sidebar-profile-pic",
11
+ "description": "A placeholder for the user's profile picture in the sidebar.",
12
+ "imageUrl": "https://images.unsplash.com/photo-1596854407944-bf87f6fdd49e?q=80&w=580&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
13
+ "imageHint": "cat helmet"
14
+ },
15
+ {
16
+ "id": "first-image",
17
+ "description": "extraimage",
18
+ "imageUrl": "https://shadowv0id.vercel.app/01.png",
19
+ "imageHint": "github 01"
20
+ },
21
+ {
22
+ "id": "second-image",
23
+ "description": "extraimage",
24
+ "imageUrl": "https://shadowv0id.vercel.app/02.png",
25
+ "imageHint": "github 02"
26
+ },
27
+ {
28
+ "id": "third-image",
29
+ "description": "extraimage",
30
+ "imageUrl": "https://shadowv0id.vercel.app/03.png",
31
+ "imageHint": "github 03"
32
+ },
33
+ {
34
+ "id": "fourth-image",
35
+ "description": "extraimage",
36
+ "imageUrl": "https://shadowv0id.vercel.app/04.png",
37
+ "imageHint": "github 03"
38
+ },
39
+ {
40
+ "id": "middle-image",
41
+ "description": "extraimage",
42
+ "imageUrl": "https://shadowv0id.vercel.app/servwr.jpg",
43
+ "imageHint": "server"
44
+ },
45
+ {
46
+ "id": "before-passwd",
47
+ "description": "extraimage",
48
+ "imageUrl": "https://shadowv0id.vercel.app/before-passwd.png",
49
+ "imageHint": "box1"
50
+ },
51
+ {
52
+ "id": "after-passwd",
53
+ "description": "extraimage",
54
+ "imageUrl": "https://shadowv0id.vercel.app/after-passwd.png",
55
+ "imageHint": "box2"
56
+ },
57
+ {
58
+ "id": "mouse-cursor",
59
+ "description": "extraimage",
60
+ "imageUrl": "https://shadowv0id.vercel.app/mouse-cursor.png",
61
+ "imageHint": "mouse-cursor"
62
+ },
63
+ {
64
+ "id": "chevron-down",
65
+ "description": "Decorative chevron pointing down.",
66
+ "imageUrl": "https://storage.googleapis.com/stedi-assets/N3kb0w/chevron.png",
67
+ "imageHint": "chevron down"
68
+ },
69
+ {
70
+ "id": "standard-post-banner",
71
+ "description": "Banner for a standard blog post.",
72
+ "imageUrl": "https://images.unsplash.com/photo-1518770660439-4636190af475?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3NDE5ODJ8MHwxfHNlYXJjaHwxfHx0ZWNobm9sb2d5fGVufDB8fHx8MTc2MjU1NTk2NHww&ixlib=rb-4.1.0&q=80&w=1080",
73
+ "imageHint": "technology code"
74
+ },
75
+ {
76
+ "id": "time-locked-banner",
77
+ "description": "Banner for a time-locked post, showing a clock.",
78
+ "imageUrl": "https://images.unsplash.com/photo-1508272534888-3101643936a2?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3NDE5ODJ8MHwxfHNlYXJjaHwxfHxjbG9ja3xlbnwwfHx8fDE3NjI1NTU5NjR8MA&ixlib=rb-4.1.0&q=80&w=1080",
79
+ "imageHint": "abstract clock"
80
+ },
81
+ {
82
+ "id": "token-gated-banner",
83
+ "description": "Banner for a token-gated post, showing a key or lock.",
84
+ "imageUrl": "https://images.unsplash.com/photo-1555865267-231a8f9d021c?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3NDE5ODJ8MHwxfHNlYXJjaHwxfHxrZXl8ZW58MHx8fHwxNzYyNTU1OTY0fDA&ixlib=rb-4.1.0&q=80&w=1080",
85
+ "imageHint": "golden key"
86
+ }
87
+ ]
88
+ }
@@ -0,0 +1,14 @@
1
+ import data from './placeholder-images.json';
2
+
3
+ export type ImagePlaceholder = {
4
+ id: string;
5
+ description: string;
6
+ imageUrl: string;
7
+ imageHint: string;
8
+ };
9
+
10
+ export const placeholderImages: ImagePlaceholder[] = data.placeholderImages;
11
+
12
+ export function getPlaceholderImage(id: string): ImagePlaceholder | undefined {
13
+ return placeholderImages.find((img) => img.id === id);
14
+ }
@@ -0,0 +1,14 @@
1
+
2
+ export type Writeup = {
3
+ slug: string;
4
+ title: string;
5
+ date: string;
6
+ excerpt: string;
7
+ categories: string[];
8
+ tags: string[];
9
+ content: string;
10
+ image: string; // Added image property
11
+ path?: 'direct';
12
+ releaseDate?: string; // Date when the write-up can be accessed
13
+ tokens?: string[]; // Array of tokens to bypass the release date lock
14
+ };
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import matter from 'gray-matter';
4
+ import type { Writeup } from './types';
5
+ import { cache } from 'react';
6
+
7
+ const writeupsDirectory = path.join(process.cwd(), 'writeups');
8
+
9
+ export const getWriteupsData = cache((): Writeup[] => {
10
+ if (!fs.existsSync(writeupsDirectory)) {
11
+ return [];
12
+ }
13
+
14
+ const categories = fs.readdirSync(writeupsDirectory);
15
+ const allWriteupsData: Writeup[] = [];
16
+
17
+ categories.forEach((category) => {
18
+ const categoryPath = path.join(writeupsDirectory, category);
19
+ if (!fs.statSync(categoryPath).isDirectory()) return;
20
+
21
+ const writeupFolders = fs.readdirSync(categoryPath);
22
+
23
+ writeupFolders.forEach((folder) => {
24
+ const slug = folder;
25
+ const writeupPath = path.join(categoryPath, folder);
26
+ const filePath = path.join(writeupPath, 'index.md');
27
+
28
+ if (fs.existsSync(filePath)) {
29
+ const fileContents = fs.readFileSync(filePath, 'utf8');
30
+ const { data, content } = matter(fileContents);
31
+
32
+ if (
33
+ !data.title ||
34
+ !data.date ||
35
+ !data.excerpt ||
36
+ !data.tags ||
37
+ !data.image
38
+ ) {
39
+ console.warn(`Skipping writeup with missing frontmatter: ${filePath}`);
40
+ return;
41
+ }
42
+
43
+ const writeup: Writeup = {
44
+ slug: slug,
45
+ title: data.title,
46
+ date: new Date(data.date).toISOString(),
47
+ excerpt: data.excerpt,
48
+ categories: [category],
49
+ tags: data.tags,
50
+ image: data.image,
51
+ content: content,
52
+ path: data.path,
53
+ releaseDate: data.releaseDate ? new Date(data.releaseDate).toISOString() : undefined,
54
+ tokens: data.tokens,
55
+ };
56
+ allWriteupsData.push(writeup);
57
+ }
58
+ });
59
+ });
60
+
61
+ return allWriteupsData.sort((a, b) => {
62
+ if (new Date(a.date) < new Date(b.date)) {
63
+ return 1;
64
+ } else {
65
+ return -1;
66
+ }
67
+ });
68
+ });
69
+
70
+ export function getWriteupBySlug(slug: string): Writeup | undefined {
71
+ return getWriteupsData().find((w) => w.slug === slug);
72
+ }
73
+
74
+ export function getAllCategories() {
75
+ return [...new Set(getWriteupsData().flatMap((w) => w.categories))];
76
+ }