Skip to content

Commit d1eff17

Browse files
add blog core
1 parent 19208e2 commit d1eff17

File tree

18 files changed

+830
-17
lines changed

18 files changed

+830
-17
lines changed

app/components/blog/HeaderItem.vue

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<template>
2+
<component :is="headerTag" class="text-accent-primary font-bold" :class="headerClasses">
3+
{{ title }}
4+
</component>
5+
</template>
6+
7+
<script setup lang="ts">
8+
import { computed } from 'vue';
9+
10+
const props = withDefaults(defineProps<{
11+
title: string
12+
size: '1' | '2' | '3' | '4' | '5' | '6'
13+
}>(), {
14+
size: '1'
15+
})
16+
17+
const headerTag = computed(() => `h${props.size}`)
18+
19+
const headerClasses = computed(() => {
20+
switch (props.size) {
21+
default:
22+
return 'text-2xl'
23+
case '2':
24+
return 'text-xl'
25+
case '3':
26+
return 'text-lg'
27+
case '4':
28+
return 'text-base'
29+
case '5':
30+
return 'text-sm'
31+
case '6':
32+
return 'text-xs'
33+
}
34+
})
35+
</script>

app/components/blog/ImageItem.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<template>
2+
<div class="mb-4 flex justify-center">
3+
<img
4+
:src="imageUrl"
5+
:alt="imageAlt || ''"
6+
class="w-full rounded-lg"
7+
/>
8+
</div>
9+
</template>
10+
11+
<script setup lang="ts">
12+
const props = defineProps<{
13+
imageUrl: string
14+
imageAlt?: string
15+
}>()
16+
</script>
17+

