init
This commit is contained in:
Vendored
+17
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+30
@@ -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
|
||||
}
|
||||
};
|
||||
Vendored
+56
@@ -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
|
||||
};
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export default defineI18nConfig(() => ({
|
||||
legacy: false,
|
||||
locale: "en"
|
||||
}));
|
||||
Vendored
+68
@@ -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
|
||||
});
|
||||
Vendored
+55
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+43
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
#dashboard-page {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
+62
@@ -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
@@ -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
@@ -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.
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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+44
@@ -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>
|
||||
+19
@@ -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>
|
||||
+45
@@ -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>
|
||||
+24
@@ -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>
|
||||
+14
@@ -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>
|
||||
+37
@@ -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
Reference in New Issue
Block a user