Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
.git
.gitignore
*.md
.vscode
.idea
3 changes: 3 additions & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store
15 changes: 15 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM node:20-alpine

WORKDIR /app

COPY package.json yarn.lock ./

RUN yarn install --frozen-lockfile

COPY . .

RUN yarn build

EXPOSE 3000

CMD ["yarn", "preview", "--host", "0.0.0.0"]
13 changes: 13 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cvtek - CV/Resume Builder</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23 changes: 23 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "cvtek",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"html2pdf.js": "^0.10.1",
"nunjucks": "^3.2.4",
"smol-toml": "^1.4.2",
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.0"
}
}
7 changes: 7 additions & 0 deletions app/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

54 changes: 54 additions & 0 deletions app/src/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<template>
<div class="flex flex-col h-screen bg-github-dark">
<AppHeader
v-model:output-format="outputFormat"
@download="downloadOutput"
/>

<div class="flex flex-1 overflow-hidden md:flex-row flex-col">
<EditorPane
v-model="tomlInput"
:is-example-loaded="isExampleLoaded"
@load-example="loadExample"
/>

<div class="w-px bg-github-border md:block hidden"></div>
<div class="h-px bg-github-border md:hidden"></div>

<PreviewPane
:output="renderedOutput"
:format="outputFormat"
:error="error"
/>
</div>
</div>
</template>

<script setup>
import { onMounted, computed } from 'vue'
import AppHeader from './components/AppHeader.vue'
import EditorPane from './components/EditorPane.vue'
import PreviewPane from './components/PreviewPane.vue'
import { useResume } from './composables/useResume'
import { exampleTOML } from './utils/utils'

const {
tomlInput,
outputFormat,
error,
renderedOutput,
downloadOutput
} = useResume()

const loadExample = () => {
tomlInput.value = exampleTOML
}

const isExampleLoaded = computed(() =>
tomlInput.value.trim() === exampleTOML.trim()
)

onMounted(() => {
loadExample()
})
</script>
41 changes: 41 additions & 0 deletions app/src/components/AppHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<header class="flex items-center justify-between px-8 py-4 bg-github-canvas border-b border-github-border md:flex-row flex-col gap-4 md:gap-0">
<div class="flex items-baseline gap-4">
<h1 class="text-2xl font-semibold text-github-accent">cvtek</h1>
<p class="text-sm text-github-muted">CV/Resume Builder</p>
</div>
<div class="flex gap-4 items-center md:w-auto w-full md:justify-end justify-between">
<select
v-model="format"
class="px-4 py-2 bg-github-canvas text-github-text border border-github-border rounded-md text-sm cursor-pointer outline-none hover:border-github-accent transition-colors"
>
<option value="html">HTML</option>
<option value="pdf">PDF</option>
<option value="markdown">Markdown</option>
<option value="latex">LaTeX</option>
</select>
<button
@click="$emit('download')"
class="px-4 py-2 bg-github-success text-white border-none rounded-md text-sm font-medium cursor-pointer hover:bg-green-600 transition-colors"
>
Download
</button>
</div>
</header>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
outputFormat: String
})

const emit = defineEmits(['update:outputFormat', 'download'])

const format = computed({
get: () => props.outputFormat,
set: (val) => emit('update:outputFormat', val)
})
</script>

31 changes: 31 additions & 0 deletions app/src/components/EditorPane.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<div class="flex-1 flex flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 bg-github-canvas border-b border-github-border">
<span class="text-sm font-semibold text-github-muted uppercase tracking-wider">TOML Input</span>
<button
v-if="!isExampleLoaded"
@click="$emit('loadExample')"
class="px-3 py-1.5 bg-github-canvas text-github-text border border-github-border rounded-md text-xs cursor-pointer hover:bg-github-border hover:border-github-accent transition-all"
>
Load Example
</button>
</div>
<textarea
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
class="flex-1 p-4 bg-github-dark text-github-text border-none font-mono text-sm leading-relaxed resize-none outline-none placeholder-gray-600"
placeholder="Enter your resume in TOML format..."
spellcheck="false"
></textarea>
</div>
</template>

<script setup>
defineProps({
modelValue: String,
isExampleLoaded: Boolean
})

defineEmits(['update:modelValue', 'loadExample'])
</script>

125 changes: 125 additions & 0 deletions app/src/components/PreviewPane.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<template>
<div class="flex-1 flex flex-col overflow-hidden">
<PreviewHeader
:format="format"
:output="output"
:error="error"
:copied="copied"
@copy="copyToClipboard"
/>
<PreviewContent
:format="format"
:output="output"
:error="error"
:pdfUrl="pdfUrl"
/>
</div>
</template>