app/components/blog/TextItem.vue

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<template>
2+
<p class="mb-4 leading-relaxed text-accent-primary">
3+
<span v-for="(part, index) in parsedParts" :key="index">
4+
<template v-if="part.type === 'text'">
5+
{{ part.content }}
6+
</template>
7+
<template v-else-if="part.type === 'bold'">
8+
<strong class="font-bold">{{ part.content }}</strong>
9+
</template>
10+
<template v-else-if="part.type === 'italic'">
11+
<em class="italic">{{ part.content }}</em>
12+
</template>
13+
<template v-else-if="part.type === 'boldItalic'">
14+
<strong class="font-bold"><em class="italic">{{ part.content }}</em></strong>
15+
</template>
16+
<template v-else-if="part.type === 'code'">
17+
<code class="px-1.5 py-0.5 bg-gray-200 dark:bg-gray-800 rounded text-sm font-mono text-accent-primary">
18+
{{ part.content }}
19+
</code>
20+
</template>
21+
<template v-else-if="part.type === 'link'">
22+
<a :href="part.url" class="text-accent-primary underline hover:text-accent-primary/80" target="_blank" rel="noopener noreferrer">
23+
{{ part.text }}
24+
</a>
25+
</template>
26+
<template v-else-if="part.type === 'strikethrough'">
27+
<del class="line-through">{{ part.content }}</del>
28+
</template>
29+
<template v-else-if="part.type === 'linebreak'">
30+
<br />
31+
</template>
32+
</span>
33+
</p>
34+
</template>
35+
36+
<script setup lang="ts">
37+
import { computed } from 'vue'
38+
39+
const props = defineProps<{
40+
text: string
41+
}>()
42+
43+
interface TextPart {
44+
type: 'text' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'link' | 'strikethrough' | 'linebreak'
45+
content?: string
46+
text?: string
47+
url?: string
48+
}
49+
50+
const parsedParts = computed((): TextPart[] => {
51+
const parts: TextPart[] = []
52+
const text = props.text
53+
54+
if (!text) {
55+
return parts
56+
}
57+
58+
// Helper function to add text part
59+
const addText = (content: string) => {
60+
if (content) {
61+
parts.push({ type: 'text', content })
62+
}
63+
}
64+
65+
// Helper to find all pattern matches with their positions
66+
const findMatches = (regex: RegExp, type: string) => {
67+
const matches: Array<{ type: string; start: number; end: number; groups: RegExpMatchArray }> = []
68+
let match: RegExpExecArray | null
69+
70+
// Reset regex lastIndex
71+
regex.lastIndex = 0
72+
73+
while ((match = regex.exec(text)) !== null) {
74+
matches.push({
75+
type,
76+
start: match.index,
77+
end: match.index + match[0].length,
78+
groups: match
79+
})
80+
81+
// Prevent infinite loop on zero-length matches
82+
if (match[0].length === 0) {
83+
regex.lastIndex++
84+
}
85+
}
86+
87+
return matches
88+
}
89+
90+
// Find all matches for each pattern type
91+
// Order matters: process more specific patterns first (e.g., boldItalic before bold/italic)
92+
const allMatches: Array<{ type: string; start: number; end: number; groups: RegExpMatchArray }> = []
93+
94+
// Bold+Italic (***text*** or ___text___)
95+
allMatches.push(...findMatches(/(\*\*\*|___)(.+?)\1/g, 'boldItalic'))
96+
97+
// Code (inline code - process first to avoid parsing inside code blocks)
98+
allMatches.push(...findMatches(/`([^`\n]+)`/g, 'code'))
99+
100+
// Links ([text](url))
101+
allMatches.push(...findMatches(/\[([^\]]+)\]\(([^)]+)\)/g, 'link'))
102+
103+
// Strikethrough (~~text~~)
104+
allMatches.push(...findMatches(/~~(.+?)~~/g, 'strikethrough'))
105+
106+
// Bold (**text** or __text__)
107+
// Note: We check these are not part of boldItalic by processing boldItalic first and filtering overlaps
108+
allMatches.push(...findMatches(/(\*\*|__)([^*_\n]+?)\1/g, 'bold'))
109+
110+
// Italic (*text* or _text_)
111+
// Must have word boundary to avoid matching parts of bold or code
112+
allMatches.push(...findMatches(/(?<![*_])(?<!\w)(\*|_)([^*_\n]+?)\1(?![*_])(?!\w)/g, 'italic'))
113+
114+
// Line breaks (two spaces + newline or double newline)
115+
allMatches.push(...findMatches(/ \n|\n\n/g, 'linebreak'))
116+
117+
// Sort matches by position
118+
allMatches.sort((a, b) => a.start - b.start)
119+
120+
// Remove overlapping matches (keep the longest match when overlaps occur)
121+
const filteredMatches: Array<{ type: string; start: number; end: number; groups: RegExpMatchArray }> = []
122+
for (let i = 0; i < allMatches.length; i++) {
123+
const current = allMatches[i] as { type: string; start: number; end: number; groups: RegExpMatchArray }
124+
let shouldAdd = true
125+
126+
// Check if current overlaps with any already filtered match
127+
for (let j = filteredMatches.length - 1; j >= 0; j--) {
128+
const existing = filteredMatches[j] as { type: string; start: number; end: number; groups: RegExpMatchArray }
129+
const overlaps = !(current.end <= existing.start || current.start >= existing.end)
130+
131+
if (overlaps) {
132+
// If current is longer, replace the existing match
133+
const currentLength = current.end - current.start
134+
const existingLength = existing.end - existing.start
135+
136+
if (currentLength > existingLength) {
137+
filteredMatches.splice(j, 1)
138+
} else {
139+
// Current is shorter or equal, don't add it
140+
shouldAdd = false
141+
break
142+
}
143+
}
144+
}
145+
146+
if (shouldAdd) {
147+
filteredMatches.push(current)
148+
}
149+
}
150+
151+
// Re-sort after filtering (positions may have changed)
152+
filteredMatches.sort((a, b) => a.start - b.start)
153+
154+
// Build parts array
155+
let lastPos = 0
156+
157+
for (const match of filteredMatches) {
158+
// Add text before this match
159+
if (match.start > lastPos) {
160+
addText(text.substring(lastPos, match.start))
161+
}
162+
163+
// Add the matched part
164+
switch (match.type) {
165+
case 'boldItalic':
166+
parts.push({ type: 'boldItalic', content: match.groups[2] })
167+
break
168+
case 'bold':
169+
parts.push({ type: 'bold', content: match.groups[2] })
170+
break
171+
case 'italic':
172+
parts.push({ type: 'italic', content: match.groups[2] })
173+
break
174+
case 'code':
175+
parts.push({ type: 'code', content: match.groups[1] })
176+
break
177+
case 'link':
178+
parts.push({ type: 'link', text: match.groups[1], url: match.groups[2] })
179+
break
180+
case 'strikethrough':
181+
parts.push({ type: 'strikethrough', content: match.groups[1] })
182+
break
183+
case 'linebreak':
184+
parts.push({ type: 'linebreak' })
185+
break
186+
}
187+
188+
lastPos = match.end
189+
}
190+
191+
// Add remaining text
192+
if (lastPos < text.length) {
193+
addText(text.substring(lastPos))
194+
}
195+
196+
// If no parts were created, add the whole text as plain text
197+
if (parts.length === 0) {
198+
parts.push({ type: 'text', content: text })
199+
}
200+
201+
return parts
202+
})
203+
</script>
204+

app/layouts/default.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
<template>
2-
<div class="bg-background-primary overflow-x-hidden max-w-full">
2+
<div class="bg-background-primary overflow-x-hidden max-w-full min-h-screen">
33
<Header />
4-
<slot />
4+
<div class="flex flex-col min-h-screen">
5+
<main class="flex-grow">
6+
<slot />
7+
</main>
8+
</div>
59
<Footer />
610
</div>
711
</template>

app/pages/admin/blog/[slug].vue

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<template>
2+
<div class="container mx-auto px-4 py-8 max-w-5xl">
3+
<div class="flex items-center justify-between mb-4">
4+
<h1 class="text-2xl font-bold text-accent-primary">Edit: {{ slug }}</h1>
5+
<MainButton
6+
label="Back to list"
7+
buttonStyle="secondary"
8+
size="M"
9+
link="/admin/blog"
10+
icon="mdi:arrow-left"
11+
/>
12+
</div>
13+
14+
<div class="bg-fill-secondary border border-separator-primary rounded-xl p-4">
15+
<div class="grid grid-cols-1 gap-3">
16+
<input v-model="title" type="text" placeholder="title"
17+
class="w-full p-2 rounded bg-transparent border border-separator-primary text-label-primary" />
18+
<select v-model="status"
19+
class="w-full p-2 rounded bg-transparent border border-separator-primary text-label-primary">
20+
<option value="draft">draft</option>
21+
<option value="published">published</option>
22+
</select>
23+
<textarea v-model="markdown" rows="24" placeholder="markdown"
24+
class="w-full p-3 rounded bg-transparent border border-separator-primary text-label-primary font-mono"></textarea>
25+
<div class="flex items-center gap-2">
26+
<button class="bg-accent-primary text-white px-4 py-2 rounded hover:opacity-90" @click="save"
27+
:disabled="saving">{{ saving ? 'Saving…' : 'Save' }}</button>
28+
<span v-if="saveMsg" class="text-green-600">{{ saveMsg }}</span>
29+
<span v-if="errorMsg" class="text-red-500">{{ errorMsg }}</span>
30+
</div>
31+
</div>
32+
</div>
33+
</div>
34+
</template>
35+
36+
<script setup lang="ts">
37+
import { definePageMeta, useFetch, useRoute } from '#imports'
38+
import { ref } from 'vue'
39+
40+
definePageMeta({ layout: 'default', middleware: ['auth'] })
41+
42+
const route = useRoute()
43+
const slug = route.params.slug as string
44+
45+
const title = ref('')
46+
const status = ref<'draft' | 'published'>('draft')
47+
const markdown = ref('')
48+
const saving = ref(false)
49+
const saveMsg = ref('')
50+
const errorMsg = ref('')
51+
52+
async function load() {
53+
const { data, error } = await useFetch(`/api/blog/${encodeURIComponent(slug)}`)
54+
if (!error.value && data.value) {
55+
// @ts-ignore
56+
title.value = data.value.title || ''
57+
// @ts-ignore
58+
status.value = data.value.status || 'draft'
59+
// @ts-ignore
60+
markdown.value = data.value.rawMarkdown || ''
61+
}
62+
}
63+
64+
async function save() {
65+
saving.value = true
66+
saveMsg.value = ''
67+
errorMsg.value = ''
68+
const { error } = await useFetch(`/api/blog/${encodeURIComponent(slug)}`, {
69+
method: 'PUT',
70+
body: { title: title.value, status: status.value, markdown: markdown.value },
71+
})
72+
saving.value = false
73+
if (error.value) {
74+
// @ts-ignore
75+
errorMsg.value = error.value.statusMessage || 'Failed to save'
76+
} else {
77+
saveMsg.value = 'Saved'
78+
}
79+
}
80+
81+
await load()
82+
</script>

0 commit comments

Comments
 (0)