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.
- package/README.md +5 -0
- package/package.json +82 -0
- package/src/app/fonts.css +8 -0
- package/src/app/globals.css +192 -0
- package/src/components/fonts/welcome.ttf +0 -0
- package/src/components/layout/header.tsx +50 -0
- package/src/components/layout/protected-layout.tsx +39 -0
- package/src/components/layout/sidebar-nav.tsx +150 -0
- package/src/components/logo.tsx +43 -0
- package/src/components/markdown-renderer.tsx +19 -0
- package/src/components/page-transition.tsx +45 -0
- package/src/components/password-gate.tsx +178 -0
- package/src/components/portal-page.tsx +471 -0
- package/src/components/profile-card.tsx +107 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +70 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/carousel.tsx +262 -0
- package/src/components/ui/chart.tsx +365 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/form.tsx +178 -0
- package/src/components/ui/index.ts +38 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/menubar.tsx +256 -0
- package/src/components/ui/popover.tsx +31 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/radio-group.tsx +44 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +790 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/slider.tsx +28 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +21 -0
- package/src/components/ui/toast.tsx +129 -0
- package/src/components/ui/toaster.tsx +35 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/components/upload-form.tsx +243 -0
- package/src/components/writeup-card.tsx +53 -0
- package/src/components/writeups-list.tsx +60 -0
- package/src/lib/actions.ts +21 -0
- package/src/lib/placeholder-images.json +88 -0
- package/src/lib/placeholder-images.ts +14 -0
- package/src/lib/types.ts +14 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/writeups.ts +76 -0
- 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
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -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
|
+
};
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
}
|