<script setup>
import { toRefs, computed } from 'vue'
import { usePreview } from '../composables/usePreview'

const props = defineProps({
output: String,
format: String,
error: String
})

const { output, format, error } = toRefs(props)
const { pdfUrl, copied, copyToClipboard } = usePreview(output, format, error)

const PreviewHeader = {
props: ['format', 'output', 'error', 'copied'],
emits: ['copy'],
setup(props, { emit }) {
const showCopyButton = computed(() =>
(props.format === 'markdown' || props.format === 'latex') && props.output && !props.error
)

const copyLabel = computed(() =>
`Copy ${props.format === 'markdown' ? 'Markdown' : 'LaTeX'}`
)

return { showCopyButton, copyLabel, onCopy: () => emit('copy') }
},
template: `
<div class="flex items-center justify-between px-4 py-3 bg-github-canvas border-b border-github-border">
<span class="text-sm font-semibold text-github-muted uppercase tracking-wider">Preview</span>
<div class="flex items-center gap-2">
<button
v-if="showCopyButton"
@click="onCopy"
class="px-3 py-1.5 bg-github-canvas text-github-text border border-github-border rounded-md text-xs cursor-pointer hover:bg-github-border hover:border-github-accent transition-all flex items-center gap-1.5"
>
<svg v-if="!copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg v-else class="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ copied ? 'Copied!' : copyLabel }}
</button>
<span v-if="error" class="px-2 py-1 bg-github-danger text-white rounded text-xs font-semibold">Error</span>
</div>
</div>
`
}

const PreviewContent = {
props: ['format', 'output', 'error', 'pdfUrl'],
setup(props) {
const containerClass = computed(() =>
props.format === 'html' ? 'bg-white' : 'bg-gray-500'
)

return { containerClass }
},
template: `
<div class="flex-1 overflow-hidden" :class="containerClass">
<ErrorDisplay v-if="error" :error="error" />
<PDFPreview v-else-if="format === 'pdf'" :pdfUrl="pdfUrl" />
<HTMLPreview v-else-if="format === 'html'" :output="output" />
<TextPreview v-else :output="output" />
</div>
`,
components: {
ErrorDisplay: {
props: ['error'],
template: `
<div class="h-full flex items-center justify-center p-8">
<div class="max-w-3xl p-4 bg-github-danger text-white rounded-md font-mono text-sm">
{{ error }}
</div>
</div>
`
},
PDFPreview: {
props: ['pdfUrl'],
template: `
<div v-if="!pdfUrl" class="h-full flex items-center justify-center text-github-muted">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-github-accent mx-auto mb-4"></div>
<p>Generating PDF preview...</p>
</div>
</div>
<iframe v-else :src="pdfUrl" class="w-full h-full border-none" />
`
},
HTMLPreview: {
props: ['output'],
template: `
<div class="h-full overflow-auto bg-white" v-html="output"></div>
`
},
TextPreview: {
props: ['output'],
template: `
<div class="h-full overflow-auto p-8">
<pre class="max-w-4xl mx-auto bg-white text-gray-900 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words p-8 rounded shadow-lg">{{ output }}</pre>
</div>
`
}
}
}
</script>
64 changes: 64 additions & 0 deletions app/src/composables/usePreview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ref, watch, nextTick } from 'vue'
import html2pdf from 'html2pdf.js'

export const usePreview = (output, format, error) => {
const pdfUrl = ref('')
const copied = ref(false)

const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(output.value)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}

const generatePDF = async () => {
if (format.value !== 'pdf' || !output.value || error.value) {
if (pdfUrl.value) {
URL.revokeObjectURL(pdfUrl.value)
pdfUrl.value = ''
}
return
}

try {
const wrapper = document.createElement('div')
wrapper.innerHTML = output.value

const opt = {
margin: 0,
filename: 'resume.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, letterRendering: true },
jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' }
}

const pdf = await html2pdf().set(opt).from(wrapper).output('blob')

if (pdfUrl.value) {
URL.revokeObjectURL(pdfUrl.value)
}

pdfUrl.value = URL.createObjectURL(pdf)
} catch (err) {
console.error('PDF generation error:', err)
}
}

watch(() => [output.value, format.value, error.value], async () => {
await nextTick()
generatePDF()
}, { immediate: true })

return {
pdfUrl,
copied,
copyToClipboard
}
}

Loading