This commit is contained in:
2026-07-27 00:56:18 +03:30
commit 1c75afe0fd
244 changed files with 27710 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tsConfigPath": ".nuxt/tsconfig.json",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/assets/css/tailwind.css",
"baseColor": "gray",
"cssVariables": true
},
"framework": "nuxt",
"aliases": {
"components": "~/components",
"utils": "~/utils/shadcn"
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { NuxtI18nOptions } from "@nuxtjs/i18n";
export const i18n: NuxtI18nOptions = {
defaultLocale: "en",
strategy: "prefix_and_default",
locales: [
{
code: "en",
name: "English",
icon: "i-icon-park-outline:english",
file: "en.yaml"
},
{
code: "sp",
name: "Spanish",
icon: "i-material-symbols:language-spanish",
file: "sp.yaml"
},
{
code: "zh-cn",
name: "简体中文",
icon: "i-icon-park-outline:chinese",
file: "zh-cn.yaml"
}
],
langDir: "i18n",
compilation: {
strictMessage: false
}
};
+56
View File
@@ -0,0 +1,56 @@
import type { ModuleOptions } from "@vite-pwa/nuxt";
const scope = "/";
export const pwa: ModuleOptions = {
registerType: "autoUpdate",
scope,
base: scope,
manifest: {
id: scope,
scope,
name: "Oh My CV!",
short_name: "Oh My CV!",
icons: [
{
src: "/pwa-192x192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "/pwa-512x512.png",
sizes: "512x512",
type: "image/png"
},
{
src: "/favicon.svg",
sizes: "512x512",
type: "image/svg",
purpose: "any maskable"
}
]
},
workbox: {
globPatterns: ["**/*.{js,css,html,otf,ttf,woff2,png,svg}"],
maximumFileSizeToCacheInBytes: 16000000,
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts.googleapis.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // <== 365 days
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
registerWebManifestInRouteRules: true,
writePlugin: true
};
+4
View File
@@ -0,0 +1,4 @@
export default defineI18nConfig(() => ({
legacy: false,
locale: "en"
}));
+68
View File
@@ -0,0 +1,68 @@
import { pwa } from "./configs/pwa";
import { i18n } from "./configs/i18n";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
srcDir: "src/",
modules: [
"@vueuse/nuxt",
"@unocss/nuxt",
"@pinia/nuxt",
"@nuxtjs/i18n",
"@nuxtjs/color-mode",
"@vite-pwa/nuxt",
"nuxt-simple-sitemap",
"radix-vue/nuxt",
"shadcn-nuxt"
],
css: [
"@unocss/reset/tailwind.css",
"katex/dist/katex.min.css",
"~/assets/css/index.css"
],
i18n,
shadcn: {
prefix: "Ui",
componentDir: "./src/components/ui"
},
runtimeConfig: {
public: {
googleFontsKey: "",
signalApiBase: ""
}
},
colorMode: {
classSuffix: ""
},
app: {
baseURL: "/cv/",
head: {
viewport: "width=device-width,initial-scale=1",
link: [
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
{ rel: "mask-icon", href: "/safari-pinned-tab.svg", color: "#222" }
],
meta: [
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ name: "application-name", content: "Oh My CV!" },
{ name: "apple-mobile-web-app-title", content: "Oh My CV!" },
{ name: "msapplication-TileColor", content: "#fff" },
{ property: "og:url", content: "https://ohmycv.app" },
{ property: "og:type", content: "website" }
]
}
},
site: {
url: "https://ohmycv.app"
},
pwa
});
+55
View File
@@ -0,0 +1,55 @@
{
"name": "site",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt generate",
"dev": "nuxt dev",
"serve": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@iconify/json": "^2.2.225",
"@nuxt/devtools": "latest",
"@nuxtjs/color-mode": "^3.4.2",
"@nuxtjs/i18n": "^8.3.1",
"@ohmycv/case-police": "workspace:*",
"@ohmycv/dynamic-css": "workspace:*",
"@ohmycv/front-matter": "workspace:*",
"@ohmycv/google-fonts-loader": "workspace:*",
"@ohmycv/markdown-it-cross-ref": "workspace:*",
"@ohmycv/markdown-it-katex": "workspace:*",
"@ohmycv/markdown-it-latex-cmds": "workspace:*",
"@ohmycv/vue-shortcuts": "workspace:*",
"@ohmycv/vue-smart-pages": "workspace:*",
"@ohmycv/vue-zoom": "workspace:*",
"@pinia/nuxt": "^0.5.1",
"@renovamen/utils": "workspace:*",
"@types/markdown-it-link-attributes": "^3.0.5",
"@unocss/nuxt": "^0.61.2",
"@vite-pwa/nuxt": "^0.9.0",
"@vueuse/core": "^10.11.0",
"@vueuse/nuxt": "^10.11.0",
"@zag-js/color-picker": "^0.60.0",
"@zag-js/combobox": "^0.60.0",
"@zag-js/file-upload": "^0.60.0",
"@zag-js/vue": "^0.60.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"katex": "^0.16.11",
"localforage": "^1.10.0",
"lucide-vue-next": "^0.400.0",
"markdown-it-deflist": "^3.0.0",
"markdown-it-link-attributes": "^4.0.1",
"monaco-editor": "^0.50.0",
"nuxt": "^3.12.3",
"nuxt-simple-sitemap": "^4.4.1",
"pinia": "^2.1.7",
"radix-vue": "^1.9.0",
"shadcn-nuxt": "^0.10.4",
"tailwind-merge": "^2.3.0",
"unocss-preset-animations": "^1.0.2",
"unocss-preset-shadcn": "^0.3.1",
"vue-sonner": "^1.1.3"
}
}
+43
View File
@@ -0,0 +1,43 @@
<template>
<div class="font-ui">
<VitePwaManifest />
<NuxtPage />
<UiToaster close-button />
</div>
</template>
<script setup lang="ts">
const { t, locale } = useI18n();
const colorMode = useColorMode();
const preferredDark = usePreferredDark();
useHead({
title: t("head.title"),
meta: [
{ name: "keywords", content: t("head.keywords") },
{ name: "description", content: t("head.desc") },
{ property: "og:title", content: t("head.title") },
{ property: "og:description", content: t("head.desc") },
{ property: "og:locale", content: locale },
{
name: "theme-color",
content: () => (colorMode.value === "dark" ? "#30343A" : "#F3F4F6")
}
],
link: [
{
rel: "icon",
type: "image/svg+xml",
href: () => (preferredDark.value ? "/favicon-dark.svg" : "/favicon.svg")
}
],
script: [
{
src: "https://code.iconify.design/2/2.2.1/iconify.min.js",
type: "module",
tagPosition: "bodyClose"
}
]
});
</script>
+4
View File
@@ -0,0 +1,4 @@
#dashboard-page {
height: 100vh;
height: 100dvh;
}
+62
View File
@@ -0,0 +1,62 @@
#editor-page {
height: 100vh;
height: 100dvh;
}
#resume-preview {
@apply flex flex-col gap-1.5;
}
.pane-container {
@apply h-full rounded-xl;
}
/* Print mode */
@media print {
@page {
margin: 0;
}
#editor-page {
height: auto;
}
.workspace,
[data-panel-group-id="splitter-editor"] {
display: block !important;
padding: 0 !important;
}
header,
.toaster.group,
#zoom-bar,
#code-pane,
#code-preview-handle,
#tools-pane,
#nuxt-devtools-container,
[data-radix-popper-content-wrapper] {
display: none !important;
}
.pane-container {
border: none;
border-radius: 0;
}
.vue-zoom {
transform: scale(1) !important;
margin: 0 !important;
}
#resume-preview {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
gap: 0;
}
#resume-preview [data-scope="vue-smart-pages"][data-part="page"] {
break-before: page;
height: 100vh !important;
}
}
+199
View File
@@ -0,0 +1,199 @@
/* English */
@font-face {
font-family: "CMU Sans Serif";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/cmu-sans-serif/regular.ttf") format("opentype");
}
@font-face {
font-family: "CMU Sans Serif";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/cmu-sans-serif/bold.ttf") format("opentype");
}
@font-face {
font-family: "CMU Sans Serif";
font-style: italic;
font-weight: normal;
src: url("~/assets/fonts/cmu-sans-serif/italic.ttf") format("opentype");
}
@font-face {
font-family: "CMU Sans Serif";
font-style: italic;
font-weight: bold;
src: url("~/assets/fonts/cmu-sans-serif/bolditalic.ttf") format("opentype");
}
@font-face {
font-family: "Times Newer Roman";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/times-newer-roman/regular.otf") format("opentype");
}
@font-face {
font-family: "Times Newer Roman";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/times-newer-roman/bold.otf") format("opentype");
}
@font-face {
font-family: "Times Newer Roman";
font-style: italic;
font-weight: normal;
src: url("~/assets/fonts/times-newer-roman/italic.otf") format("opentype");
}
@font-face {
font-family: "Times Newer Roman";
font-style: italic;
font-weight: bold;
src: url("~/assets/fonts/times-newer-roman/bolditalic.otf") format("opentype");
}
@font-face {
font-family: "Minion Pro";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/minion-pro/regular.otf") format("opentype");
}
@font-face {
font-family: "Minion Pro";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/minion-pro/bold.otf") format("opentype");
}
@font-face {
font-family: "Minion Pro";
font-style: italic;
font-weight: normal;
src: url("~/assets/fonts/minion-pro/italic.otf") format("opentype");
}
@font-face {
font-family: "Minion Pro";
font-style: italic;
font-weight: bold;
src: url("~/assets/fonts/minion-pro/bolditalic.otf") format("opentype");
}
@font-face {
font-family: "Tex Gyre Pagella";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/tex-gyre-pagella/regular.otf") format("opentype");
}
@font-face {
font-family: "Tex Gyre Pagella";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/tex-gyre-pagella/bold.otf") format("opentype");
}
@font-face {
font-family: "Tex Gyre Pagella";
font-style: italic;
font-weight: normal;
src: url("~/assets/fonts/tex-gyre-pagella/italic.otf") format("opentype");
}
@font-face {
font-family: "Tex Gyre Pagella";
font-style: italic;
font-weight: bold;
src: url("~/assets/fonts/tex-gyre-pagella/bolditalic.otf") format("opentype");
}
@font-face {
font-family: "Adobe Garamond Pro";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/adobe-garamond-pro/regular.ttf") format("opentype");
}
@font-face {
font-family: "Adobe Garamond Pro";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/adobe-garamond-pro/bold.ttf") format("opentype");
}
@font-face {
font-family: "Adobe Garamond Pro";
font-style: italic;
font-weight: normal;
src: url("~/assets/fonts/adobe-garamond-pro/italic.ttf") format("opentype");
}
@font-face {
font-family: "Adobe Garamond Pro";
font-style: italic;
font-weight: bold;
src: url("~/assets/fonts/adobe-garamond-pro/bolditalic.ttf") format("opentype");
}
@font-face {
font-family: "Euclid";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/euclid/regular.ttf") format("opentype");
}
@font-face {
font-family: "Euclid";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/euclid/bold.ttf") format("opentype");
}
@font-face {
font-family: "Euclid";
font-style: italic;
font-weight: normal;
src: url("~/assets/fonts/euclid/italic.ttf") format("opentype");
}
@font-face {
font-family: "Euclid";
font-style: italic;
font-weight: bold;
src: url("~/assets/fonts/euclid/bolditalic.ttf") format("opentype");
}
/* Chinese */
@font-face {
font-family: "LXGW WenKai";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/lxgw-wen-kai/regular.ttf") format("opentype");
}
@font-face {
font-family: "LXGW WenKai";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/lxgw-wen-kai/bold.ttf") format("opentype");
}
@font-face {
font-family: "HKST";
font-style: normal;
font-weight: normal;
src: url("~/assets/fonts/hkst/w7.ttf") format("opentype");
}
@font-face {
font-family: "HKST";
font-style: normal;
font-weight: bold;
src: url("~/assets/fonts/hkst/w9.ttf") format("opentype");
}
+37
View File
@@ -0,0 +1,37 @@
@import "./fonts.css";
@import "./editor.css";
@import "./dashboard.css";
/* Variables have been injected via `unocss-preset-shadcn` (https://github.com/hyoban/unocss-preset-shadcn) */
* {
border-color: hsl(var(--border));
}
body {
color: hsl(var(--foreground));
background: hsl(var(--accent));
}
/* `workspace` element should fill the space not occupied by the header */
header {
height: 48px;
}
.workspace {
height: calc(100% - 48px);
}
/* For icons used in resumes */
svg.iconify {
display: inline-block;
}
/* Utility for hiding scrollbar */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar { /* Safari and Chrome */
display: none;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
<template>
<div class="flex gap-2">
<UiButton @click="exportToJSON">
<span i-ic:baseline-save-as size-4 mr-1 />
{{ $t("dashboard.saveas") }}
</UiButton>
<UiButton
class="bg-neutral-800 hover:(bg-neutral-800/90 ring-neutral-800/40) dark:(bg-secondary hover:bg-background hover:ring-secondary/40)"
@click="open"
>
<span i-ic:round-upload-file size-4 mr-1 />
{{ $t("dashboard.import") }}
</UiButton>
</div>
</template>
<script lang="ts" setup>
import { useShortcuts } from "@ohmycv/vue-shortcuts";
import { useFileDialog, readFile } from "@renovamen/utils";
const emits = defineEmits<{
(e: "update"): void;
}>();
const { open, onChange } = useFileDialog(".json");
onChange(async (file) => {
const content = await readFile(file);
await storageService.importFromJson(content);
emits("update");
});
const exportToJSON = () => storageService.exportToJSON();
useShortcuts("shift+ctrl+s", exportToJSON);
</script>
@@ -0,0 +1,23 @@
<template>
<div class="w-56 h-80">
<button
class="resume-card group w-[210px] h-[299px] flex-center bg-secondary hover:bg-background ring-when-focus"
:aria-label="$t('dashboard.new')"
@click="newAndSwitch"
>
<span i-ic:round-plus text="5xl muted-foreground group-hover:primary" />
</button>
</div>
</template>
<script lang="ts" setup>
const router = useRouter();
const localePath = useLocalePath();
const newAndSwitch = async () => {
const data = await storageService.createResume();
if (!data) return;
else router.push(localePath(`/editor/${data.id}`));
};
</script>
@@ -0,0 +1,48 @@
<template>
<div class="text-center">
<SharedUiEditable
class="w-53 mx-auto"
:default-value="resume.name"
submit-mode="enter"
auto-resize
@submit="(text) => rename(text)"
/>
<div text="xs muted-foreground" mt-1.5>
{{ $t("dashboard.updated") }}{{ formatDate(resume.updated_at) }}
</div>
<div text="xs muted-foreground" mt-0.5>
{{ $t("dashboard.created") }}{{ formatDate(resume.created_at) }}
</div>
</div>
</template>
<script lang="ts" setup>
import { isInteger } from "@renovamen/utils";
import type { DbResume } from "~/utils/storage";
const props = defineProps<{
resume: DbResume;
}>();
const rename = async (text?: string) => {
if (!text) return;
await storageService.updateResume(
{
id: props.resume.id,
name: text
},
false
);
};
const formatDate = (date?: string) =>
date &&
isInteger(date, { allowString: true }) &&
new Date(parseInt(date))
.toISOString()
.substring(0, 19)
.replace("T", " ")
.replaceAll("-", "/");
</script>
@@ -0,0 +1,75 @@
<template>
<div w-56>
<div h-80>
<div class="resume-card group/card size-fit">
<nuxt-link
:to="$nuxt.$localePath(`/editor/${props.resume.id}`)"
class="block border overflow-hidden rounded-md ring-when-focus peer"
:style="{
width: `${size.w}px`,
height: `${size.h}px`
}"
>
<SharedResumeRender
:id="resume.id"
ref="renderRef"
:markdown="resume.markdown"
:styles="resume.styles"
class="origin-top-left"
:style="{
transform: `scale(${1 / PAPER.MM_TO_PX})`
}"
/>
</nuxt-link>
<DashboardResumeOptions
class="opacity-0 group-hover/card:opacity-100 peer-focus-within:opacity-100 focus-within:opacity-100"
pos="absolute right-3 top-3"
:resume="resume"
@update="emit('update')"
/>
</div>
</div>
<DashboardResumeInfo :resume="resume" />
</div>
</template>
<script lang="ts" setup>
import { delay } from "@renovamen/utils";
import type { DbResume } from "~/utils/storage";
import { SharedResumeRender } from "#components";
const props = defineProps<{
resume: DbResume;
}>();
const emit = defineEmits<{
(e: "update"): void;
}>();
const { PAPER } = useConstant();
const size = PAPER.SIZES[props.resume.styles.paper];
const renderRef = ref<InstanceType<typeof SharedResumeRender>>();
onMounted(async () => {
// set styles that are defined via CSS editor
dynamicCssService.injectCssEditor(props.resume.css, props.resume.id);
// load Google fonts
await googleFontsService.resolve(props.resume.styles.fontEN);
await googleFontsService.resolve(props.resume.styles.fontCJK);
// set styles that are defined via toolbar
dynamicCssService.injectToolbar(props.resume.styles, props.resume.id);
// force update resume render
await delay(100);
renderRef.value?.render();
});
</script>
<style scoped>
/* Only need to show the first page of the resume card */
:deep(.resume-render) > *:not(:first-child) {
@apply hidden;
}
</style>
@@ -0,0 +1,50 @@
<template>
<div flex="~ col gap-y-2" items-end>
<UiButton
size="round"
class="group/btn gap-x-1 transition-all bg-gray-500/90 hover:(bg-gray-500 ring-none w-auto px-2) focus-visible:(w-auto px-2)"
@click="duplicate"
:aria-label="$t('dashboard.duplicate')"
>
<span i-ion:duplicate />
<span class="hidden text-xs group-hover/btn:inline group-focus-visible/btn:inline">
{{ $t("dashboard.duplicate") }}
</span>
</UiButton>
<UiButton
size="round"
variant="destructive"
class="group/btn gap-x-1 transition-all bg-destructive/90 hover:(bg-destructive w-auto px-2) focus-visible:(w-auto px-2)"
@click="remove"
:aria-label="$t('dashboard.delete')"
>
<span i-material-symbols:delete-outline-rounded />
<span class="hidden text-xs group-hover/btn:inline group-focus-visible/btn:inline">
{{ $t("dashboard.delete") }}
</span>
</UiButton>
</div>
</template>
<script lang="ts" setup>
import type { DbResume } from "~/utils/storage";
const props = defineProps<{
resume: DbResume;
}>();
const emit = defineEmits<{
(e: "update"): void;
}>();
const duplicate = async () => {
await storageService.duplicateResume(props.resume.id);
emit("update");
};
const remove = async () => {
await storageService.deleteResume(props.resume.id);
emit("update");
};
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<TabsRoot
class="pane-container overflow-hidden bg-background"
flex="~ col"
default-value="markdown"
@update:model-value="(payload) => activateModel(payload)"
>
<TabsList
class="relative shrink-0 hstack w-full text-sm h-9 border-b px-4"
md="text-base h-10"
>
<TabsIndicator
class="absolute left-0 bottom-0 h-0.5 bg-primary rounded-full w-[--radix-tabs-indicator-size] translate-x-[--radix-tabs-indicator-position] transition-[width,transform] duration-300"
/>
<TabsTrigger value="markdown" p="x-2" :disabled="loading">Markdown</TabsTrigger>
<TabsTrigger value="css" p="x-4" :disabled="loading">CSS</TabsTrigger>
</TabsList>
<div ref="editor" flex-1 />
</TabsRoot>
</template>
<script lang="ts" setup>
const editor = ref<HTMLDivElement>();
const { setup, activateModel, dispose, loading } = useMonaco();
onMounted(async () => {
await setup(editor.value);
activateModel("markdown");
});
onBeforeUnmount(dispose);
</script>
+62
View File
@@ -0,0 +1,62 @@
<template>
<div
class="pane-container overflow-scroll hide-scrollbar bg-secondary"
border="4 secondary"
>
<VueZoom ref="zoom" :scale="scale">
<SharedResumeRender
id="preview"
:markdown="data.markdown"
:css="data.css"
:styles="styles"
/>
</VueZoom>
<div
id="zoom-bar"
class="hstack fixed bottom-4 ml-2 shadow-c rounded-full overflow-hidden text-primary-foreground bg-blue-500"
lg="bottom-auto top-15 opacity-0 hover:opacity-100 focus-within:opacity-100"
>
<button @click="scale *= 1.1" aria-label="Zoom in">
<span i-lucide:zoom-in />
</button>
<button @click="scale /= 1.1" aria-label="Zoom out">
<span i-lucide:zoom-out />
</button>
<button @click="fitWidth" aria-label="Fit width">
<span i-fluent:arrow-autofit-width-20-filled />
</button>
<button @click="fitHeight" aria-label="Fit height">
<span i-fluent:arrow-autofit-height-20-filled />
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import VueZoom from "@ohmycv/vue-zoom";
const scale = ref(1);
const zoom = ref<InstanceType<typeof VueZoom>>();
const { width, height } = useElementSize(zoom);
const { styles } = useStyleStore();
const { data } = useDataStore();
const { PAPER } = useConstant();
const fitWidth = () => {
scale.value = width.value / PAPER.sizeToPx(styles.paper, "w");
};
const fitHeight = () => {
scale.value = height.value / PAPER.sizeToPx(styles.paper, "h");
};
watch(width, fitWidth);
</script>
<style scoped>
#zoom-bar button {
@apply flex-center size-10 text-lg hover:bg-blue-600 focus-visible:bg-blue-600;
}
</style>
@@ -0,0 +1,21 @@
<template>
<div :class="cn('px-4 py-6 text-sm', props.class)">
<div hstack gap-x-2 mb-4 text-base>
<span v-if="icon" :class="icon" />
{{ text }}
</div>
<slot />
</div>
</template>
<script lang="ts" setup>
import { cn } from "~/utils/shadcn";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
text: string;
icon?: HTMLAttributes["class"];
class?: HTMLAttributes["class"];
}>();
</script>
@@ -0,0 +1,42 @@
<template>
<EditorToolbarBox
:text="$t('toolbar.correct_case.title')"
icon="i-icon-park-outline:check-correct"
>
<UiAlert>
<UiAlertTitle>{{ $t("toolbar.correct_case.example.title") }}</UiAlertTitle>
<UiAlertDescription>
{{ $t("toolbar.correct_case.example.content") }}
</UiAlertDescription>
</UiAlert>
<UiAlert variant="info" class="mt-3">
<UiAlertTitle>{{ $t("toolbar.correct_case.note.title") }}</UiAlertTitle>
<UiAlertDescription>
{{ $t("toolbar.correct_case.note.content") }}
</UiAlertDescription>
</UiAlert>
<div class="mt-3 text-right">
<UiButton @click="correct" size="sm">
<span i-carbon:rocket mr-1 />
{{ $t("toolbar.correct_case.btn") }}
</UiButton>
</div>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
import { replace } from "@ohmycv/case-police";
const { data, setAndSyncToMonaco } = useDataStore();
const toast = useToast();
const correct = async () => {
const md = data.markdown;
const result = replace(md);
setAndSyncToMonaco("markdown", result?.code ?? md);
toast.correct(result?.changed);
};
</script>
@@ -0,0 +1,94 @@
<template>
<EditorToolbarBox
:text="$t('toolbar.font_family.title')"
icon="i-material-symbols:font-download-outline"
>
<div class="w-full hstack gap-x-2 mb-2">
<SharedUiCombobox
v-if="loaded"
id="font-cjk"
class="flex-1"
:items="localCjk.concat(gfCjk)"
:default-value="styles.fontCJK.fontFamily || styles.fontCJK.name"
/>
<UiSkeleton v-else class="flex-1 h-9" />
<span w-13>{{ $t("toolbar.font_family.cjk") }}</span>
</div>
<div class="hstack gap-x-2 w-full">
<SharedUiCombobox
v-if="loaded"
id="font-en"
class="flex-1"
:items="localEn.concat(gfEn)"
:default-value="styles.fontEN.fontFamily || styles.fontEN.name"
/>
<UiSkeleton v-else class="flex-1 h-9" />
<span w-13>{{ $t("toolbar.font_family.en") }}</span>
</div>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
import type { ComboboxItem } from "~/components/shared/ui/Combobox.vue";
const { styles, setStyle } = useStyleStore();
const { FONT } = useConstant();
const localEn = FONT.LOCAL.EN.map<ComboboxItem>((item) => {
const family =
FONT.LOCAL.EN.find((font) => font.name === item.name)?.fontFamily || item.name;
return {
label: item.name,
value: family,
onSelect: () => setStyle("fontEN", { name: item.name, fontFamily: family })
};
});
const localCjk = FONT.LOCAL.CJK.map<ComboboxItem>((item) => {
const family =
FONT.LOCAL.CJK.find((font) => font.name === item.name)?.fontFamily || item.name;
return {
label: item.name,
value: family,
onSelect: () => setStyle("fontCJK", { name: item.name, fontFamily: family })
};
});
// Setup Google Fonts
const loaded = ref(false);
const gfEn = ref<ComboboxItem[]>([]);
const gfCjk = ref<ComboboxItem[]>([]);
onMounted(async () => {
const { en, cjk } = await googleFontsService.get();
gfEn.value = en.map((font) => ({
label: font.family,
value: font.family,
onSelect: () => setStyle("fontEN", { name: font.family })
}));
gfCjk.value = cjk
.map((font) => {
const family = font.family;
const name = FONT.GF.CJK_FAMILY_TO_NAME[family] || family;
return {
label: name,
value: family,
onSelect: () => setStyle("fontCJK", { name: name, fontFamily: family })
};
})
.sort(
(a, b) =>
Number(FONT.GF.CJK_FIRST.includes(b.label)) -
Number(FONT.GF.CJK_FIRST.includes(a.label))
);
loaded.value = true;
});
</script>
@@ -0,0 +1,22 @@
<template>
<EditorToolbarBox :text="$t('toolbar.font_size')" icon="i-ri:font-size-2">
<SharedUiSlider
unit="px"
:model-value="modelValue"
:min="12"
:max="20"
@update:model-value="
(value) => {
modelValue = value!;
setStyle('fontSize', value!.at(0)!);
}
"
/>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
const { styles, setStyle } = useStyleStore();
const modelValue = ref([styles.fontSize]);
</script>
@@ -0,0 +1,25 @@
<template>
<EditorToolbarBox
:text="$t('toolbar.line_height')"
icon="i-ic:round-format-line-spacing"
>
<SharedUiSlider
:model-value="modelValue"
:min="1"
:max="2"
:step="0.05"
@update:model-value="
(value) => {
modelValue = value!;
setStyle('lineHeight', value!.at(0)!);
}
"
/>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
const { styles, setStyle } = useStyleStore();
const modelValue = ref([styles.lineHeight]);
</script>
@@ -0,0 +1,42 @@
<template>
<EditorToolbarBox :text="$t('toolbar.margins.title')" icon="i-radix-icons:margin">
<div hstack text-muted-foreground gap-x-1 justify-end>
<span i-icon-park-outline:margin-one />
{{ $t("toolbar.margins.vertical") }}
</div>
<SharedUiSlider
unit="px"
:model-value="vModelValue"
@update:model-value="
(value) => {
vModelValue = value!;
setStyle('marginV', value!.at(0)!);
}
"
/>
<div mt-4 hstack text-muted-foreground gap-x-1 justify-end>
<span i-icon-park-outline:margin />
{{ $t("toolbar.margins.horizontal") }}
</div>
<SharedUiSlider
unit="px"
:model-value="hModelValue"
@update:model-value="
(value) => {
hModelValue = value!;
setStyle('marginH', value!.at(0)!);
}
"
/>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
const { styles, setStyle } = useStyleStore();
const vModelValue = ref([styles.marginV]);
const hModelValue = ref([styles.marginH]);
</script>
@@ -0,0 +1,23 @@
<template>
<EditorToolbarBox :text="$t('toolbar.paper_size')" icon="i-majesticons:paper-fold-line">
<SharedUiCombobox
id="paper-size"
class="capitalize"
:items="items"
:default-value="styles.paper"
/>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
import type { ValidPaperSize } from "~/composables/constant";
const { styles, setStyle } = useStyleStore();
const { PAPER } = useConstant();
const items = Object.keys(PAPER.SIZES).map((paper) => ({
label: paper,
value: paper,
onSelect: () => setStyle("paper", paper as ValidPaperSize)
}));
</script>
@@ -0,0 +1,24 @@
<template>
<EditorToolbarBox
:text="$t('toolbar.paragraph_spacing')"
icon="i-icon-park-outline:paragraph-break-two"
>
<SharedUiSlider
unit="px"
:model-value="modelValue"
:max="50"
@update:model-value="
(value) => {
modelValue = value!;
setStyle('paragraphSpace', value!.at(0)!);
}
"
/>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
const { styles, setStyle } = useStyleStore();
const modelValue = ref([styles.paragraphSpace]);
</script>
@@ -0,0 +1,107 @@
<template>
<EditorToolbarBox
:text="$t('toolbar.theme_color')"
icon="i-material-symbols:palette-outline"
>
<!-- Color presets -->
<div class="flex justify-between mb-4">
<button
v-for="(color, i) in COLOR.PRESET"
:key="`${i}-${color}`"
class="size-6 flex-center rounded text-white ring-when-focus"
:style="{ backgroundColor: color }"
@click="api.setValue(color)"
>
<span v-show="toHex(api.value) === color.toUpperCase()" i-line-md:confirm />
</button>
</div>
<!-- Color picker -->
<div v-bind="api.getRootProps()" relative z-21>
<div
v-bind="api.getControlProps()"
:class="[
'w-full hstack h-9 gap-x-2 px-2 py-1 rounded-md border-1.5 data-[focus]:border-primary',
api.open && 'border-primary'
]"
>
<button
v-bind="api.getTriggerProps()"
class="size-4 rounded overflow-hidden ring-when-focus"
>
<div class="size-full" v-bind="api.getSwatchProps({ value: api.value })" />
</button>
<input
v-bind="api.getChannelInputProps({ channel: 'hex' })"
class="bg-transparent outline-none"
/>
</div>
<div v-bind="api.getPositionerProps()" w-full ml-2>
<div
v-bind="api.getContentProps()"
class="bg-background overflow-hidden shadow-md"
border="~ rounded-md"
>
<div v-bind="api.getAreaProps()">
<div v-bind="api.getAreaBackgroundProps()" class="w-full h-30" />
<div
v-bind="api.getAreaThumbProps()"
class="size-4 rounded-full border-2 border-black ring-when-focus"
>
<span absolute size-3 border="2 white rounded-full" />
</div>
</div>
<div hstack my-3 px-3 gap-x-3>
<UiButton
v-bind="api.getEyeDropperTriggerProps()"
variant="ghost"
size="icon"
class="size-7 rounded"
>
<span i-bx:bxs-eyedropper text-lg />
</UiButton>
<div v-bind="api.getChannelSliderProps({ channel: 'hue' })" flex-1>
<div
v-bind="api.getChannelSliderTrackProps({ channel: 'hue' })"
class="w-full h-2.5 rounded-full"
/>
<div
v-bind="api.getChannelSliderThumbProps({ channel: 'hue' })"
class="size-4.5 -mt-2 -ml-2 ring-when-focus"
border="2 black rounded-full"
>
<span absolute size-3.5 border="2 white rounded-full" />
</div>
</div>
</div>
</div>
</div>
</div>
</EditorToolbarBox>
</template>
<script lang="ts" setup>
import * as colorPicker from "@zag-js/color-picker";
import { normalizeProps, useMachine } from "@zag-js/vue";
const { styles, setStyle } = useStyleStore();
const { COLOR } = useConstant();
const [state, send] = useMachine(
colorPicker.machine({
id: "theme-color",
value: colorPicker.parse(styles.themeColor),
positioning: {
gutter: 14
},
onValueChange: (details) => setStyle("themeColor", toHex(details.value))
})
);
const api = computed(() => colorPicker.connect(state.value, send, normalizeProps));
const toHex = (value: colorPicker.Color) =>
"#" + value.toHexInt().toString(16).toUpperCase().padStart(6, "0");
</script>
@@ -0,0 +1,351 @@
<template>
<UiDialog>
<UiDialogTrigger as-child>
<UiButton
class="gap-x-1.5 w-full h-8 justify-start text-primary"
variant="ghost"
size="sm"
>
<span class="i-material-symbols:auto-awesome text-base" />
AI Tailor with Signal
</UiButton>
</UiDialogTrigger>
<UiDialogContent class="sm:max-w-130">
<UiDialogHeader>
<UiDialogTitle>Tailor this Markdown resume</UiDialogTitle>
<UiDialogDescription>
Signal reads the current editor buffer, maps every claim to evidence, then
rewrites it for one role. Your YAML header and editor styles stay intact.
</UiDialogDescription>
</UiDialogHeader>
<div class="space-y-4 pt-2 text-sm">
<div class="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1">
<UiButton
size="sm"
:variant="mode === 'url' ? 'secondary' : 'ghost'"
@click="mode = 'url'"
>
Job URL
</UiButton>
<UiButton
size="sm"
:variant="mode === 'text' ? 'secondary' : 'ghost'"
@click="mode = 'text'"
>
Paste description
</UiButton>
</div>
<div v-if="mode === 'url'" class="space-y-2">
<label for="signal-job-url" class="font-medium">Public job-post URL</label>
<UiInput
id="signal-job-url"
v-model="jobURL"
type="url"
placeholder="https://company.com/careers/role"
/>
</div>
<div v-else class="space-y-2">
<label for="signal-job-text" class="font-medium"
>Complete job description</label
>
<textarea
id="signal-job-text"
v-model="jobText"
rows="8"
class="w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
placeholder="Paste responsibilities and qualifications…"
/>
<div class="text-right text-xs text-muted-foreground">
{{ jobText.length.toLocaleString() }} characters
</div>
</div>
<div class="space-y-2">
<label for="signal-about" class="font-medium">
Additional factual context
<span class="font-normal text-muted-foreground">(optional)</span>
</label>
<textarea
id="signal-about"
v-model="about"
rows="3"
class="w-full resize-y rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
placeholder="Add relevant facts that are not yet written in the resume…"
/>
</div>
<div class="space-y-3 rounded-lg border bg-muted/40 p-3">
<div class="flex items-start justify-between gap-4">
<div>
<label for="signal-strength" class="font-medium">
Tailoring strength
</label>
<p class="mt-1 text-xs text-muted-foreground">
{{ strengthDescription }}
</p>
</div>
<span
class="min-w-10 rounded-md bg-primary px-2 py-1 text-center text-xs font-semibold text-primary-foreground"
>
{{ tailoringStrength }}
</span>
</div>
<input
id="signal-strength"
v-model.number="tailoringStrength"
class="w-full cursor-pointer accent-primary"
type="range"
min="0"
max="100"
step="5"
/>
<div class="flex justify-between text-xs text-muted-foreground">
<span>Source-faithful</span>
<span>Maximum truthful fit</span>
</div>
<p class="border-t pt-2 text-xs text-muted-foreground">
Strength changes wording and supported detail only. It never permits invented
qualifications or achievements.
</p>
</div>
<UiAlert v-if="errorMessage" variant="destructive">
<UiAlertTitle>Signal could not complete the request</UiAlertTitle>
<UiAlertDescription>{{ errorMessage }}</UiAlertDescription>
</UiAlert>
<UiAlert v-if="successMessage">
<UiAlertTitle>Done</UiAlertTitle>
<UiAlertDescription>{{ successMessage }}</UiAlertDescription>
</UiAlert>
<div class="rounded-lg border bg-muted/40 p-3 text-xs text-muted-foreground">
Before applying AI changes, Signal stores an undo snapshot in this browser.
Tailored Markdown is also saved automatically in Oh My CV.
</div>
</div>
<UiDialogFooter class="gap-2 sm:justify-between">
<div class="flex gap-2">
<UiButton
variant="outline"
size="sm"
:disabled="busy"
@click="buildCareerProfile"
>
<span class="i-material-symbols:person-search-outline mr-1.5" />
Learn this resume
</UiButton>
<UiButton
v-if="hasBackup"
variant="ghost"
size="sm"
:disabled="busy"
@click="undoTailoring"
>
Undo AI edit
</UiButton>
</div>
<UiButton :disabled="busy || !hasJobInput" @click="tailorResume">
<span
:class="
busy ? 'i-line-md:loading-twotone-loop' : 'i-material-symbols:auto-awesome'
"
class="mr-1.5"
/>
{{ busy ? "Working…" : "Tailor & apply" }}
</UiButton>
</UiDialogFooter>
</UiDialogContent>
</UiDialog>
</template>
<script lang="ts" setup>
type TailorResponse = {
markdown: string;
package: {
job: {
role_title: string | null;
company: string | null;
};
};
};
const { data, setAndSyncToMonaco } = useDataStore();
const { styles } = useStyleStore();
const config = useRuntimeConfig();
const mode = ref<"url" | "text">("url");
const jobURL = ref("");
const jobText = ref("");
const about = ref("");
const tailoringStrength = ref(50);
const busy = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const hasBackup = ref(false);
const apiBase = computed(() =>
String(config.public.signalApiBase || "").replace(/\/$/, "")
);
const backupKey = computed(() => `signal:markdown-backup:${data.resumeId ?? "draft"}`);
const hasJobInput = computed(() =>
mode.value === "url"
? jobURL.value.trim().length > 0
: jobText.value.trim().length >= 80
);
const strengthDescription = computed(() => {
if (tailoringStrength.value <= 20) {
return "Minimal edits that stay close to the current wording and structure.";
}
if (tailoringStrength.value <= 70) {
return "Balanced rewriting using only evidence from this resume.";
}
return "Fuller supported detail and stronger job-aligned wording without invention.";
});
onMounted(() => {
hasBackup.value = Boolean(localStorage.getItem(backupKey.value));
});
async function post<T>(path: string, payload: Record<string, unknown>): Promise<T> {
const response = await fetch(`${apiBase.value}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const body = (await response.json()) as T & { detail?: string };
if (!response.ok) throw new Error(body.detail || "The Signal API returned an error.");
return body;
}
async function postTask<T>(path: string, payload: Record<string, unknown>): Promise<T> {
const started = await post<{ task_id: string }>(`${path}/start`, payload);
let consecutiveNetworkErrors = 0;
while (true) {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
try {
const response = await fetch(`${apiBase.value}/api/tasks/${started.task_id}`);
if (!response.ok) {
const body = (await response.json()) as { detail?: string };
throw new Error(body.detail || "Could not read the model task status.");
}
const task = (await response.json()) as {
status: "running" | "succeeded" | "failed";
result?: T;
error?: string;
};
consecutiveNetworkErrors = 0;
if (task.status === "succeeded" && task.result) return task.result;
if (task.status === "failed") {
throw new Error(task.error || "The background model task failed.");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("NetworkError") && !message.includes("Failed to fetch")) {
throw error;
}
consecutiveNetworkErrors += 1;
if (consecutiveNetworkErrors >= 50) {
throw new Error(
`Lost contact with Signal while task ${started.task_id} was running. ` +
"Check the server stdout logs; the model task may still be active."
);
}
}
}
}
const jobPayload = () => ({
job_url: mode.value === "url" ? jobURL.value.trim() : null,
job_text: mode.value === "text" ? jobText.value.trim() : null
});
const buildCareerProfile = async () => {
busy.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
const profile = await postTask<{ facts: unknown[] }>("/api/markdown/profile", {
markdown: data.markdown,
about: about.value
});
successMessage.value = `Career profile updated with ${profile.facts.length} evidence-backed facts.`;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Unknown error.";
} finally {
busy.value = false;
}
};
const tailorResume = async () => {
busy.value = true;
errorMessage.value = "";
successMessage.value = "";
try {
localStorage.setItem(
backupKey.value,
JSON.stringify({ markdown: data.markdown, savedAt: new Date().toISOString() })
);
hasBackup.value = true;
const result = await postTask<TailorResponse>("/api/markdown/tailor", {
markdown: data.markdown,
about: about.value,
tailoring_strength: tailoringStrength.value,
...jobPayload()
});
setAndSyncToMonaco("markdown", result.markdown);
if (data.resumeId) {
await storageService.updateResume({
id: data.resumeId,
name: data.resumeName,
markdown: result.markdown,
css: data.css,
styles: toRaw(styles)
});
}
const role = result.package.job.role_title || "the target role";
const company = result.package.job.company ? ` at ${result.package.job.company}` : "";
successMessage.value = `Applied an audited version tailored for ${role}${company}.`;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Unknown error.";
} finally {
busy.value = false;
}
};
const undoTailoring = async () => {
const raw = localStorage.getItem(backupKey.value);
if (!raw) return;
try {
const backup = JSON.parse(raw) as { markdown: string };
setAndSyncToMonaco("markdown", backup.markdown);
if (data.resumeId) {
await storageService.updateResume({
id: data.resumeId,
name: data.resumeName,
markdown: backup.markdown,
css: data.css,
styles: toRaw(styles)
});
}
localStorage.removeItem(backupKey.value);
hasBackup.value = false;
successMessage.value = "The pre-AI Markdown version has been restored.";
errorMessage.value = "";
} catch {
errorMessage.value = "The stored undo snapshot could not be restored.";
}
};
</script>
@@ -0,0 +1,59 @@
<template>
<UiTooltipProvider :delay-duration="0">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
class="gap-x-1.5 w-full h-8 justify-start"
variant="ghost"
size="sm"
@click="exportPDF"
>
<span i-mdi:file-pdf text-base />
{{ $t("toolbar.file.export_pdf.title") }}
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent side="bottom" class="w-54 p-0 rounded border-destructive/60">
<UiAlert variant="destructive" class="border-none rounded-none">
<UiAlertTitle>
{{ $t("toolbar.file.export_pdf.alert.title") }}
<span class="text-foreground font-normal text-xs">
(<SharedIssueLink issue="13" />, <SharedIssueLink issue="16" />)
</span>
</UiAlertTitle>
<UiAlertDescription v-html="$t('toolbar.file.export_pdf.alert.content')" />
</UiAlert>
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
<UiButton
class="gap-x-1.5 w-full h-8 justify-start"
variant="ghost"
size="sm"
@click="exportMd"
>
<span i-ri:markdown-fill text-base />
{{ $t("toolbar.file.export_md") }}
</UiButton>
</template>
<script lang="ts" setup>
import { downloadFile } from "@renovamen/utils";
const { data } = useDataStore();
const saveName = computed(() => data.resumeName.trim().replace(/\s+/g, "_"));
// Export as PDF
const exportPDF = () => {
const title = document.title;
document.title = saveName.value;
window.print();
document.title = title;
};
// Export as Markdown
const exportMd = () => {
downloadFile(`${saveName.value}.md`, data.markdown);
};
</script>
@@ -0,0 +1,107 @@
<template>
<UiDialog>
<UiDialogTrigger as-child>
<UiButton class="gap-x-1.5 w-full h-8 justify-start" variant="ghost" size="sm">
<span i-mdi:upload text-base />
{{ $t("toolbar.file.import.trigger") }}
</UiButton>
</UiDialogTrigger>
<UiDialogContent class="sm:max-w-110">
<UiDialogHeader>
<UiDialogTitle>{{ $t("toolbar.file.import.dialog.header") }}</UiDialogTitle>
</UiDialogHeader>
<div class="pt-2 space-y-6 text-sm">
<div v-bind="api.getRootProps()">
<div
v-bind="api.getDropzoneProps()"
class="py-14 cursor-pointer hover:(bg-accent text-accent-foreground)"
border="~ dashed rounded"
>
<input v-bind="api.getHiddenInputProps()" />
<div text-center>{{ $t("toolbar.file.import.dialog.from_local") }}</div>
</div>
<div v-if="localFile" class="bg-muted text-muted-foreground rounded p-2 mt-2">
{{ localFile }}
</div>
</div>
<div hstack>
<UiSeparator flex-1 bg="primary/40" />
<div px-5 text-primary>OR</div>
<UiSeparator flex-1 bg="primary/40" />
</div>
<div class="flex gap-x-2">
<UiInput
v-model="pastedURL"
:placeholder="$t('toolbar.file.import.dialog.from_url')"
@keyup.enter="uploadFileFromURL"
/>
<UiButton
type="submit"
size="icon"
class="shrink-0"
@click="uploadFileFromURL"
:disabled="pastedURL === ''"
>
<span class="sr-only">Submit</span>
<span i-line-md:confirm size-4 />
</UiButton>
</div>
</div>
</UiDialogContent>
</UiDialog>
</template>
<script lang="ts" setup>
import * as fileUpload from "@zag-js/file-upload";
import { normalizeProps, useMachine } from "@zag-js/vue";
import { fetchFile } from "@renovamen/utils";
const { setAndSyncToMonaco } = useDataStore();
// Zag.js file component
const localFile = ref<string | null>(null);
const [state, send] = useMachine(
fileUpload.machine({
id: "import-dialog",
accept: ".md",
onFileAccept: ({ files }) => {
const reader = new FileReader();
reader.onloadend = () => {
const content = reader.result as string;
setAndSyncToMonaco("markdown", content);
};
reader.readAsText(files[0]);
localFile.value = files[0].name;
pastedURL.value = "";
}
})
);
const api = computed(() => fileUpload.connect(state.value, send, normalizeProps));
// Fetched file from pasted URL
const pastedURL = ref("");
const uploadFileFromURL = async () => {
if (pastedURL.value.trim() === "") return;
try {
const content = await fetchFile(pastedURL.value);
setAndSyncToMonaco("markdown", content);
localFile.value = null;
pastedURL.value = "";
} catch (error) {
// TODO: use toast to show error message
console.error(error);
}
};
</script>
@@ -0,0 +1,33 @@
<template>
<div class="hstack gap-x-1.5 px-3 w-full h-8">
<span i-material-symbols:edit-square-outline-rounded text-base />
{{ $t("toolbar.file.rename") }}
<span class="flex-1 tracking-widest" text="xs right muted-foreground"></span>
</div>
<SharedUiEditable
class="text-sm ml-8.5 mt-1"
:default-value="data.resumeName"
submit-mode="enter"
auto-resize
@submit="(text) => rename(text)"
/>
</template>
<script lang="ts" setup>
const { data } = useDataStore();
const rename = async (text?: string) => {
if (!text || !data.resumeId) return;
data.resumeName = text;
await storageService.updateResume(
{
id: data.resumeId,
name: text
},
false
);
};
</script>
@@ -0,0 +1,34 @@
<template>
<UiButton
class="gap-x-1.5 w-full h-8 justify-start"
variant="ghost"
size="sm"
@click="save"
>
<span i-ic:baseline-save text-base />
{{ $t("toolbar.file.save") }}
<span class="flex-1 tracking-widest" text="xs right muted-foreground"> S</span>
</UiButton>
</template>
<script lang="ts" setup>
import { useShortcuts } from "@ohmycv/vue-shortcuts";
const { data } = useDataStore();
const { styles } = useStyleStore();
const save = async () => {
if (!data.resumeId) return;
await storageService.updateResume({
id: data.resumeId,
name: data.resumeName,
markdown: data.markdown,
css: data.css,
styles: toRaw(styles)
});
};
// Use the shortcut to save the current resume
useShortcuts("ctrl+s", save);
</script>
@@ -0,0 +1,15 @@
<template>
<EditorToolbarBox :text="$t('toolbar.file.title')" icon="i-carbon:import-export">
<EditorToolbarFileSave />
<EditorToolbarFileRename />
<EditorToolbarFileAiTailor />
<hr border-dashed my-2 />
<EditorToolbarFileExport />
<hr border-dashed my-2 />
<EditorToolbarFileImport />
</EditorToolbarBox>
</template>
@@ -0,0 +1,120 @@
<template>
<div class="flex w-72 h-full">
<div
id="toolbar"
class="pane-container overflow-y-scroll hide-scrollbar bg-background"
lt-lg="bg-accent rounded-none"
>
<template v-for="(tool, i) in tools" :key="tool.id">
<component :is="tool.component" :id="`toolbar-${tool.id}`" />
<UiSeparator v-if="i < tools.length - 1" class="w-[calc(100%-32px)] mx-auto" />
</template>
</div>
<div flex="center col none gap-1" border="l dashed lg:none" w-10 bg-accent>
<template v-for="tool in tools" :key="tool.id">
<UiTooltipProvider :delay-duration="0">
<UiTooltip>
<UiTooltipTrigger as-child>
<UiButton
size="round"
variant="ghost-secondary"
@click="scrollTo(tool.id)"
:aria-label="getTooltip(tool.id)"
>
<span :class="[tool.icon, ' size-4']" />
</UiButton>
</UiTooltipTrigger>
<UiTooltipContent side="left">
{{ getTooltip(tool.id) }}
</UiTooltipContent>
</UiTooltip>
</UiTooltipProvider>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
EditorToolbarFile,
EditorToolbarPaper,
EditorToolbarThemeColor,
EditorToolbarFontFamily,
EditorToolbarFontSize,
EditorToolbarMargins,
EditorToolbarParagraphSpace,
EditorToolbarLineHeight,
EditorToolbarCorrectCase
} from "#components";
const tools = [
{
id: "file",
icon: "i-carbon:import-export",
component: EditorToolbarFile
},
{
id: "paper_size",
icon: "i-majesticons:paper-fold-line",
component: EditorToolbarPaper
},
{
id: "theme_color",
icon: "i-material-symbols:palette-outline",
component: EditorToolbarThemeColor
},
{
id: "font_family",
icon: "i-material-symbols:font-download-outline",
component: EditorToolbarFontFamily
},
{
id: "font_size",
icon: "i-ri:font-size-2",
component: EditorToolbarFontSize
},
{
id: "margins",
icon: "i-radix-icons:margin",
component: EditorToolbarMargins
},
{
id: "paragraph_spacing",
icon: "i-icon-park-outline:paragraph-break-two",
component: EditorToolbarParagraphSpace
},
{
id: "line_height",
icon: "i-ic:round-format-line-spacing",
component: EditorToolbarLineHeight
},
{
id: "correct_case",
icon: "i-icon-park-outline:check-correct",
component: EditorToolbarCorrectCase
}
];
const scrollTo = (id: string) => {
const toolbar = document.querySelector<HTMLElement>("#toolbar");
const section = document.querySelector<HTMLElement>(`#toolbar-${id}`);
if (!toolbar || !section) return;
toolbar.scrollTo({
// offsetTop - header height
top: section.offsetTop - 48,
behavior: "smooth"
});
};
const { t } = useI18n();
const getTooltip = (id: string) => {
const key = `toolbar.${id}`;
return ["file", "correct_case", "font_family", "margins"].includes(id)
? t(`${key}.title`)
: t(key);
};
</script>
@@ -0,0 +1 @@
<template>Oh<span text-primary>My</span>CV</template>
+93
View File
@@ -0,0 +1,93 @@
<template>
<header class="hstack justify-between pl-4 pr-1">
<nuxt-link class="hstack gap-x-2" :to="$nuxt.$localePath('/')">
<SharedLogo text-base />
<div text-lg><SharedBrandName /></div>
</nuxt-link>
<div class="hstack">
<UiButton
:as="NuxtLink"
:to="$nuxt.$localePath('/dashboard')"
variant="ghost-secondary"
size="xs"
class="h-8 gap-x-1"
:aria-label="$t('dashboard.my_resumes')"
>
<span class="i-ep:menu text-lg" />
<span class="hide-on-mobile text-base">
{{ $t("dashboard.my_resumes") }}
</span>
</UiButton>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost-secondary"
size="xs"
class="h-8 gap-x-1"
:aria-label="`Switch the language from: ${localeName}`"
>
<span class="i-ic:round-translate text-lg" />
<span class="hide-on-mobile text-base">
{{ localeName }}
</span>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent class="min-w-28" align="start" :side-offset="0">
<UiDropdownMenuItem
v-for="item in availableLocales"
:key="item.code"
:as="NuxtLink"
:to="switchLocalePath(item.code)"
>
<span v-if="item.icon" :class="[item.icon, 'text-base mr-1.5']" />
{{ item.name }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<slot name="tail" />
<UiButton
as="a"
variant="ghost-secondary"
size="xs"
class="h-8 gap-x-1"
href="/"
aria-label="Open Signal AI resume workspace"
>
<span class="i-material-symbols:auto-awesome text-lg" />
<span class="hide-on-mobile text-base">Signal AI</span>
</UiButton>
<SharedToggleDark />
<UiButton
as="a"
variant="ghost-secondary"
size="round"
href="http://github.com/Renovamen/oh-my-cv"
target="_blank"
rel="nofollow noopener"
>
<span i-tabler:brand-github text-lg />
</UiButton>
</div>
</header>
</template>
<script lang="ts" setup>
import { NuxtLink } from "#components";
const switchLocalePath = useSwitchLocalePath();
const { locale, locales } = useI18n();
const availableLocales = computed(() =>
locales.value.filter((i) => i.code !== locale.value)
);
const localeName = computed(
() => locales.value.find((i) => i.code === locale.value)?.name || ""
);
</script>
@@ -0,0 +1,19 @@
<template>
<a
:class="cn('hover:underline', props.class)"
:href="`https://github.com/Renovamen/oh-my-cv/issues/${issue}`"
target="_blank"
rel="nofollow noopener"
>#{{ issue }}</a
>
</template>
<script lang="ts" setup>
import type { HTMLAttributes } from "vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<{
issue: string | number;
class?: HTMLAttributes["class"];
}>();
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<svg
width="1.7em"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
:fill="$colorMode.unknown || $colorMode.value !== 'dark' ? '#1e293b' : '#e2e8f0'"
>
<circle cx="15.672" cy="15.184" r="11.544" fill-opacity="0.15" />
<path
d="M11.244 30.525c.532.036.656-.003 1.038-.165.241-.138.38-.35.499-.677l2.204-6.471c.068-.536 1.211-1.736 2.556-1.608.093.009.172.073.26.115 3.57 1.694 9.404 3.535 11.036.032.054-.116.099-.21.088-.324-.014-.154-.16-.311-.218-.386l-9.194-11.43 2.554-7.6a.456.456 0 0 0 .003-.288.394.394 0 0 0-.344-.26.503.503 0 0 0-.376.167L4.001 19.347c-.614.699-.878.905-.925 1.864-.039.79.357 1.251.646 1.623l6.538 7.135c.236.298.584.53.984.556Zm7.953-16.495a1.783 1.783 0 1 1-.003 3.565 1.783 1.783 0 0 1 .003-3.565Zm.294.529a.902.902 0 1 1 0 1.804.902.902 0 0 1 0-1.804Z"
/>
</svg>
</template>
@@ -0,0 +1,60 @@
<template>
<div class="resume-render" :id="`resume-${id}`" ref="target" />
</template>
<script lang="ts" setup>
import { useSmartPages } from "@ohmycv/vue-smart-pages";
import type { ResumeStyles } from "~/composables/stores/style";
const props = defineProps<{
id: string | number;
markdown: string;
css?: string;
styles: ResumeStyles;
}>();
const constant = useConstant();
const target = ref<HTMLElement>();
const size = computed(() => ({
height: constant.PAPER.sizeToPx(props.styles.paper, "h"),
width: constant.PAPER.SIZES[props.styles.paper].w
}));
const margins = computed(() => ({
top: props.styles.marginV,
bottom: Math.max(props.styles.marginV - 10, constant.RENDER.PRINT_BOTTOM),
left: props.styles.marginH,
right: props.styles.marginH
}));
const html = computed(() => markdownService.renderResume(props.markdown));
const { render } = useSmartPages(target, html, size, margins, {
beforeRender: async () => {
// Wait for the fonts to be loaded
await googleFontsService.presetObserver(props.styles);
},
watchThrottledOptions: {
throttle: 200
}
});
watchThrottled(
() => [
props.styles.lineHeight,
props.styles.paragraphSpace,
props.styles.fontSize,
props.css,
props.styles.fontCJK,
props.styles.fontEN
],
render,
{
throttle: 200,
leading: false
}
);
defineExpose({
render
});
</script>
@@ -0,0 +1,57 @@
<template>
<UiButton
variant="ghost-secondary"
size="round"
:aria-label="$t('toggle_theme')"
@click="switchMode"
>
<div
v-for="mode in modes"
:class="[
'absolute transition-transform duration-500',
mode.icon,
transform(mode.value)
]"
/>
</UiButton>
</template>
<script lang="ts" setup>
type ModeValue = "light" | "dark" | "system";
const modes: Array<{ value: ModeValue; icon: string }> = [
{
value: "system",
icon: "i-material-symbols:night-sight-auto-rounded size-4.5"
},
{
value: "light",
icon: "i-ph:sun-bold size-4"
},
{
value: "dark",
icon: "i-ph:moon-bold size-4"
}
];
const colorMode = useColorMode();
const _findModeIndex = (mode: string) => modes.findIndex((m) => m.value === mode);
const _findNeighbourMode = (mode: string, position: -1 | 1) => {
const index = _findModeIndex(mode);
return modes[(modes.length + index + position) % modes.length].value;
};
const switchMode = () => {
colorMode.preference = _findNeighbourMode(colorMode.preference, 1);
};
const transform = (mode: ModeValue) => {
return colorMode.preference === mode
? "scale-100 rotate-0"
: colorMode.preference === _findNeighbourMode(mode, -1)
? "scale-0 rotate-90"
: "scale-0 -rotate-90";
};
</script>
@@ -0,0 +1,92 @@
<template>
<div v-bind="api.getRootProps()" relative>
<div
v-bind="api.getControlProps()"
class="group hstack h-9 gap-x-2 px-2 py-1 rounded-md border-1.5 data-[focus]:border-primary"
>
<input
v-bind="api.getInputProps()"
class="w-full outline-none bg-transparent capitalize"
/>
<button v-bind="api.getTriggerProps()" size-5 flex-center>
<span
class="text-lg i-ic:sharp-arrow-drop-down group-data-[focus]:i-ic:sharp-arrow-drop-up"
/>
</button>
</div>
<div v-bind="api.getPositionerProps()">
<ul
v-if="options.length > 0"
v-bind="api.getContentProps()"
class="z-20 max-h-60 -mt-1 p-1 bg-background border rounded-md shadow-c overflow-y-scroll"
>
<li
v-for="item in options"
:key="item.value"
v-bind="api.getItemProps({ item })"
class="px-2 py-1.5 rounded-sm truncate cursor-pointer data-[highlighted]:(bg-accent text-accent-foreground) data-[state=checked]:(bg-accent text-accent-foreground)"
>
{{ item.label }}
</li>
</ul>
</div>
</div>
</template>
<script lang="ts" setup>
import * as combobox from "@zag-js/combobox";
import { normalizeProps, useMachine } from "@zag-js/vue";
export interface ComboboxItem {
label: string;
value: string;
onSelect: () => void;
}
const props = defineProps<{
id: string;
items: Array<ComboboxItem>;
defaultValue: string;
}>();
const options = ref(props.items);
const collectionRef = computed(() =>
combobox.collection({
items: options.value,
itemToValue: (item) => item.value,
itemToString: (item) => item.label
})
);
const [state, send] = useMachine(
combobox.machine({
id: props.id,
collection: collectionRef.value,
value: [props.defaultValue],
openOnClick: true,
closeOnSelect: false,
onOpenChange: () => {
options.value = props.items;
},
onInputValueChange: ({ inputValue }) => {
const filtered = props.items.filter((item) =>
item.label.toLowerCase().includes(inputValue.toLowerCase())
);
options.value = filtered.length > 0 ? filtered : props.items;
},
onValueChange: ({ value }) => {
const item = props.items.find((i) => i.value === value[0]);
item?.onSelect();
}
}),
{
context: computed(() => ({
collection: collectionRef.value
}))
}
);
const api = computed(() => combobox.connect(state.value, send, normalizeProps));
</script>
@@ -0,0 +1,44 @@
<template>
<EditableRoot v-slot="{ isEditing }" v-bind="forwarded">
<EditableArea class="w-full">
<EditablePreview class="cursor-pointer" />
<EditableInput />
</EditableArea>
<div v-if="isEditing" class="flex gap-1 mt-1">
<UiButton
:as="EditableSubmitTrigger"
size="xs"
class="flex-1 rounded hover:ring-none"
aria-label="Submit"
>
<span i-material-symbols-check-rounded size-4 />
</UiButton>
<UiButton
:as="EditableCancelTrigger"
variant="secondary"
size="xs"
class="flex-1 rounded"
aria-label="Cancel"
>
<span i-material-symbols-close-rounded size-4 />
</UiButton>
</div>
</EditableRoot>
</template>
<script lang="ts" setup>
import {
EditableSubmitTrigger,
EditableCancelTrigger,
type EditableRootProps,
type EditableRootEmits,
useForwardPropsEmits
} from "radix-vue";
const props = defineProps<EditableRootProps>();
const emits = defineEmits<EditableRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
@@ -0,0 +1,50 @@
<template>
<SliderRoot
class="relative flex w-full touch-none select-none items-center py-2"
v-bind="forwarded"
>
<SliderTrack
class="relative h-1 w-full grow overflow-hidden rounded-full bg-secondary"
>
<SliderRange class="absolute h-full bg-primary" />
</SliderTrack>
<SliderThumb
v-for="(_, key) in modelValue"
:key="key"
class="group block size-4 rounded-full border-2.5 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<span
class="hidden group-hover:block group-focus-visible:block p-1 min-w-6 rounded bg-primary absolute -top-2 left-1/2 -translate-x-2/4 -translate-y-full after:(absolute content-[''] size-0 border-5 border-transparent border-t-primary top-full inset-x-0 mx-auto)"
text="white xs center"
>
{{ modelValue?.at(0) }}
</span>
</SliderThumb>
</SliderRoot>
<div flex justify-between text-muted-foreground>
<span>{{ min }}{{ unit }}</span>
<span>{{ middle }}{{ unit }}</span>
<span>{{ max }}{{ unit }}</span>
</div>
</template>
<script lang="ts" setup>
import type { SliderRootEmits, SliderRootProps } from "radix-vue";
import { useForwardPropsEmits } from "radix-vue";
const props = defineProps<
SliderRootProps & {
unit?: string;
}
>();
const emits = defineEmits<SliderRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
const min = computed(() => props.min || 0);
const max = computed(() => props.max || 100);
const middle = computed(() => (min.value + max.value) / 2);
const unit = computed(() => props.unit || "");
</script>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { type AlertVariants, alertVariants } from ".";
import { cn } from "~/utils/shadcn";
const props = defineProps<{
class?: HTMLAttributes["class"];
variant?: AlertVariants["variant"];
}>();
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<h5 :class="cn('mb-1 font-bold leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>
+26
View File
@@ -0,0 +1,26 @@
import { type VariantProps, cva } from "class-variance-authority";
export { default as Alert } from "./Alert.vue";
export { default as AlertTitle } from "./AlertTitle.vue";
export { default as AlertDescription } from "./AlertDescription.vue";
export const alertVariants = cva(
"relative w-full rounded-md border p-3 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-accent/30 text-foreground",
destructive:
"border-destructive/60 bg-destructive/5 [&>h5]:text-destructive [&>svg]:text-destructive",
success:
"border-success/60 bg-success/5 [&>h5]:text-success [&>svg]:text-success",
info: "border-info/60 bg-info/5 [&>h5]:text-info [&>svg]:text-info"
}
},
defaultVariants: {
variant: "default"
}
}
);
export type AlertVariants = VariantProps<typeof alertVariants>;
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { Primitive, type PrimitiveProps } from "radix-vue";
import { type ButtonVariants, buttonVariants } from ".";
import { cn } from "~/utils/shadcn";
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
}
const props = withDefaults(defineProps<Props>(), {
as: "button"
});
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+36
View File
@@ -0,0 +1,36 @@
import { type VariantProps, cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:(bg-primary/90 ring-4 ring-ring/40)",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
"ghost-secondary": "hover:bg-secondary hover:text-secondary-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-10 px-4 py-2",
xs: "h-7 rounded px-2",
sm: "h-9 rounded px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
round: "h-8 w-8 rounded-full"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DialogRoot,
type DialogRootEmits,
type DialogRootProps,
useForwardPropsEmits
} from "radix-vue";
const props = defineProps<DialogRootProps>();
const emits = defineEmits<DialogRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot v-bind="forwarded">
<slot />
</DialogRoot>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogClose, type DialogCloseProps } from "radix-vue";
const props = defineProps<DialogCloseProps>();
</script>
<template>
<DialogClose v-bind="props">
<slot />
</DialogClose>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits
} from "radix-vue";
import { X } from "lucide-vue-next";
import { cn } from "~/utils/shadcn";
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class
)
"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DialogDescription,
type DialogDescriptionProps,
useForwardProps
} from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogDescription
v-bind="forwardedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>
<template>
<div
:class="
cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', props.class)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DialogClose,
DialogContent,
type DialogContentEmits,
type DialogContentProps,
DialogOverlay,
DialogPortal,
useForwardPropsEmits
} from "radix-vue";
import { X } from "lucide-vue-next";
import { cn } from "~/utils/shadcn";
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>();
const emits = defineEmits<DialogContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class
)
"
v-bind="forwarded"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-3 right-3 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { DialogTitle, type DialogTitleProps, useForwardProps } from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogTitle
v-bind="forwardedProps"
:class="cn('text-lg font-semibold leading-none tracking-tight', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DialogTrigger, type DialogTriggerProps } from "radix-vue";
const props = defineProps<DialogTriggerProps>();
</script>
<template>
<DialogTrigger v-bind="props">
<slot />
</DialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as Dialog } from "./Dialog.vue";
export { default as DialogClose } from "./DialogClose.vue";
export { default as DialogTrigger } from "./DialogTrigger.vue";
export { default as DialogHeader } from "./DialogHeader.vue";
export { default as DialogTitle } from "./DialogTitle.vue";
export { default as DialogDescription } from "./DialogDescription.vue";
export { default as DialogContent } from "./DialogContent.vue";
export { default as DialogScrollContent } from "./DialogScrollContent.vue";
export { default as DialogFooter } from "./DialogFooter.vue";
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuRoot,
type DropdownMenuRootEmits,
type DropdownMenuRootProps,
useForwardPropsEmits
} from "radix-vue";
const props = defineProps<DropdownMenuRootProps>();
const emits = defineEmits<DropdownMenuRootEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuCheckboxItem,
type DropdownMenuCheckboxItemEmits,
type DropdownMenuCheckboxItemProps,
DropdownMenuItemIndicator,
useForwardPropsEmits
} from "radix-vue";
import { Check } from "lucide-vue-next";
import { cn } from "~/utils/shadcn";
const props = defineProps<
DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuCheckboxItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Check class="w-4 h-4" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuContent,
type DropdownMenuContentEmits,
type DropdownMenuContentProps,
DropdownMenuPortal,
useForwardPropsEmits
} from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4
}
);
const emits = defineEmits<DropdownMenuContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
:class="
cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { DropdownMenuGroup, type DropdownMenuGroupProps } from "radix-vue";
const props = defineProps<DropdownMenuGroupProps>();
</script>
<template>
<DropdownMenuGroup v-bind="props">
<slot />
</DropdownMenuGroup>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { DropdownMenuItem, type DropdownMenuItemProps, useForwardProps } from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<
DropdownMenuItemProps & { class?: HTMLAttributes["class"]; inset?: boolean }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuItem
v-bind="forwardedProps"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
props.class
)
"
>
<slot />
</DropdownMenuItem>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuLabel,
type DropdownMenuLabelProps,
useForwardProps
} from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<
DropdownMenuLabelProps & { class?: HTMLAttributes["class"]; inset?: boolean }
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DropdownMenuLabel
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuRadioGroup,
type DropdownMenuRadioGroupEmits,
type DropdownMenuRadioGroupProps,
useForwardPropsEmits
} from "radix-vue";
const props = defineProps<DropdownMenuRadioGroupProps>();
const emits = defineEmits<DropdownMenuRadioGroupEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuRadioGroup v-bind="forwarded">
<slot />
</DropdownMenuRadioGroup>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
type DropdownMenuRadioItemEmits,
type DropdownMenuRadioItemProps,
useForwardPropsEmits
} from "radix-vue";
import { Circle } from "lucide-vue-next";
import { cn } from "~/utils/shadcn";
const props = defineProps<
DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuRadioItem
v-bind="forwarded"
:class="
cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<Circle class="h-2 w-2 fill-current" />
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import { DropdownMenuSeparator, type DropdownMenuSeparatorProps } from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"];
}
>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
</script>
<template>
<DropdownMenuSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<span :class="cn('ml-auto text-xs tracking-widest opacity-60', props.class)">
<slot />
</span>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import {
DropdownMenuSub,
type DropdownMenuSubEmits,
type DropdownMenuSubProps,
useForwardPropsEmits
} from "radix-vue";
const props = defineProps<DropdownMenuSubProps>();
const emits = defineEmits<DropdownMenuSubEmits>();
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DropdownMenuSub v-bind="forwarded">
<slot />
</DropdownMenuSub>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from "vue";
import {
DropdownMenuSubContent,
type DropdownMenuSubContentEmits,
type DropdownMenuSubContentProps,
useForwardPropsEmits
} from "radix-vue";
import { cn } from "~/utils/shadcn";
const props = defineProps<
DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }
>();
const emits = defineEmits<DropdownMenuSubContentEmits>();
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props;
return delegated;
});
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DropdownMenuSubContent
v-bind="forwarded"
:class="
cn(
'z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
"
>
<slot />
</DropdownMenuSubContent>
</template>

Some files were not shown because too many files have changed in this diff Show More