Created
August 27, 2025 20:54
-
-
Save dedemenezes/0a6a67bd905d33758d26279e4e9c4c89 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md | |
index 50e8193..028a8a6 100644 | |
--- a/.github/pull_request_template.md | |
+++ b/.github/pull_request_template.md | |
@@ -1,9 +1,11 @@ | |
# Task | |
+ | |
<!-- Please add link(s) to Jira task(s) related to this PR --> | |
A link to a task in Jira or an issus on Github | |
# Description | |
+ | |
<!-- Please include a summary of the change --> | |
<!-- Any details that you think are important to review this PR? --> | |
<!-- Are there other PRs related to this one? --> | |
@@ -11,16 +13,19 @@ A link to a task in Jira or an issus on Github | |
A summary of the change, anything else that will to review this PR | |
# Demo | |
+ | |
<!-- Add a screenshot or a video demonstration when possible --> | |
A few screenshots or a video | |
# How Has This Been Tested? | |
+ | |
<!-- Please describe how you tested your changes --> | |
A detailed explanation how the code was tested | |
# Checklist | |
+ | |
<!-- Go over all the following points, and put an `x` in all the boxes that apply --> | |
- [ ] I have performed a self-review of my own code | |
diff --git a/.gitignore b/.gitignore | |
index 668ee12..10e2ab6 100644 | |
--- a/.gitignore | |
+++ b/.gitignore | |
@@ -26,3 +26,6 @@ dist-ssr | |
# Local Netlify folder | |
.netlify | |
+/public/dev*.xml | |
+gcmsg.md | |
+msg.md | |
diff --git a/README.md b/README.md | |
index 0d33af4..cded37c 100644 | |
--- a/README.md | |
+++ b/README.md | |
@@ -18,14 +18,14 @@ Colors are defined as **primitives** in `design-tokens/color-primitives.json` an | |
import * as colorPrimitives from "./design-tokens/color-primitives.json"; | |
extend: { | |
- colors: colorPrimitives | |
+ colors: colorPrimitives; | |
} | |
``` | |
+ | |
Usage example: | |
+ | |
```html | |
-<div class="bg-brand-300 text-neutral-700"> | |
- Button | |
-</div> | |
+<div class="bg-brand-300 text-neutral-700">Button</div> | |
``` | |
## 2. Spacing | |
@@ -48,11 +48,13 @@ Spacing tokens from Figma map to Tailwind’s spacing scale in rem units | |
| 4000 | 9.375rem | 150px | | |
Usage: | |
+ | |
```html | |
<div class="p-400">Padding 16px</div> | |
``` | |
## 3. Border Radius | |
+ | |
```js | |
borderRadius: { | |
"100": "0.25rem", // 4px | |
@@ -61,8 +63,6 @@ borderRadius: { | |
} | |
``` | |
- | |
- | |
## 4. Typography | |
### Font Families | |
@@ -83,6 +83,7 @@ fontWeight: { | |
``` | |
### Font Sizes | |
+ | |
```js | |
fontSize: { | |
xs: "0.75rem", // 12px | |
@@ -97,6 +98,7 @@ fontSize: { | |
'6xl': "4.5rem", // 72px | |
} | |
``` | |
+ | |
Usage: | |
```html | |
@@ -121,19 +123,15 @@ These match Figma frame sizes for responsive design. | |
Usage: | |
```html | |
-<div class="p-4 md:p-8 xl:p-12"> | |
- Responsive padding | |
-</div> | |
+<div class="p-4 md:p-8 xl:p-12">Responsive padding</div> | |
``` | |
### Principle: | |
We keep only primitives in Tailwind, no semantic mappings (primary, secondary, h1, etc.). | |
- | |
# Riff Vue Component Library - Simple Documentation | |
- | |
## 🏗️ Component Library Structure | |
``` | |
@@ -155,16 +153,19 @@ src/components/ | |
## 🎨 Design System | |
### Colors | |
+ | |
- **Neutrals**: `neutrals-200`, `neutrals-300`, `neutrals-600`, `neutrals-900`, `neutrals-1000` | |
- **Brand**: `magenta-600`, `laranja-600`, `vermelho-600` | |
- **Transparency**: `white-transp-1000` | |
### Typography | |
+ | |
- **Fonts**: `font-body`, `font-heading` | |
- **Weights**: `font-regular`, `font-semibold` | |
- **Sizes**: `text-xs`, `text-sm`, `text-md`, `text-xl` | |
### Spacing | |
+ | |
- **Scale**: `100`, `150`, `200`, `300`, `400` (padding/margin) | |
- **Border Radius**: `rounded-100`, `rounded-200` | |
@@ -173,7 +174,9 @@ src/components/ | |
## 📚 Typography Components | |
### BaseHeader | |
+ | |
Main heading component with customizable font size and color. | |
+ | |
```vue | |
<BaseHeader fontSize="text-2xl" textColor="text-neutrals-900"> | |
Page Title | |
@@ -181,7 +184,9 @@ Main heading component with customizable font size and color. | |
``` | |
### BodyRegular | |
+ | |
Standard body text component. | |
+ | |
```vue | |
<BodyRegular> | |
Regular paragraph text content | |
@@ -189,6 +194,7 @@ Standard body text component. | |
``` | |
### Other Typography | |
+ | |
- **HeaderSmall**: Small heading text | |
- **SectionHeader**: Section title styling | |
- **SubHeading**: Subtitle component | |
@@ -200,15 +206,18 @@ Standard body text component. | |
## 🔘 Button Components | |
### BaseButton | |
+ | |
Main button component with variants and sizes. | |
**Variants:** | |
+ | |
- `gray`: Default neutral button | |
- `cta`: Gradient call-to-action button | |
- `rioMarket`: Red background button | |
- `underline`: Text button with underline | |
**Sizes:** | |
+ | |
- `xs`: Extra small (px-200 py-150) | |
- `sm`: Small (px-300 py-200) | |
- `md`: Medium (px-400 py-400) | |
@@ -221,9 +230,11 @@ Main button component with variants and sizes. | |
``` | |
### ButtonText | |
+ | |
Text-only button component. | |
**Variants:** | |
+ | |
- `dark`: Dark text with hover | |
- `light`: White transparent text | |
- `color`: Gradient text effect | |
@@ -237,7 +248,9 @@ Text-only button component. | |
## 📝 Input Components | |
### TextInput | |
+ | |
Standard text input with label and validation states. | |
+ | |
```vue | |
<TextInput | |
id="email" | |
@@ -248,12 +261,11 @@ Standard text input with label and validation states. | |
``` | |
### CheckboxInput | |
+ | |
Checkbox with custom styling and label. | |
+ | |
```vue | |
-<CheckboxInput | |
- label="I agree to terms" | |
- v-model="agreed" | |
-/> | |
+<CheckboxInput label="I agree to terms" v-model="agreed" /> | |
``` | |
--- | |
@@ -261,9 +273,11 @@ Checkbox with custom styling and label. | |
## 🃏 Card Components | |
### ArticleCard | |
+ | |
Article display card with image and content. | |
**Variants:** | |
+ | |
- `primary`: Full content display | |
- `secondary`: Fixed height (182px) | |
- `simple`: Minimal version | |
@@ -280,6 +294,7 @@ Article display card with image and content. | |
``` | |
### ListCard & QuickLinkCard | |
+ | |
Specialized card components for different content types. | |
--- | |
@@ -287,7 +302,9 @@ Specialized card components for different content types. | |
## 🔄 Accordion Components | |
### AccordionGroup | |
+ | |
Collapsible content section. | |
+ | |
```vue | |
<AccordionGroup text="Section Title" :isOpen="false"> | |
<template #content> | |
@@ -301,12 +318,15 @@ Collapsible content section. | |
## 🏷️ Tag Components | |
### TagFilter | |
+ | |
Removable filter tag with close icon. | |
+ | |
```vue | |
<TagFilter text="Design" /> | |
``` | |
### TagMostra & TagScreening | |
+ | |
Specialized tag variants for different contexts. | |
--- | |
@@ -314,14 +334,11 @@ Specialized tag variants for different contexts. | |
## 🎯 Icon System | |
### BaseIcon | |
+ | |
Core icon component with gradient support. | |
+ | |
```vue | |
-<BaseIcon | |
- :width="24" | |
- :height="24" | |
- className="text-neutrals-900" | |
- :active="true" | |
-> | |
+<BaseIcon :width="24" :height="24" className="text-neutrals-900" :active="true"> | |
<!-- SVG content --> | |
</BaseIcon> | |
``` | |
@@ -329,15 +346,19 @@ Core icon component with gradient support. | |
### Icon Categories | |
**Actions** (4 icons): | |
+ | |
- IconClose, IconFilter, IconPlus, IconSearch | |
**Misc** (8 icons): | |
+ | |
- IconChange, IconClock, IconDash, IconInfo, IconLink, IconNewUser, IconPin, IconProgram | |
**Navigation** (4 icons): | |
+ | |
- IconCarretUp, IconChevronLeft, IconChevronRight, IconMenu | |
**Status** (1 icon): | |
+ | |
- IconCheck | |
--- | |
@@ -345,17 +366,20 @@ Core icon component with gradient support. | |
## 🔧 Usage Guidelines | |
### Component Props | |
+ | |
- Always include type validation | |
- Use validator functions for restricted values | |
- Provide sensible defaults | |
- Mark required props clearly | |
### CSS Classes | |
+ | |
- Use design system tokens consistently | |
- Prefer utility classes over custom CSS | |
- Follow spacing scale (100, 150, 200, 300, 400) | |
### Naming Conventions | |
+ | |
- Components: PascalCase (BaseButton) | |
- Props: camelCase (textColor) | |
- CSS classes: kebab-case (text-neutrals-900) | |
diff --git a/bun.lock b/bun.lock | |
index 7255478..dd2468c 100644 | |
--- a/bun.lock | |
+++ b/bun.lock | |
@@ -4,13 +4,25 @@ | |
"": { | |
"name": "riff-vue", | |
"dependencies": { | |
+ "@tanstack/vue-query": "^5.85.5", | |
+ "@vueuse/core": "^13.7.0", | |
"axios": "^1.11.0", | |
+ "class-variance-authority": "^0.7.1", | |
+ "clsx": "^2.1.1", | |
"fast-xml-parser": "^5.2.5", | |
+ "lodash-es": "^4.17.21", | |
+ "lucide-vue-next": "^0.540.0", | |
+ "reka-ui": "^2.4.1", | |
+ "tailwind-merge": "^3.3.1", | |
+ "tailwindcss-animate": "^1.0.7", | |
"vue": "^3.5.17", | |
+ "vue-i18n": "11", | |
+ "vue-router": "4", | |
}, | |
"devDependencies": { | |
"@eslint/js": "^9.32.0", | |
"@eslint/markdown": "^7.1.0", | |
+ "@types/node": "^24.3.0", | |
"@vitejs/plugin-vue": "^6.0.0", | |
"autoprefixer": "^10.4.21", | |
"eslint": "^9.32.0", | |
@@ -107,6 +119,14 @@ | |
"@eslint/plugin-kit": ["@eslint/[email protected]", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], | |
+ "@floating-ui/core": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], | |
+ | |
+ "@floating-ui/dom": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag=="], | |
+ | |
+ "@floating-ui/utils": ["@floating-ui/[email protected]", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], | |
+ | |
+ "@floating-ui/vue": ["@floating-ui/[email protected]", "", { "dependencies": { "@floating-ui/dom": "^1.7.3", "@floating-ui/utils": "^0.2.10", "vue-demi": ">=0.13.0" } }, "sha512-SNJAa1jbT8Gh1LvWw2uIIViLL0saV2bCY59ISCvJzhbut5DSb2H3LKUK49Xkd7SixTNHKX4LFu59nbwIXt9jjQ=="], | |
+ | |
"@humanfs/core": ["@humanfs/[email protected]", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], | |
"@humanfs/node": ["@humanfs/[email protected]", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], | |
@@ -115,6 +135,16 @@ | |
"@humanwhocodes/retry": ["@humanwhocodes/[email protected]", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], | |
+ "@internationalized/date": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA=="], | |
+ | |
+ "@internationalized/number": ["@internationalized/[email protected]", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-P+/h+RDaiX8EGt3shB9AYM1+QgkvHmJ5rKi4/59k4sg9g58k9rqsRW0WxRO7jCoHyvVbFRRFKmVTdFYdehrxHg=="], | |
+ | |
+ "@intlify/core-base": ["@intlify/[email protected]", "", { "dependencies": { "@intlify/message-compiler": "11.1.11", "@intlify/shared": "11.1.11" } }, "sha512-1Z0N8jTfkcD2Luq9HNZt+GmjpFe4/4PpZF3AOzoO1u5PTtSuXZcfhwBatywbfE2ieB/B5QHIoOFmCXY2jqVKEQ=="], | |
+ | |
+ "@intlify/message-compiler": ["@intlify/[email protected]", "", { "dependencies": { "@intlify/shared": "11.1.11", "source-map-js": "^1.0.2" } }, "sha512-7PC6neomoc/z7a8JRjPBbu0T2TzR2MQuY5kn2e049MP7+o32Ve7O8husylkA7K9fQRe4iNXZWTPnDJ6vZdtS1Q=="], | |
+ | |
+ "@intlify/shared": ["@intlify/[email protected]", "", {}, "sha512-RIBFTIqxZSsxUqlcyoR7iiC632bq7kkOwYvZlvcVObHfrF4NhuKc4FKvu8iPCrEO+e3XsY7/UVpfgzg+M7ETzA=="], | |
+ | |
"@isaacs/cliui": ["@isaacs/[email protected]", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], | |
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], | |
@@ -175,6 +205,18 @@ | |
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg=="], | |
+ "@swc/helpers": ["@swc/[email protected]", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], | |
+ | |
+ "@tanstack/match-sorter-utils": ["@tanstack/[email protected]", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="], | |
+ | |
+ "@tanstack/query-core": ["@tanstack/[email protected]", "", {}, "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w=="], | |
+ | |
+ "@tanstack/virtual-core": ["@tanstack/[email protected]", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="], | |
+ | |
+ "@tanstack/vue-query": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/query-core": "5.85.5", "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "@vue/composition-api": "^1.1.2", "vue": "^2.6.0 || ^3.3.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-f2gT08SakfnyDGW5bgwsyjqnl2pgacvNWIpyj9UchjTo1JAEYMpMBT26TzhYgRL6il2wnunxnii7DHk1Kcj9Og=="], | |
+ | |
+ "@tanstack/vue-virtual": ["@tanstack/[email protected]", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww=="], | |
+ | |
"@types/chai": ["@types/[email protected]", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], | |
"@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], | |
@@ -189,8 +231,12 @@ | |
"@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], | |
+ "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="], | |
+ | |
"@types/unist": ["@types/[email protected]", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], | |
+ "@types/web-bluetooth": ["@types/[email protected]", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], | |
+ | |
"@vitejs/plugin-vue": ["@vitejs/[email protected]", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.29" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vue": "^3.2.25" } }, "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw=="], | |
"@vitest/expect": ["@vitest/[email protected]", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], | |
@@ -215,6 +261,8 @@ | |
"@vue/compiler-ssr": ["@vue/[email protected]", "", { "dependencies": { "@vue/compiler-dom": "3.5.18", "@vue/shared": "3.5.18" } }, "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g=="], | |
+ "@vue/devtools-api": ["@vue/[email protected]", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], | |
+ | |
"@vue/reactivity": ["@vue/[email protected]", "", { "dependencies": { "@vue/shared": "3.5.18" } }, "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg=="], | |
"@vue/runtime-core": ["@vue/[email protected]", "", { "dependencies": { "@vue/reactivity": "3.5.18", "@vue/shared": "3.5.18" } }, "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w=="], | |
@@ -225,6 +273,12 @@ | |
"@vue/shared": ["@vue/[email protected]", "", {}, "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA=="], | |
+ "@vueuse/core": ["@vueuse/[email protected]", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.7.0", "@vueuse/shared": "13.7.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg=="], | |
+ | |
+ "@vueuse/metadata": ["@vueuse/[email protected]", "", {}, "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg=="], | |
+ | |
+ "@vueuse/shared": ["@vueuse/[email protected]", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg=="], | |
+ | |
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], | |
"acorn-jsx": ["[email protected]", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], | |
@@ -243,6 +297,8 @@ | |
"argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], | |
+ "aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], | |
+ | |
"assertion-error": ["[email protected]", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], | |
"asynckit": ["[email protected]", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], | |
@@ -285,6 +341,10 @@ | |
"chokidar": ["[email protected]", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], | |
+ "class-variance-authority": ["[email protected]", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], | |
+ | |
+ "clsx": ["[email protected]", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], | |
+ | |
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], | |
"color-name": ["[email protected]", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], | |
@@ -309,6 +369,8 @@ | |
"deep-is": ["[email protected]", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], | |
+ "defu": ["[email protected]", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], | |
+ | |
"delayed-stream": ["[email protected]", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], | |
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], | |
@@ -473,6 +535,8 @@ | |
"locate-path": ["[email protected]", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], | |
+ "lodash-es": ["[email protected]", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], | |
+ | |
"lodash.merge": ["[email protected]", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], | |
"longest-streak": ["[email protected]", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], | |
@@ -481,6 +545,8 @@ | |
"lru-cache": ["[email protected]", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], | |
+ "lucide-vue-next": ["[email protected]", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-H7qhKVNKLyoFMo05pWcGSWBiLPiI3zJmWV65SuXWHlrIGIcvDer10xAyWcRJ0KLzIH5k5+yi7AGw/Xi1VF8Pbw=="], | |
+ | |
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], | |
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], | |
@@ -601,6 +667,8 @@ | |
"object-hash": ["[email protected]", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], | |
+ "ohash": ["[email protected]", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], | |
+ | |
"optionator": ["[email protected]", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], | |
"p-limit": ["[email protected]", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], | |
@@ -659,6 +727,10 @@ | |
"readdirp": ["[email protected]", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], | |
+ "reka-ui": ["[email protected]", "", { "dependencies": { "@floating-ui/dom": "^1.6.13", "@floating-ui/vue": "^1.1.6", "@internationalized/date": "^3.5.0", "@internationalized/number": "^3.5.0", "@tanstack/vue-virtual": "^3.12.0", "@vueuse/core": "^12.5.0", "@vueuse/shared": "^12.5.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "ohash": "^2.0.11" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-NB7DrCsODN8MH02BWtgiExygfFcuuZ5/PTn6fMgjppmFHqePvNhmSn1LEuF35nel6PFbA4v+gdj0IoGN1yZ+vw=="], | |
+ | |
+ "remove-accents": ["[email protected]", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="], | |
+ | |
"resolve": ["[email protected]", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], | |
"resolve-from": ["[email protected]", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], | |
@@ -705,8 +777,12 @@ | |
"supports-preserve-symlinks-flag": ["[email protected]", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], | |
+ "tailwind-merge": ["[email protected]", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], | |
+ | |
"tailwindcss": ["[email protected]", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], | |
+ "tailwindcss-animate": ["[email protected]", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], | |
+ | |
"thenify": ["[email protected]", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], | |
"thenify-all": ["[email protected]", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], | |
@@ -727,8 +803,12 @@ | |
"ts-interface-checker": ["[email protected]", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], | |
+ "tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], | |
+ | |
"type-check": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], | |
+ "undici-types": ["[email protected]", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], | |
+ | |
"unist-util-is": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], | |
"unist-util-stringify-position": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], | |
@@ -751,8 +831,14 @@ | |
"vue": ["[email protected]", "", { "dependencies": { "@vue/compiler-dom": "3.5.18", "@vue/compiler-sfc": "3.5.18", "@vue/runtime-dom": "3.5.18", "@vue/server-renderer": "3.5.18", "@vue/shared": "3.5.18" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA=="], | |
+ "vue-demi": ["[email protected]", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="], | |
+ | |
"vue-eslint-parser": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw=="], | |
+ "vue-i18n": ["[email protected]", "", { "dependencies": { "@intlify/core-base": "11.1.11", "@intlify/shared": "11.1.11", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA=="], | |
+ | |
+ "vue-router": ["[email protected]", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="], | |
+ | |
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], | |
"why-is-node-running": ["[email protected]", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], | |
@@ -797,6 +883,10 @@ | |
"readdirp/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], | |
+ "reka-ui/@vueuse/core": ["@vueuse/[email protected]", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="], | |
+ | |
+ "reka-ui/@vueuse/shared": ["@vueuse/[email protected]", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="], | |
+ | |
"string-width-cjs/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], | |
"string-width-cjs/strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], | |
@@ -811,6 +901,8 @@ | |
"glob/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], | |
+ "reka-ui/@vueuse/core/@vueuse/metadata": ["@vueuse/[email protected]", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="], | |
+ | |
"string-width-cjs/strip-ansi/ansi-regex": ["[email protected]", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], | |
"wrap-ansi-cjs/string-width/emoji-regex": ["[email protected]", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], | |
diff --git a/components.json b/components.json | |
new file mode 100644 | |
index 0000000..9aeed83 | |
--- /dev/null | |
+++ b/components.json | |
@@ -0,0 +1,21 @@ | |
+{ | |
+ "$schema": "https://shadcn-vue.com/schema.json", | |
+ "style": "new-york", | |
+ "typescript": false, | |
+ "tailwind": { | |
+ "config": "tailwind.config.js", | |
+ "css": "src/style.css", | |
+ "baseColor": "neutral", | |
+ "cssVariables": true, | |
+ "prefix": "" | |
+ }, | |
+ "aliases": { | |
+ "components": "@/components", | |
+ "composables": "@/composables", | |
+ "utils": "@/lib/utils", | |
+ "ui": "@/components/ui", | |
+ "common": "@/components/common", | |
+ "lib": "@/lib" | |
+ }, | |
+ "iconLibrary": "lucide" | |
+} | |
diff --git a/design-tokens/color-primitives.json b/design-tokens/color-primitives.json | |
index 0b9907d..4b220aa 100644 | |
--- a/design-tokens/color-primitives.json | |
+++ b/design-tokens/color-primitives.json | |
@@ -80,5 +80,45 @@ | |
"azul": { | |
"400": "#94BBE2", | |
"600": "#1881F1" | |
+ }, | |
+ "background": "hsl(var(--background))", | |
+ "foreground": "hsl(var(--foreground))", | |
+ "card": { | |
+ "DEFAULT": "hsl(var(--card))", | |
+ "foreground": "hsl(var(--card-foreground))" | |
+ }, | |
+ "popover": { | |
+ "DEFAULT": "hsl(var(--popover))", | |
+ "foreground": "hsl(var(--popover-foreground))" | |
+ }, | |
+ "primary": { | |
+ "DEFAULT": "hsl(var(--primary))", | |
+ "foreground": "hsl(var(--primary-foreground))" | |
+ }, | |
+ "secondary": { | |
+ "DEFAULT": "hsl(var(--secondary))", | |
+ "foreground": "hsl(var(--secondary-foreground))" | |
+ }, | |
+ "muted": { | |
+ "DEFAULT": "hsl(var(--muted))", | |
+ "foreground": "hsl(var(--muted-foreground))" | |
+ }, | |
+ "accent": { | |
+ "DEFAULT": "hsl(var(--accent))", | |
+ "foreground": "hsl(var(--accent-foreground))" | |
+ }, | |
+ "destructive": { | |
+ "DEFAULT": "hsl(var(--destructive))", | |
+ "foreground": "hsl(var(--destructive-foreground))" | |
+ }, | |
+ "border": "hsl(var(--border))", | |
+ "input": "hsl(var(--input))", | |
+ "ring": "hsl(var(--ring))", | |
+ "chart": { | |
+ "1": "hsl(var(--chart-1))", | |
+ "2": "hsl(var(--chart-2))", | |
+ "3": "hsl(var(--chart-3))", | |
+ "4": "hsl(var(--chart-4))", | |
+ "5": "hsl(var(--chart-5))" | |
} | |
} | |
diff --git a/design-tokens/color-tokens.json b/design-tokens/color-tokens.json | |
index a5fc5f3..91aaa8d 100644 | |
--- a/design-tokens/color-tokens.json | |
+++ b/design-tokens/color-tokens.json | |
@@ -1,12 +1,12 @@ | |
{ | |
"background": { | |
"brand": { | |
- "primary":"{laranja.600}", | |
- "primary-hover":"{laranja.400}", | |
- "secondary":"{magenta.600}", | |
- "secondary-hover":"{magenta.400}", | |
- "tertiary":"{amarelo.600}", | |
- "tertiary-hover":"{amarelo.400}" | |
+ "primary": "{laranja.600}", | |
+ "primary-hover": "{laranja.400}", | |
+ "secondary": "{magenta.600}", | |
+ "secondary-hover": "{magenta.400}", | |
+ "tertiary": "{amarelo.600}", | |
+ "tertiary-hover": "{amarelo.400}" | |
}, | |
"neutral": { | |
"primary": "{neutrals.100}", | |
diff --git a/eslint.config.js b/eslint.config.js | |
index d07088b..23c8609 100644 | |
--- a/eslint.config.js | |
+++ b/eslint.config.js | |
@@ -5,17 +5,12 @@ import markdown from "@eslint/markdown"; | |
import { defineConfig } from "eslint/config"; | |
export default defineConfig([ | |
+ js.configs.recommended, | |
+ ...pluginVue.configs["flat/essential"], // ✅ spread array here safely | |
{ | |
- files: ["**/*.{js,mjs,cjs,vue}"], | |
- plugins: { js }, | |
- extends: ["js/recommended"], | |
- languageOptions: { globals: globals.browser }, | |
- }, | |
- pluginVue.configs["flat/essential"], | |
- { | |
- files: ["**/*.md"], | |
- plugins: { markdown }, | |
- language: "markdown/gfm", | |
- extends: ["markdown/recommended"], | |
+ files: ["**/*.vue"], | |
+ rules: { "vue/multi-word-component-names": "off" }, // ✅ override after spreading | |
}, | |
+ markdown.configs.recommended, | |
+ { languageOptions: { globals: globals.browser } }, | |
]); | |
diff --git a/jsconfig.json b/jsconfig.json | |
new file mode 100644 | |
index 0000000..b0db63d | |
--- /dev/null | |
+++ b/jsconfig.json | |
@@ -0,0 +1,9 @@ | |
+{ | |
+ "compilerOptions": { | |
+ "baseUrl": ".", | |
+ "paths": { | |
+ "@/*": ["src/*"] | |
+ } | |
+ }, | |
+ "include": ["src"] | |
+} | |
diff --git a/package.json b/package.json | |
index 386b1fb..93f364c 100644 | |
--- a/package.json | |
+++ b/package.json | |
@@ -8,18 +8,31 @@ | |
"build": "vite build", | |
"preview": "vite preview", | |
"format": "prettier . --write", | |
+ "pretty": "prettier . --check", | |
"lint": "eslint src --ext .vue,.js,.ts", | |
"lint:fix": "eslint src --ext .vue,.js,.ts --fix", | |
"test": "vitest" | |
}, | |
"dependencies": { | |
+ "@tanstack/vue-query": "^5.85.5", | |
+ "@vueuse/core": "^13.7.0", | |
"axios": "^1.11.0", | |
+ "class-variance-authority": "^0.7.1", | |
+ "clsx": "^2.1.1", | |
"fast-xml-parser": "^5.2.5", | |
- "vue": "^3.5.17" | |
+ "lodash-es": "^4.17.21", | |
+ "lucide-vue-next": "^0.540.0", | |
+ "reka-ui": "^2.4.1", | |
+ "tailwind-merge": "^3.3.1", | |
+ "tailwindcss-animate": "^1.0.7", | |
+ "vue": "^3.5.17", | |
+ "vue-i18n": "11", | |
+ "vue-router": "4" | |
}, | |
"devDependencies": { | |
"@eslint/js": "^9.32.0", | |
"@eslint/markdown": "^7.1.0", | |
+ "@types/node": "^24.3.0", | |
"@vitejs/plugin-vue": "^6.0.0", | |
"autoprefixer": "^10.4.21", | |
"eslint": "^9.32.0", | |
diff --git a/postcss.config.js b/postcss.config.js | |
index 2e7af2b..2aa7205 100644 | |
--- a/postcss.config.js | |
+++ b/postcss.config.js | |
@@ -3,4 +3,4 @@ export default { | |
tailwindcss: {}, | |
autoprefixer: {}, | |
}, | |
-} | |
+}; | |
diff --git a/public/poc-poster.jpg b/public/poc-poster.jpg | |
new file mode 100644 | |
index 0000000..29c6f7c | |
Binary files /dev/null and b/public/poc-poster.jpg differ | |
diff --git a/src/App.vue b/src/App.vue | |
index 16a73eb..07064ea 100644 | |
--- a/src/App.vue | |
+++ b/src/App.vue | |
@@ -1,243 +1,19 @@ | |
<script setup> | |
-import SponsorHeader from "./components/layout/headers/SponsorHeader.vue"; | |
-import NavbarMain from "./components/layout/navbar/NavbarMain.vue"; | |
-import NavbarSecundary from "./components/layout/navbar/NavbarSecundary.vue"; | |
-import { IconCheck } from "@/components/ui/icons"; | |
-import { IconSearch } from "@/components/ui/icons"; | |
-import { IconDash } from "@/components/ui/icons"; | |
-import { IconCarretUp } from "@/components/ui/icons"; | |
-import { IconInfo } from "@/components/ui/icons"; | |
-import { IconPin } from "@/components/ui/icons"; | |
-import { IconChange } from "@/components/ui/icons"; | |
-import { IconNewUser } from "@/components/ui/icons"; | |
-import { IconClose } from "@/components/ui/icons"; | |
-import { IconClock } from "@/components/ui/icons"; | |
-import { IconChevronLeft } from "@/components/ui/icons"; | |
-import { IconChevronRight } from "@/components/ui/icons"; | |
-import { IconLink } from "@/components/ui/icons"; | |
-import { IconFilter } from "@/components/ui/icons"; | |
-import { IconPlus } from "@/components/ui/icons"; | |
-import { IconProgram } from "@/components/ui/icons"; | |
-import { IconMenu } from "@/components/ui/icons"; | |
-import TextInput from "./components/inputs/TextInput.vue"; | |
-import TagMostra from "@/components/ui/tags/TagMostra.vue"; | |
-import TagScreening from "@/components/ui/tags/TagScreening.vue"; | |
-import TagFilter from "./components/ui/tags/TagFilter.vue"; | |
-import QuickLinksSection from "./components/layout/sections/QuickLinksSection.vue"; | |
-import HomeBanner from "@/components/ui/HomeBanner.vue"; | |
-import SubHeading from "@/components/ui/typography/SubHeading.vue"; | |
-import BaseHeader from "@/components/ui/typography/BaseHeader.vue"; | |
-import TwContainer from "@/components/layout/TwContainer.vue"; | |
-import ButtonText from "@/components/ui/buttons/ButtonText.vue"; | |
-import ArticleCard from "./components/ui/cards/ArticleCard.vue"; | |
-import { ref } from "vue"; | |
-import CheckboxInput from "@/components/inputs/CheckboxInput.vue"; | |
-import ContextMenu from "@/components/layout/navbar/ContextMenu.vue"; | |
-import MovieCard from "./components/ui/cards/MovieCard.vue"; | |
-const quickLinks = [ | |
- { | |
- id: 1, | |
- title: 'PROGRAMAÇÃO', | |
- description: 'Veja a programação completa ou filtre de acordo com o que deseja.', | |
- href: '/programacao' | |
- }, | |
- { | |
- id: 2, | |
- title: 'INGRESSOS', | |
- description: 'Descubra como garantir sua entrada nos cinemas e eventos.', | |
- href: '/filmes' | |
- }, | |
- { | |
- id: 3, | |
- title: 'MUDANÇAS NA PROGRAMAÇÃO', | |
- description: 'Planeje-se verificando as mudanças na programação.', | |
- href: '/mudancas' | |
- } | |
-] | |
- | |
-const inputValue = ref('Flamengo') | |
-const inputCheckboxValue = ref(false) | |
+import SponsorHeader from "@/components/layout/headers/SponsorHeader.vue"; | |
+import NavbarMain from "@/components/layout/navbar/NavbarMain.vue"; | |
+import NavbarSecundary from "@/components/layout/navbar/NavbarSecondary.vue"; | |
+import { useI18n } from "@/composables/useI18n"; | |
+const { locale } = useI18n(); | |
</script> | |
<template> | |
- <SponsorHeader class="bg-azul-400" /> | |
- <NavbarMain /> | |
- <NavbarSecundary /> | |
- <ContextMenu /> | |
- <HomeBanner | |
- imagePath="/src/assets/images/mobile-banner.png" | |
- alt="Banner promocional" | |
- > | |
- <BaseHeader | |
- font-size="text-2xl lg:text-3xl" | |
- class="mb-200" | |
- > | |
- A 26ª edição do Festival do Rio vem aí! | |
- </BaseHeader> | |
- <SubHeading> | |
- De 2 a 12 de outubro o cinema estará sob a luz do Rio | |
- </SubHeading> | |
- </HomeBanner> | |
- | |
- <QuickLinksSection v-bind:links="quickLinks" /> | |
- | |
- <TwContainer> | |
- <MovieCard | |
- title="O Quarto ao Lado" | |
- country="Espanha" | |
- category="FIC" | |
- duration="114’" | |
- cinema="Cine Odeon - CCLSR - Centro" | |
- :times="['21H30', '23H15']" | |
- tag="GALA DE ABERTURA" | |
- image="https://leiturafilmica.com.br/wp-content/uploads/2018/09/saneamento-basico-o-filme-1024x575.png" | |
- /> | |
- </TwContainer> | |
- | |
- <TwContainer> | |
- <div class="py-1200"> | |
- <div class="flex flex-col gap-y-800"> | |
- <BaseHeader font-size="text-3xl">Últimas notícias</BaseHeader> | |
- | |
- <div class="flex flex-col gap-y-800 lg:grid lg:gap-x-800 lg:grid-cols-[2fr_1fr]"> | |
- <ArticleCard | |
- variant="primary" | |
- background-image="../src/assets/images/noticia-one.png" | |
- heightClass="h-[589px]" | |
- title="Pedaço de Mim, de Anne-Sophie Bailly, e Apocalipse nos Trópicos, de Petra Costa, estão entre as principais estreias da semana" | |
- content="Os longas-metragens têm em comum a direção de cineastas mulheres e a presença destacada na programação do 26º Festival do Rio (2024)." | |
- date="22.07.2025" | |
- category="estreias da semana" | |
- /> | |
- <ArticleCard | |
- variant="primary" | |
- background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/507dcb8456c3c6939126d891a11725d5.jpeg" | |
- heightClass="h-[472px]" | |
- title="Festival do Rio na Bienal do Livro: O Auto da Compadecida será exibido na feira literária carioca" | |
- content="Longa que adaptada célebre obra do escritor Ariano Suassuna terá uma projeção especial na Praça Além da Página Shell, nesta segunda-feira (16). A sessão é promovida pelo Festival do Rio em parceria com a Bienal do Livro, com apoio da Globo Filmes" | |
- date="22.07.2025" | |
- category="festival do rio" | |
- /> | |
- </div> | |
- | |
- <!-- secundaria --> | |
- <div class="flex flex-col gap-y-800 lg:grid lg:gap-x-800 lg:grid-cols-4 "> | |
- <ArticleCard | |
- variant="secondary" | |
- background-image="../src/assets/images/noticia-two.png" | |
- title="Talents Rio 2025: Projeto Paradiso renova apoio ao programa de formação de profissionais do audiovisual" | |
- date="22.07.2025" | |
- category="talents rio" | |
- /> | |
- <ArticleCard | |
- variant="secondary" | |
- background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/77ebda55831201b57c8b342db86e65a6.jpeg" | |
- title="Dia Nacional do Documentário Brasileiro: conheça todos os vencedores do Troféu Redentor de melhor documentário no Festival do Rio" | |
- date="22.05.2025" | |
- category="premiere brasil" | |
- /> | |
- <ArticleCard | |
- variant="secondary" | |
- background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/c15a563d6d839971cc16c34aef3cf32e.jpg" | |
- title="Festival do Rio celebra os vencedores do Prêmio Grande Otelo 2025" | |
- date="12.04.2025" | |
- category="festival do rio" | |
- /> | |
- <ArticleCard | |
- variant="secondary" | |
- background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/21dc8f4c81fbb402b1725cb997d3a1ea.jpg" | |
- title="Festival do Rio indica: 15 filmes românticos disponíveis no streaming para assistir no Dia dos Namorados" | |
- date="22.03.2025" | |
- category="festival do rio" | |
- /> | |
- </div> | |
- | |
- <ButtonText tag="a" text="Ver mais" variant="dark" size="md" class="self-center hover:text-neutrals-700"> | |
- <template v-slot:icon> | |
- <IconPlus class="me-100" /> | |
- </template> | |
- </ButtonText> | |
- | |
- </div> | |
- | |
- </div> | |
- | |
- </TwContainer> | |
- | |
- <TwContainer> | |
- <p>{{ inputValue }}</p> | |
- <p>Checkbox: {{ inputCheckboxValue }}</p> | |
- <TextInput id="flamengo" placeholder="Your email" v-model:value="inputValue"/> | |
- <TextInput id="flamengo" placeholder="Your email"/> | |
- <TextInput disabled='true' id="flamengo" placeholder="Your email"/> | |
- <div class="flex space-x-200"> | |
- <CheckboxInput v-model="inputCheckboxValue" label="Legenda" id="form-12"/> | |
- <CheckboxInput label="Legenda" id="form-13" :disabled="true"/> | |
- </div> | |
- </TwContainer> | |
- <TwContainer> | |
- <div class="flex justify-center space-x-200 mb-600"> | |
- <IconClock /> | |
- <IconClock active="true" /> | |
- <IconProgram /> | |
- <IconProgram active="true" /> | |
- <IconNewUser /> | |
- <IconNewUser active="true" /> | |
- <IconChange /> | |
- <IconChange active="true" /> | |
- <IconLink /> | |
- <IconLink active="true" /> | |
- <IconChevronRight /> | |
- <IconChevronLeft /> | |
- <IconMenu /> | |
- <IconClose /> | |
- <IconPin /> | |
- <IconSearch /> | |
- <IconPlus /> | |
- <IconFilter /> | |
- <IconInfo /> | |
- <IconCheck /> | |
- <IconDash /> | |
- <IconCarretUp /> | |
- </div> | |
- <div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-4"> | |
- <TagMostra mode="filled" variant="gala-abertura" text="Gala de Abertura" /> | |
- <TagMostra mode="outline" variant="gala-abertura" text="Gala de Abertura" /> | |
- <TagMostra mode="filled" variant="gala-encerramento" text="Gala de Encerramento" /> | |
- <TagMostra mode="outline" variant="gala-encerramento" text="Gala de Encerramento" /> | |
- <TagMostra mode="filled" variant="cine-memoria" text="CinE MemóRIA" /> | |
- <TagMostra mode="outline" variant="cine-memoria" text="CinE MemóRIA" /> | |
- <TagMostra mode="filled" variant="midnight-movies" text="Midnight movies" /> | |
- <TagMostra mode="outline" variant="midnight-movies" text="Midnight movies" /> | |
- <TagMostra mode="filled" variant="expectativa" text="Expectativa" /> | |
- <TagMostra mode="outline" variant="expectativa" text="Expectativa" /> | |
- <TagMostra mode="filled" variant="premiere-brasil" text="Premiere-brasil" /> | |
- <TagMostra mode="outline" variant="premiere-brasil" text="Premiere-brasil" /> | |
- <TagMostra mode="filled" variant="premiere-latina" text="Premiere-latina" /> | |
- <TagMostra mode="outline" variant="premiere-latina" text="Premiere-latina" /> | |
- <TagMostra mode="filled" variant="resistencias" text="Resistencias" /> | |
- <TagMostra mode="outline" variant="resistencias" text="Resistencias" /> | |
- <TagMostra mode="filled" variant="itinerarios" text="Itinerarios" /> | |
- <TagMostra mode="outline" variant="itinerarios" text="Itinerarios" /> | |
- <TagMostra mode="filled" variant="classicos-cults" text="Classicos-cults" /> | |
- <TagMostra mode="outline" variant="classicos-cults" text="Classicos-cults" /> | |
- <TagMostra mode="filled" variant="panorama" text="Panorama" /> | |
- <TagMostra mode="outline" variant="panorama" text="Panorama" /> | |
- <TagMostra mode="filled" variant="cinema-capacete" text="Cinema-capacete" /> | |
- <TagMostra mode="outline" variant="cinema-capacete" text="Cinema-capacete" /> | |
- <TagScreening time="19h30" state="default"/> | |
- <TagScreening time="19h30" state="active"/> | |
- <TagScreening time="19h30" state="disabled"/> | |
- <TagFilter text="Legenda" /> | |
- </div> | |
- </TWContainer> | |
- | |
- | |
- <div class="grid grid-cols-3 gap-300"> | |
- <!-- <ListCard v-for="movie in movies" :key="movie.id" :movie /> --> | |
+ <div :lang="locale"> | |
+ <SponsorHeader class="bg-azul-400" /> | |
+ <NavbarMain /> | |
+ <NavbarSecundary /> | |
+ <RouterView /> | |
</div> | |
- | |
</template> | |
<style scoped></style> | |
diff --git a/src/assets/css/animations.css b/src/assets/css/animations.css | |
new file mode 100644 | |
index 0000000..5880885 | |
--- /dev/null | |
+++ b/src/assets/css/animations.css | |
@@ -0,0 +1,9 @@ | |
+/* transition vue component animation classes */ | |
+.slide-left-enter-active, | |
+.slide-left-leave-active { | |
+ transition: transform 0.3s ease; | |
+} | |
+.slide-left-enter-from, | |
+.slide-left-leave-to { | |
+ transform: translateX(-100%); | |
+} | |
diff --git a/src/assets/css/typography.css b/src/assets/css/typography.css | |
new file mode 100644 | |
index 0000000..756dfe5 | |
--- /dev/null | |
+++ b/src/assets/css/typography.css | |
@@ -0,0 +1,94 @@ | |
+/* Typography System - Evil Martians Style */ | |
+/* src/assets/typography.css */ | |
+ | |
+@tailwind base; | |
+@tailwind components; | |
+@tailwind utilities; | |
+ | |
+@layer components { | |
+ /* | |
+ * TYPOGRAPHY STYLES - Pure typography properties only | |
+ * No colors, no layout - just font, size, weight, line-height | |
+ */ | |
+ | |
+ /* Body text variants */ | |
+ .text-body-regular { | |
+ @apply font-body text-sm font-regular leading-[150%]; | |
+ } | |
+ | |
+ .text-body-strong-xs { | |
+ @apply font-body text-xs font-semibold leading-[140%]; | |
+ } | |
+ | |
+ .text-body-strong-sm { | |
+ @apply font-body text-sm font-semibold leading-[140%]; | |
+ } | |
+ | |
+ .text-subheading { | |
+ @apply font-body text-xl font-regular leading-[30px]; | |
+ } | |
+ | |
+ /* Header variants */ | |
+ .text-header-base { | |
+ @apply font-heading font-semibold leading-[120%]; | |
+ } | |
+ | |
+ .text-header-sm { | |
+ @apply font-heading text-lg font-semibold leading-[120%]; | |
+ } | |
+ | |
+ .text-header-md { | |
+ @apply font-heading text-xl font-semibold leading-[120%]; | |
+ } | |
+ | |
+ .text-section-header { | |
+ @apply font-heading text-lg font-semibold leading-[28.8px]; | |
+ } | |
+ | |
+ /* Special typography */ | |
+ .text-overline { | |
+ @apply font-body text-2xs font-medium leading-[160%] tracking-widest uppercase; | |
+ } | |
+ | |
+ /* | |
+ * SEMANTIC COLOR UTILITIES - Only colors | |
+ * These can be mixed with any typography class above | |
+ */ | |
+ .text-primary { | |
+ @apply text-neutrals-900; | |
+ } | |
+ | |
+ .text-secondary-gray { | |
+ @apply text-neutrals-700; | |
+ } | |
+ | |
+ .text-muted { | |
+ @apply text-neutrals-600; | |
+ } | |
+ | |
+ .text-on-dark { | |
+ @apply text-white-transp-1000; | |
+ } | |
+ | |
+ .text-on-dark-secondary { | |
+ @apply text-white-transp-900; | |
+ } | |
+} | |
+ | |
+/* | |
+ * OPTIONAL: Preset combinations for common patterns | |
+ * Only create these if you use the same combination 5+ times | |
+ */ | |
+@layer components { | |
+ .text-movie-title { | |
+ @apply text-header-sm text-on-dark; | |
+ } | |
+ | |
+ .text-movie-meta { | |
+ @apply text-overline text-on-dark-secondary; | |
+ } | |
+ | |
+ .text-cinema-info { | |
+ @apply text-body-regular text-secondary; | |
+ } | |
+} | |
diff --git a/src/assets/Vector.svg b/src/assets/icons/Vector.svg | |
similarity index 100% | |
rename from src/assets/Vector.svg | |
rename to src/assets/icons/Vector.svg | |
diff --git a/src/assets/divisor.svg b/src/assets/icons/divisor.svg | |
similarity index 100% | |
rename from src/assets/divisor.svg | |
rename to src/assets/icons/divisor.svg | |
diff --git a/src/assets/icons/filter.svg b/src/assets/icons/filter.svg | |
new file mode 100644 | |
index 0000000..aee5548 | |
--- /dev/null | |
+++ b/src/assets/icons/filter.svg | |
@@ -0,0 +1,3 @@ | |
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"> | |
+ <path d="M6.71429 14.2857C6.71429 14.0963 6.78954 13.9146 6.9235 13.7806C7.05745 13.6467 7.23913 13.5714 7.42857 13.5714H11.7143C11.9037 13.5714 12.0854 13.6467 12.2194 13.7806C12.3533 13.9146 12.4286 14.0963 12.4286 14.2857C12.4286 14.4752 12.3533 14.6568 12.2194 14.7908C12.0854 14.9247 11.9037 15 11.7143 15H7.42857C7.23913 15 7.05745 14.9247 6.9235 14.7908C6.78954 14.6568 6.71429 14.4752 6.71429 14.2857ZM3.85714 10C3.85714 9.81056 3.9324 9.62888 4.06635 9.49492C4.20031 9.36097 4.38199 9.28571 4.57143 9.28571H14.5714C14.7609 9.28571 14.9426 9.36097 15.0765 9.49492C15.2105 9.62888 15.2857 9.81056 15.2857 10C15.2857 10.1894 15.2105 10.3711 15.0765 10.5051C14.9426 10.639 14.7609 10.7143 14.5714 10.7143H4.57143C4.38199 10.7143 4.20031 10.639 4.06635 10.5051C3.9324 10.3711 3.85714 10.1894 3.85714 10ZM1 5.71429C1 5.52485 1.07526 5.34316 1.20921 5.20921C1.34316 5.07526 1.52485 5 1.71429 5H17.4286C17.618 5 17.7997 5.07526 17.9337 5.20921C18.0676 5.34316 18.1429 5.52485 18.1429 5.71429C18.1429 5.90373 18.0676 6.08541 17.9337 6.21936C17.7997 6.35332 17.618 6.42857 17.4286 6.42857H1.71429C1.52485 6.42857 1.34316 6.35332 1.20921 6.21936C1.07526 6.08541 1 5.90373 1 5.71429Z" fill="black"/> | |
+</svg> | |
diff --git a/src/assets/search-icon.svg b/src/assets/icons/search-icon.svg | |
similarity index 100% | |
rename from src/assets/search-icon.svg | |
rename to src/assets/icons/search-icon.svg | |
diff --git a/src/assets/festival-logo-mobile.svg b/src/assets/images/logos/festival-logo-mobile.svg | |
similarity index 100% | |
rename from src/assets/festival-logo-mobile.svg | |
rename to src/assets/images/logos/festival-logo-mobile.svg | |
diff --git a/src/assets/festival-logo.svg b/src/assets/images/logos/festival-logo.svg | |
similarity index 100% | |
rename from src/assets/festival-logo.svg | |
rename to src/assets/images/logos/festival-logo.svg | |
diff --git a/src/components/app/HelloWorld.vue b/src/components/app/HelloWorld.vue | |
index 56b8cb9..323fabf 100644 | |
--- a/src/components/app/HelloWorld.vue | |
+++ b/src/components/app/HelloWorld.vue | |
@@ -1,5 +1,5 @@ | |
<script setup lang="ts"> | |
-import BaseButton from './buttons/BaseButton.vue'; | |
+import { BaseButton } from "@/components/common/buttons"; | |
</script> | |
<template> | |
@@ -7,23 +7,27 @@ import BaseButton from './buttons/BaseButton.vue'; | |
<div class="container mx-auto sm:max-w-full md:max-w-4xl"> | |
<div class="flex flex-col gap-12"> | |
<div class="flex flex-col gap-6"> | |
- <h2 class="font-[family-name:var(--header-font)] text-[40px] font-semibold leading-[48px]">Retrospectiva 2024</h2> | |
+ <h2 | |
+ class="font-[family-name:var(--header-font)] text-[40px] font-semibold leading-[48px]" | |
+ > | |
+ Retrospectiva 2024 | |
+ </h2> | |
<div class="h-[280px] md:h-[769px] w-full bg-black rounded-2xl"></div> | |
</div> | |
<div class="flex shrink-0 items-center"> | |
- <p class="text-[32px] font-semibold leading-[38.4px] max-w-[360px]" | |
- style="text-shadow: 10px 10px 35px rgba(82, 81, 81, 0.10);">Receba nossos informativos e fique por dentro das novidades do Festival do Rio</p> | |
+ <p | |
+ class="text-[32px] font-semibold leading-[38.4px] max-w-[360px]" | |
+ style="text-shadow: 10px 10px 35px rgba(82, 81, 81, 0.1)" | |
+ > | |
+ Receba nossos informativos e fique por dentro das novidades do | |
+ Festival do Rio | |
+ </p> | |
<BaseButton text="RioMarket" /> | |
- | |
- | |
</div> | |
</div> | |
- | |
</div> | |
</div> | |
- <img src="../assets/pda-grad.svg" class="w-full" alt=""> | |
+ <img src="../assets/pda-grad.svg" class="w-full" alt="" /> | |
</template> | |
-<style scoped> | |
- | |
-</style> | |
+<style scoped></style> | |
diff --git a/src/components/app/TheLanguageSwitcher.vue b/src/components/app/TheLanguageSwitcher.vue | |
new file mode 100644 | |
index 0000000..207fa95 | |
--- /dev/null | |
+++ b/src/components/app/TheLanguageSwitcher.vue | |
@@ -0,0 +1,33 @@ | |
+<script setup> | |
+import { useLanguageSwitcher } from "@/components/app/useLanguageSwitcher"; | |
+const { availableLanguages, switchLanguage, currentLanguage } = | |
+ useLanguageSwitcher(); | |
+</script> | |
+ | |
+<template> | |
+ <div | |
+ class="languages flex items-center gap-400 text-neutrals-700" | |
+ aria-label="language selection" | |
+ > | |
+ <template v-for="language in availableLanguages" :key="language.code"> | |
+ <button | |
+ :class="[ | |
+ 'text-body-strong-sm uppercase', | |
+ { 'text-neutrals-900': language.code === currentLanguage }, | |
+ ]" | |
+ @click="switchLanguage(language.code)" | |
+ :aria-label="`Alterar para ${language.name}`" | |
+ :aria-pressed="language.code === currentLanguage" | |
+ :lang="language.code" | |
+ > | |
+ {{ language.code.toUpperCase() }} | |
+ </button> | |
+ <img | |
+ v-if="language.code !== availableLanguages.at(-1).code" | |
+ src="@assets/icons/divisor.svg" | |
+ alt="Divisor" | |
+ aria-hidden="true" | |
+ /> | |
+ </template> | |
+ </div> | |
+</template> | |
diff --git a/src/components/app/useLanguageSwitcher.js b/src/components/app/useLanguageSwitcher.js | |
new file mode 100644 | |
index 0000000..6128073 | |
--- /dev/null | |
+++ b/src/components/app/useLanguageSwitcher.js | |
@@ -0,0 +1,37 @@ | |
+import { ref, watch } from "vue"; | |
+import { useI18n } from "@/composables/useI18n"; | |
+ | |
+export function useLanguageSwitcher() { | |
+ const { locale } = useI18n(); | |
+ | |
+ const availableLanguages = [ | |
+ { code: "pt", name: "Português" }, | |
+ { code: "en", name: "English" }, | |
+ ]; | |
+ | |
+ const currentLanguage = ref( | |
+ localStorage.getItem("preferredLanguage") || "pt", | |
+ ); | |
+ | |
+ watch( | |
+ currentLanguage, | |
+ (newLang) => { | |
+ locale.value = newLang; | |
+ if (typeof window !== "undefined") { | |
+ localStorage.setItem("preferredLanguage", newLang); | |
+ } | |
+ }, | |
+ { immediate: true }, | |
+ ); | |
+ // locale.value = currentLanguage.value; | |
+ | |
+ const switchLanguage = (languageCode) => { | |
+ currentLanguage.value = languageCode; | |
+ }; | |
+ | |
+ return { | |
+ currentLanguage, | |
+ availableLanguages, | |
+ switchLanguage, | |
+ }; | |
+} | |
diff --git a/src/components/base/accordion/AccordionGroup.vue b/src/components/base/accordion/AccordionGroup.vue | |
new file mode 100644 | |
index 0000000..4b9d8f2 | |
--- /dev/null | |
+++ b/src/components/base/accordion/AccordionGroup.vue | |
@@ -0,0 +1,29 @@ | |
+<script setup> | |
+import IconCarretUp from "@/components/common/icons/navigation/IconCarretUp.vue"; | |
+ | |
+// import AccordionHeader from './AccordionHeader.vue'; | |
+const { text } = defineProps({ | |
+ text: { | |
+ type: String, | |
+ required: true, | |
+ }, | |
+ isOpen: { | |
+ type: Boolean, | |
+ default: false, | |
+ }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <details :open="isOpen"> | |
+ <summary | |
+ class="font-body font-semibold text-neutrals-900 leadgin-[19.6px] text-sm uppercase flex justify-between items-center pb-300 border-b hover:cursor-pointer" | |
+ > | |
+ {{ text }} | |
+ <IconCarretUp className="text-neutrals-900 accordion-icon" :width="16" /> | |
+ </summary> | |
+ <slot name="content"></slot> | |
+ </details> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/base/calendar/Calendar.vue b/src/components/base/calendar/Calendar.vue | |
new file mode 100644 | |
index 0000000..f3a7d82 | |
--- /dev/null | |
+++ b/src/components/base/calendar/Calendar.vue | |
@@ -0,0 +1,95 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarRoot, useForwardPropsEmits } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { | |
+ CalendarCell, | |
+ CalendarCellTrigger, | |
+ CalendarGrid, | |
+ CalendarGridBody, | |
+ CalendarGridHead, | |
+ CalendarGridRow, | |
+ CalendarHeadCell, | |
+ CalendarHeader, | |
+ CalendarHeading, | |
+ CalendarNextButton, | |
+ CalendarPrevButton, | |
+} from "."; | |
+ | |
+const props = defineProps({ | |
+ defaultValue: { type: null, required: false }, | |
+ defaultPlaceholder: { type: null, required: false }, | |
+ placeholder: { type: null, required: false }, | |
+ pagedNavigation: { type: Boolean, required: false }, | |
+ preventDeselect: { type: Boolean, required: false }, | |
+ weekStartsOn: { type: Number, required: false }, | |
+ weekdayFormat: { type: String, required: false }, | |
+ calendarLabel: { type: String, required: false }, | |
+ fixedWeeks: { type: Boolean, required: false }, | |
+ maxValue: { type: null, required: false }, | |
+ minValue: { type: null, required: false }, | |
+ locale: { type: String, required: false }, | |
+ numberOfMonths: { type: Number, required: false }, | |
+ disabled: { type: Boolean, required: false }, | |
+ readonly: { type: Boolean, required: false }, | |
+ initialFocus: { type: Boolean, required: false }, | |
+ isDateDisabled: { type: Function, required: false }, | |
+ isDateUnavailable: { type: Function, required: false }, | |
+ dir: { type: String, required: false }, | |
+ nextPage: { type: Function, required: false }, | |
+ prevPage: { type: Function, required: false }, | |
+ modelValue: { type: null, required: false }, | |
+ multiple: { type: Boolean, required: false }, | |
+ disableDaysOutsideCurrentView: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const emits = defineEmits(["update:modelValue", "update:placeholder"]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarRoot | |
+ v-slot="{ grid, weekDays }" | |
+ :class="cn('p-3', props.class)" | |
+ v-bind="forwarded" | |
+ > | |
+ <CalendarHeader> | |
+ <CalendarPrevButton /> | |
+ <CalendarHeading /> | |
+ <CalendarNextButton /> | |
+ </CalendarHeader> | |
+ | |
+ <div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0"> | |
+ <CalendarGrid v-for="month in grid" :key="month.value.toString()"> | |
+ <CalendarGridHead> | |
+ <CalendarGridRow> | |
+ <CalendarHeadCell v-for="day in weekDays" :key="day"> | |
+ {{ day }} | |
+ </CalendarHeadCell> | |
+ </CalendarGridRow> | |
+ </CalendarGridHead> | |
+ <CalendarGridBody> | |
+ <CalendarGridRow | |
+ v-for="(weekDates, index) in month.rows" | |
+ :key="`weekDate-${index}`" | |
+ class="mt-2 w-full" | |
+ > | |
+ <CalendarCell | |
+ v-for="weekDate in weekDates" | |
+ :key="weekDate.toString()" | |
+ :date="weekDate" | |
+ > | |
+ <CalendarCellTrigger :day="weekDate" :month="month.value" /> | |
+ </CalendarCell> | |
+ </CalendarGridRow> | |
+ </CalendarGridBody> | |
+ </CalendarGrid> | |
+ </div> | |
+ </CalendarRoot> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarCell.vue b/src/components/base/calendar/CalendarCell.vue | |
new file mode 100644 | |
index 0000000..939a3e6 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarCell.vue | |
@@ -0,0 +1,30 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarCell, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ date: { type: null, required: true }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarCell | |
+ :class=" | |
+ cn( | |
+ 'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', | |
+ props.class, | |
+ ) | |
+ " | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot /> | |
+ </CalendarCell> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarCellTrigger.vue b/src/components/base/calendar/CalendarCellTrigger.vue | |
new file mode 100644 | |
index 0000000..f26b32b | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarCellTrigger.vue | |
@@ -0,0 +1,42 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarCellTrigger, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { buttonVariants } from "@/components/common/buttons"; | |
+ | |
+const props = defineProps({ | |
+ day: { type: null, required: true }, | |
+ month: { type: null, required: true }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarCellTrigger | |
+ :class=" | |
+ cn( | |
+ buttonVariants({ variant: 'ghost' }), | |
+ 'h-8 w-8 p-0 font-normal', | |
+ '[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground', | |
+ // Selected | |
+ 'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground', | |
+ // Disabled | |
+ 'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50', | |
+ // Unavailable | |
+ 'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through', | |
+ // Outside months | |
+ 'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30', | |
+ props.class, | |
+ ) | |
+ " | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot /> | |
+ </CalendarCellTrigger> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarGrid.vue b/src/components/base/calendar/CalendarGrid.vue | |
new file mode 100644 | |
index 0000000..7765028 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarGrid.vue | |
@@ -0,0 +1,24 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarGrid, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarGrid | |
+ :class="cn('w-full border-collapse space-y-1', props.class)" | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot /> | |
+ </CalendarGrid> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarGridBody.vue b/src/components/base/calendar/CalendarGridBody.vue | |
new file mode 100644 | |
index 0000000..ee55203 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarGridBody.vue | |
@@ -0,0 +1,14 @@ | |
+<script setup> | |
+import { CalendarGridBody } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarGridBody v-bind="props"> | |
+ <slot /> | |
+ </CalendarGridBody> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarGridHead.vue b/src/components/base/calendar/CalendarGridHead.vue | |
new file mode 100644 | |
index 0000000..c16093c | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarGridHead.vue | |
@@ -0,0 +1,15 @@ | |
+<script setup> | |
+import { CalendarGridHead } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarGridHead v-bind="props"> | |
+ <slot /> | |
+ </CalendarGridHead> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarGridRow.vue b/src/components/base/calendar/CalendarGridRow.vue | |
new file mode 100644 | |
index 0000000..07af044 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarGridRow.vue | |
@@ -0,0 +1,21 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarGridRow, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps"> | |
+ <slot /> | |
+ </CalendarGridRow> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarHeadCell.vue b/src/components/base/calendar/CalendarHeadCell.vue | |
new file mode 100644 | |
index 0000000..fade3e0 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarHeadCell.vue | |
@@ -0,0 +1,29 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarHeadCell, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarHeadCell | |
+ :class=" | |
+ cn( | |
+ 'w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', | |
+ props.class, | |
+ ) | |
+ " | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot /> | |
+ </CalendarHeadCell> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarHeader.vue b/src/components/base/calendar/CalendarHeader.vue | |
new file mode 100644 | |
index 0000000..3d7b286 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarHeader.vue | |
@@ -0,0 +1,26 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarHeader, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarHeader | |
+ :class=" | |
+ cn('relative flex w-full items-center justify-between pt-1', props.class) | |
+ " | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot /> | |
+ </CalendarHeader> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarHeading.vue b/src/components/base/calendar/CalendarHeading.vue | |
new file mode 100644 | |
index 0000000..581a1c6 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarHeading.vue | |
@@ -0,0 +1,29 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { CalendarHeading, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+defineSlots(); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarHeading | |
+ v-slot="{ headingValue }" | |
+ :class="cn('text-sm font-medium', props.class)" | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot :heading-value> | |
+ {{ headingValue }} | |
+ </slot> | |
+ </CalendarHeading> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarNextButton.vue b/src/components/base/calendar/CalendarNextButton.vue | |
new file mode 100644 | |
index 0000000..ee7cb58 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarNextButton.vue | |
@@ -0,0 +1,35 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ChevronRight } from "lucide-vue-next"; | |
+import { CalendarNext, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { buttonVariants } from "@/components/common/buttons"; | |
+ | |
+const props = defineProps({ | |
+ nextPage: { type: Function, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarNext | |
+ :class=" | |
+ cn( | |
+ buttonVariants({ variant: 'outline' }), | |
+ 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100', | |
+ props.class, | |
+ ) | |
+ " | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot> | |
+ <ChevronRight class="h-4 w-4" /> | |
+ </slot> | |
+ </CalendarNext> | |
+</template> | |
diff --git a/src/components/base/calendar/CalendarPrevButton.vue b/src/components/base/calendar/CalendarPrevButton.vue | |
new file mode 100644 | |
index 0000000..b53c280 | |
--- /dev/null | |
+++ b/src/components/base/calendar/CalendarPrevButton.vue | |
@@ -0,0 +1,35 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ChevronLeft } from "lucide-vue-next"; | |
+import { CalendarPrev, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { buttonVariants } from "@/components/common/buttons"; | |
+ | |
+const props = defineProps({ | |
+ prevPage: { type: Function, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <CalendarPrev | |
+ :class=" | |
+ cn( | |
+ buttonVariants({ variant: 'outline' }), | |
+ 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100', | |
+ props.class, | |
+ ) | |
+ " | |
+ v-bind="forwardedProps" | |
+ > | |
+ <slot> | |
+ <ChevronLeft class="h-4 w-4" /> | |
+ </slot> | |
+ </CalendarPrev> | |
+</template> | |
diff --git a/src/components/base/calendar/index.js b/src/components/base/calendar/index.js | |
new file mode 100644 | |
index 0000000..f5e9b7e | |
--- /dev/null | |
+++ b/src/components/base/calendar/index.js | |
@@ -0,0 +1,12 @@ | |
+export { default as Calendar } from "./Calendar.vue"; | |
+export { default as CalendarCell } from "./CalendarCell.vue"; | |
+export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"; | |
+export { default as CalendarGrid } from "./CalendarGrid.vue"; | |
+export { default as CalendarGridBody } from "./CalendarGridBody.vue"; | |
+export { default as CalendarGridHead } from "./CalendarGridHead.vue"; | |
+export { default as CalendarGridRow } from "./CalendarGridRow.vue"; | |
+export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"; | |
+export { default as CalendarHeader } from "./CalendarHeader.vue"; | |
+export { default as CalendarHeading } from "./CalendarHeading.vue"; | |
+export { default as CalendarNextButton } from "./CalendarNextButton.vue"; | |
+export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"; | |
diff --git a/src/components/base/combobox/Combobox.vue b/src/components/base/combobox/Combobox.vue | |
new file mode 100644 | |
index 0000000..0998b34 | |
--- /dev/null | |
+++ b/src/components/base/combobox/Combobox.vue | |
@@ -0,0 +1,33 @@ | |
+<script setup> | |
+import { ComboboxRoot, useForwardPropsEmits } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ open: { type: Boolean, required: false }, | |
+ defaultOpen: { type: Boolean, required: false }, | |
+ resetSearchTermOnBlur: { type: Boolean, required: false }, | |
+ resetSearchTermOnSelect: { type: Boolean, required: false }, | |
+ openOnFocus: { type: Boolean, required: false }, | |
+ openOnClick: { type: Boolean, required: false }, | |
+ ignoreFilter: { type: Boolean, required: false }, | |
+ modelValue: { type: null, required: false }, | |
+ defaultValue: { type: null, required: false }, | |
+ multiple: { type: Boolean, required: false }, | |
+ dir: { type: String, required: false }, | |
+ disabled: { type: Boolean, required: false }, | |
+ highlightOnHover: { type: Boolean, required: false }, | |
+ by: { type: [String, Function], required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ name: { type: String, required: false }, | |
+ required: { type: Boolean, required: false }, | |
+}); | |
+const emits = defineEmits(["update:modelValue", "highlight", "update:open"]); | |
+ | |
+const forwarded = useForwardPropsEmits(props, emits); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxRoot v-bind="forwarded"> | |
+ <slot /> | |
+ </ComboboxRoot> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxAnchor.vue b/src/components/base/combobox/ComboboxAnchor.vue | |
new file mode 100644 | |
index 0000000..218bfc6 | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxAnchor.vue | |
@@ -0,0 +1,22 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxAnchor, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ reference: { type: null, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxAnchor v-bind="forwarded" :class="cn('w-[200px]', props.class)"> | |
+ <slot /> | |
+ </ComboboxAnchor> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxEmpty.vue b/src/components/base/combobox/ComboboxEmpty.vue | |
new file mode 100644 | |
index 0000000..e102b84 | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxEmpty.vue | |
@@ -0,0 +1,22 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxEmpty } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxEmpty | |
+ v-bind="delegatedProps" | |
+ :class="cn('py-6 text-center text-sm', props.class)" | |
+ > | |
+ <slot /> | |
+ </ComboboxEmpty> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxGroup.vue b/src/components/base/combobox/ComboboxGroup.vue | |
new file mode 100644 | |
index 0000000..46ba956 | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxGroup.vue | |
@@ -0,0 +1,34 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxGroup, ComboboxLabel } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+ heading: { type: String, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxGroup | |
+ v-bind="delegatedProps" | |
+ :class=" | |
+ cn( | |
+ 'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <ComboboxLabel | |
+ v-if="heading" | |
+ class="px-2 py-1.5 text-xs font-medium text-muted-foreground" | |
+ > | |
+ {{ heading }} | |
+ </ComboboxLabel> | |
+ <slot /> | |
+ </ComboboxGroup> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxInput.vue b/src/components/base/combobox/ComboboxInput.vue | |
new file mode 100644 | |
index 0000000..f485577 | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxInput.vue | |
@@ -0,0 +1,35 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxInput, useForwardPropsEmits } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ displayValue: { type: Function, required: false }, | |
+ modelValue: { type: String, required: false }, | |
+ autoFocus: { type: Boolean, required: false }, | |
+ disabled: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const emits = defineEmits(["update:modelValue"]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxInput | |
+ v-bind="forwarded" | |
+ :class=" | |
+ cn( | |
+ 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <slot /> | |
+ </ComboboxInput> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxItem.vue b/src/components/base/combobox/ComboboxItem.vue | |
new file mode 100644 | |
index 0000000..ab5be91 | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxItem.vue | |
@@ -0,0 +1,33 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxItem, useForwardPropsEmits } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ textValue: { type: String, required: false }, | |
+ value: { type: null, required: true }, | |
+ disabled: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits(["select"]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxItem | |
+ v-bind="forwarded" | |
+ :class=" | |
+ cn( | |
+ 'relative flex cursor-default gap-2 select-none justify-between items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <slot /> | |
+ </ComboboxItem> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxList.vue b/src/components/base/combobox/ComboboxList.vue | |
new file mode 100644 | |
index 0000000..ee575cd | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxList.vue | |
@@ -0,0 +1,65 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { | |
+ ComboboxContent, | |
+ ComboboxPortal, | |
+ ComboboxViewport, | |
+ useForwardPropsEmits, | |
+} from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ forceMount: { type: Boolean, required: false }, | |
+ position: { type: String, required: false, default: "popper" }, | |
+ bodyLock: { type: Boolean, required: false }, | |
+ side: { type: null, required: false }, | |
+ sideOffset: { type: Number, required: false, default: 4 }, | |
+ sideFlip: { type: Boolean, required: false }, | |
+ align: { type: null, required: false, default: "center" }, | |
+ alignOffset: { type: Number, required: false }, | |
+ alignFlip: { type: Boolean, required: false }, | |
+ avoidCollisions: { type: Boolean, required: false }, | |
+ collisionBoundary: { type: null, required: false }, | |
+ collisionPadding: { type: [Number, Object], required: false }, | |
+ arrowPadding: { type: Number, required: false }, | |
+ sticky: { type: String, required: false }, | |
+ hideWhenDetached: { type: Boolean, required: false }, | |
+ positionStrategy: { type: String, required: false }, | |
+ updatePositionStrategy: { type: String, required: false }, | |
+ disableUpdateOnLayoutShift: { type: Boolean, required: false }, | |
+ prioritizePosition: { type: Boolean, required: false }, | |
+ reference: { type: null, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ disableOutsidePointerEvents: { type: Boolean, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits([ | |
+ "escapeKeyDown", | |
+ "pointerDownOutside", | |
+ "focusOutside", | |
+ "interactOutside", | |
+]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxPortal> | |
+ <ComboboxContent | |
+ v-bind="forwarded" | |
+ :class=" | |
+ cn( | |
+ 'z-50 w-[200px] rounded-md border bg-popover text-popover-foreground shadow-md outline-none 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, | |
+ ) | |
+ " | |
+ > | |
+ <ComboboxViewport> | |
+ <slot /> | |
+ </ComboboxViewport> | |
+ </ComboboxContent> | |
+ </ComboboxPortal> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxSeparator.vue b/src/components/base/combobox/ComboboxSeparator.vue | |
new file mode 100644 | |
index 0000000..c3c2479 | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxSeparator.vue | |
@@ -0,0 +1,22 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxSeparator } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxSeparator | |
+ v-bind="delegatedProps" | |
+ :class="cn('-mx-1 h-px bg-border', props.class)" | |
+ > | |
+ <slot /> | |
+ </ComboboxSeparator> | |
+</template> | |
diff --git a/src/components/base/combobox/ComboboxTrigger.vue b/src/components/base/combobox/ComboboxTrigger.vue | |
new file mode 100644 | |
index 0000000..56c5fde | |
--- /dev/null | |
+++ b/src/components/base/combobox/ComboboxTrigger.vue | |
@@ -0,0 +1,22 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ComboboxTrigger, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ disabled: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <ComboboxTrigger v-bind="forwarded" :class="cn('', props.class)" tabindex="0"> | |
+ <slot /> | |
+ </ComboboxTrigger> | |
+</template> | |
diff --git a/src/components/base/combobox/index.js b/src/components/base/combobox/index.js | |
new file mode 100644 | |
index 0000000..2ca6cd6 | |
--- /dev/null | |
+++ b/src/components/base/combobox/index.js | |
@@ -0,0 +1,14 @@ | |
+export { default as Combobox } from "./Combobox.vue"; | |
+export { default as ComboboxAnchor } from "./ComboboxAnchor.vue"; | |
+export { default as ComboboxEmpty } from "./ComboboxEmpty.vue"; | |
+export { default as ComboboxGroup } from "./ComboboxGroup.vue"; | |
+export { default as ComboboxInput } from "./ComboboxInput.vue"; | |
+export { default as ComboboxItem } from "./ComboboxItem.vue"; | |
+export { default as ComboboxList } from "./ComboboxList.vue"; | |
+export { default as ComboboxSeparator } from "./ComboboxSeparator.vue"; | |
+ | |
+export { | |
+ ComboboxCancel, | |
+ ComboboxItemIndicator, | |
+ ComboboxTrigger, | |
+} from "reka-ui"; | |
diff --git a/src/components/base/command/Command.vue b/src/components/base/command/Command.vue | |
new file mode 100644 | |
index 0000000..acf5930 | |
--- /dev/null | |
+++ b/src/components/base/command/Command.vue | |
@@ -0,0 +1,114 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ListboxRoot, useFilter, useForwardPropsEmits } from "reka-ui"; | |
+import { reactive, ref, watch } from "vue"; | |
+import { cn } from "@/lib/utils"; | |
+import { provideCommandContext } from "."; | |
+ | |
+const props = defineProps({ | |
+ modelValue: { type: null, required: false, default: "" }, | |
+ defaultValue: { type: null, required: false }, | |
+ multiple: { type: Boolean, required: false }, | |
+ orientation: { type: String, required: false }, | |
+ dir: { type: String, required: false }, | |
+ disabled: { type: Boolean, required: false }, | |
+ selectionBehavior: { type: String, required: false }, | |
+ highlightOnHover: { type: Boolean, required: false }, | |
+ by: { type: [String, Function], required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ name: { type: String, required: false }, | |
+ required: { type: Boolean, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const emits = defineEmits([ | |
+ "update:modelValue", | |
+ "highlight", | |
+ "entryFocus", | |
+ "leave", | |
+]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+ | |
+const allItems = ref(new Map()); | |
+const allGroups = ref(new Map()); | |
+ | |
+const { contains } = useFilter({ sensitivity: "base" }); | |
+const filterState = reactive({ | |
+ search: "", | |
+ filtered: { | |
+ /** The count of all visible items. */ | |
+ count: 0, | |
+ /** Map from visible item id to its search score. */ | |
+ items: new Map(), | |
+ /** Set of groups with at least one visible item. */ | |
+ groups: new Set(), | |
+ }, | |
+}); | |
+ | |
+function filterItems() { | |
+ if (!filterState.search) { | |
+ filterState.filtered.count = allItems.value.size; | |
+ // Do nothing, each item will know to show itself because search is empty | |
+ return; | |
+ } | |
+ | |
+ // Reset the groups | |
+ filterState.filtered.groups = new Set(); | |
+ let itemCount = 0; | |
+ | |
+ // Check which items should be included | |
+ for (const [id, value] of allItems.value) { | |
+ const score = contains(value, filterState.search); | |
+ filterState.filtered.items.set(id, score ? 1 : 0); | |
+ if (score) itemCount++; | |
+ } | |
+ | |
+ // Check which groups have at least 1 item shown | |
+ for (const [groupId, group] of allGroups.value) { | |
+ for (const itemId of group) { | |
+ if (filterState.filtered.items.get(itemId) > 0) { | |
+ filterState.filtered.groups.add(groupId); | |
+ break; | |
+ } | |
+ } | |
+ } | |
+ | |
+ filterState.filtered.count = itemCount; | |
+} | |
+ | |
+/*eslint-disable-next-line no-unused-vars */ | |
+function handleSelect() { | |
+ filterState.search = ""; | |
+} | |
+ | |
+watch( | |
+ () => filterState.search, | |
+ () => { | |
+ filterItems(); | |
+ }, | |
+); | |
+ | |
+provideCommandContext({ | |
+ allItems, | |
+ allGroups, | |
+ filterState, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <ListboxRoot | |
+ v-bind="forwarded" | |
+ :class=" | |
+ cn( | |
+ 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <slot /> | |
+ </ListboxRoot> | |
+</template> | |
diff --git a/src/components/base/command/CommandDialog.vue b/src/components/base/command/CommandDialog.vue | |
new file mode 100644 | |
index 0000000..6291087 | |
--- /dev/null | |
+++ b/src/components/base/command/CommandDialog.vue | |
@@ -0,0 +1,26 @@ | |
+<script setup> | |
+import { useForwardPropsEmits } from "reka-ui"; | |
+import { Dialog, DialogContent } from "@/components/base/dialog"; | |
+import Command from "./Command.vue"; | |
+ | |
+const props = defineProps({ | |
+ open: { type: Boolean, required: false }, | |
+ defaultOpen: { type: Boolean, required: false }, | |
+ modal: { type: Boolean, required: false }, | |
+}); | |
+const emits = defineEmits(["update:open"]); | |
+ | |
+const forwarded = useForwardPropsEmits(props, emits); | |
+</script> | |
+ | |
+<template> | |
+ <Dialog v-bind="forwarded"> | |
+ <DialogContent class="overflow-hidden p-0 shadow-lg"> | |
+ <Command | |
+ class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5" | |
+ > | |
+ <slot /> | |
+ </Command> | |
+ </DialogContent> | |
+ </Dialog> | |
+</template> | |
diff --git a/src/components/base/command/CommandEmpty.vue b/src/components/base/command/CommandEmpty.vue | |
new file mode 100644 | |
index 0000000..7211b50 | |
--- /dev/null | |
+++ b/src/components/base/command/CommandEmpty.vue | |
@@ -0,0 +1,30 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { Primitive } from "reka-ui"; | |
+import { computed } from "vue"; | |
+import { cn } from "@/lib/utils"; | |
+import { useCommand } from "."; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const { filterState } = useCommand(); | |
+const isRender = computed( | |
+ () => !!filterState.search && filterState.filtered.count === 0, | |
+); | |
+</script> | |
+ | |
+<template> | |
+ <Primitive | |
+ v-if="isRender" | |
+ v-bind="delegatedProps" | |
+ :class="cn('py-6 text-center text-sm', props.class)" | |
+ > | |
+ <slot /> | |
+ </Primitive> | |
+</template> | |
diff --git a/src/components/base/command/CommandGroup.vue b/src/components/base/command/CommandGroup.vue | |
new file mode 100644 | |
index 0000000..961eb68 | |
--- /dev/null | |
+++ b/src/components/base/command/CommandGroup.vue | |
@@ -0,0 +1,53 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ListboxGroup, ListboxGroupLabel, useId } from "reka-ui"; | |
+import { computed, onMounted, onUnmounted } from "vue"; | |
+import { cn } from "@/lib/utils"; | |
+import { provideCommandGroupContext, useCommand } from "."; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+ heading: { type: String, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const { allGroups, filterState } = useCommand(); | |
+const id = useId(); | |
+ | |
+const isRender = computed(() => | |
+ !filterState.search ? true : filterState.filtered.groups.has(id), | |
+); | |
+ | |
+provideCommandGroupContext({ id }); | |
+onMounted(() => { | |
+ if (!allGroups.value.has(id)) allGroups.value.set(id, new Set()); | |
+}); | |
+onUnmounted(() => { | |
+ allGroups.value.delete(id); | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <ListboxGroup | |
+ v-bind="delegatedProps" | |
+ :id="id" | |
+ :class=" | |
+ cn( | |
+ 'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', | |
+ props.class, | |
+ ) | |
+ " | |
+ :hidden="isRender ? undefined : true" | |
+ > | |
+ <ListboxGroupLabel | |
+ v-if="heading" | |
+ class="px-2 py-1.5 text-xs font-medium text-muted-foreground" | |
+ > | |
+ {{ heading }} | |
+ </ListboxGroupLabel> | |
+ <slot /> | |
+ </ListboxGroup> | |
+</template> | |
diff --git a/src/components/base/command/CommandInput.vue b/src/components/base/command/CommandInput.vue | |
new file mode 100644 | |
index 0000000..cdec7d1 | |
--- /dev/null | |
+++ b/src/components/base/command/CommandInput.vue | |
@@ -0,0 +1,43 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { Search } from "lucide-vue-next"; | |
+import { ListboxFilter, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { useCommand } from "."; | |
+ | |
+defineOptions({ | |
+ inheritAttrs: false, | |
+}); | |
+ | |
+const props = defineProps({ | |
+ modelValue: { type: String, required: false }, | |
+ autoFocus: { type: Boolean, required: false }, | |
+ disabled: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+ | |
+const { filterState } = useCommand(); | |
+</script> | |
+ | |
+<template> | |
+ <div class="flex items-center border-b px-3" cmdk-input-wrapper> | |
+ <Search class="mr-2 h-4 w-4 shrink-0 opacity-50" /> | |
+ <ListboxFilter | |
+ v-bind="{ ...forwardedProps, ...$attrs }" | |
+ v-model="filterState.search" | |
+ auto-focus | |
+ :class=" | |
+ cn( | |
+ 'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', | |
+ props.class, | |
+ ) | |
+ " | |
+ /> | |
+ </div> | |
+</template> | |
diff --git a/src/components/base/command/CommandItem.vue b/src/components/base/command/CommandItem.vue | |
new file mode 100644 | |
index 0000000..513717c | |
--- /dev/null | |
+++ b/src/components/base/command/CommandItem.vue | |
@@ -0,0 +1,86 @@ | |
+<script setup> | |
+import { reactiveOmit, useCurrentElement } from "@vueuse/core"; | |
+import { ListboxItem, useForwardPropsEmits, useId } from "reka-ui"; | |
+import { computed, onMounted, onUnmounted, ref } from "vue"; | |
+import { cn } from "@/lib/utils"; | |
+import { useCommand, useCommandGroup } from "."; | |
+ | |
+const props = defineProps({ | |
+ value: { type: null, required: true }, | |
+ disabled: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits(["select"]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+ | |
+const id = useId(); | |
+const { filterState, allItems, allGroups } = useCommand(); | |
+const groupContext = useCommandGroup(); | |
+ | |
+const isRender = computed(() => { | |
+ if (!filterState.search) { | |
+ return true; | |
+ } else { | |
+ const filteredCurrentItem = filterState.filtered.items.get(id); | |
+ // If the filtered items is undefined means not in the all times map yet | |
+ // Do the first render to add into the map | |
+ if (filteredCurrentItem === undefined) { | |
+ return true; | |
+ } | |
+ | |
+ // Check with filter | |
+ return filteredCurrentItem > 0; | |
+ } | |
+}); | |
+ | |
+const itemRef = ref(); | |
+const currentElement = useCurrentElement(itemRef); | |
+onMounted(() => { | |
+ if (!(currentElement.value instanceof HTMLElement)) return; | |
+ | |
+ // textValue to perform filter | |
+ allItems.value.set( | |
+ id, | |
+ currentElement.value.textContent ?? props?.value.toString(), | |
+ ); | |
+ | |
+ const groupId = groupContext?.id; | |
+ if (groupId) { | |
+ if (!allGroups.value.has(groupId)) { | |
+ allGroups.value.set(groupId, new Set([id])); | |
+ } else { | |
+ allGroups.value.get(groupId)?.add(id); | |
+ } | |
+ } | |
+}); | |
+onUnmounted(() => { | |
+ allItems.value.delete(id); | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <ListboxItem | |
+ v-if="isRender" | |
+ v-bind="forwarded" | |
+ :id="id" | |
+ ref="itemRef" | |
+ :class=" | |
+ cn( | |
+ 'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', | |
+ props.class, | |
+ ) | |
+ " | |
+ @select=" | |
+ () => { | |
+ filterState.search = ''; | |
+ } | |
+ " | |
+ > | |
+ <slot /> | |
+ </ListboxItem> | |
+</template> | |
diff --git a/src/components/base/command/CommandList.vue b/src/components/base/command/CommandList.vue | |
new file mode 100644 | |
index 0000000..82058d2 | |
--- /dev/null | |
+++ b/src/components/base/command/CommandList.vue | |
@@ -0,0 +1,26 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ListboxContent, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <ListboxContent | |
+ v-bind="forwarded" | |
+ :class="cn('max-h-[300px] overflow-y-auto overflow-x-hidden', props.class)" | |
+ > | |
+ <div role="presentation"> | |
+ <slot /> | |
+ </div> | |
+ </ListboxContent> | |
+</template> | |
diff --git a/src/components/base/command/CommandSeparator.vue b/src/components/base/command/CommandSeparator.vue | |
new file mode 100644 | |
index 0000000..2fb978e | |
--- /dev/null | |
+++ b/src/components/base/command/CommandSeparator.vue | |
@@ -0,0 +1,24 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { Separator } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ orientation: { type: String, required: false }, | |
+ decorative: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+</script> | |
+ | |
+<template> | |
+ <Separator | |
+ v-bind="delegatedProps" | |
+ :class="cn('-mx-1 h-px bg-border', props.class)" | |
+ > | |
+ <slot /> | |
+ </Separator> | |
+</template> | |
diff --git a/src/components/base/command/CommandShortcut.vue b/src/components/base/command/CommandShortcut.vue | |
new file mode 100644 | |
index 0000000..6301f1a | |
--- /dev/null | |
+++ b/src/components/base/command/CommandShortcut.vue | |
@@ -0,0 +1,17 @@ | |
+<script setup> | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ class: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <span | |
+ :class=" | |
+ cn('ml-auto text-xs tracking-widest text-muted-foreground', props.class) | |
+ " | |
+ > | |
+ <slot /> | |
+ </span> | |
+</template> | |
diff --git a/src/components/base/command/index.js b/src/components/base/command/index.js | |
new file mode 100644 | |
index 0000000..641cf56 | |
--- /dev/null | |
+++ b/src/components/base/command/index.js | |
@@ -0,0 +1,16 @@ | |
+import { createContext } from "reka-ui"; | |
+ | |
+export { default as Command } from "./Command.vue"; | |
+export { default as CommandDialog } from "./CommandDialog.vue"; | |
+export { default as CommandEmpty } from "./CommandEmpty.vue"; | |
+export { default as CommandGroup } from "./CommandGroup.vue"; | |
+export { default as CommandInput } from "./CommandInput.vue"; | |
+export { default as CommandItem } from "./CommandItem.vue"; | |
+export { default as CommandList } from "./CommandList.vue"; | |
+export { default as CommandSeparator } from "./CommandSeparator.vue"; | |
+export { default as CommandShortcut } from "./CommandShortcut.vue"; | |
+ | |
+export const [useCommand, provideCommandContext] = createContext("Command"); | |
+ | |
+export const [useCommandGroup, provideCommandGroupContext] = | |
+ createContext("CommandGroup"); | |
diff --git a/src/components/base/dialog/Dialog.vue b/src/components/base/dialog/Dialog.vue | |
new file mode 100644 | |
index 0000000..509afc1 | |
--- /dev/null | |
+++ b/src/components/base/dialog/Dialog.vue | |
@@ -0,0 +1,18 @@ | |
+<script setup> | |
+import { DialogRoot, useForwardPropsEmits } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ open: { type: Boolean, required: false }, | |
+ defaultOpen: { type: Boolean, required: false }, | |
+ modal: { type: Boolean, required: false }, | |
+}); | |
+const emits = defineEmits(["update:open"]); | |
+ | |
+const forwarded = useForwardPropsEmits(props, emits); | |
+</script> | |
+ | |
+<template> | |
+ <DialogRoot v-bind="forwarded"> | |
+ <slot /> | |
+ </DialogRoot> | |
+</template> | |
diff --git a/src/components/base/dialog/DialogClose.vue b/src/components/base/dialog/DialogClose.vue | |
new file mode 100644 | |
index 0000000..c90b9bb | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogClose.vue | |
@@ -0,0 +1,14 @@ | |
+<script setup> | |
+import { DialogClose } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <DialogClose v-bind="props"> | |
+ <slot /> | |
+ </DialogClose> | |
+</template> | |
diff --git a/src/components/base/dialog/DialogContent.vue b/src/components/base/dialog/DialogContent.vue | |
new file mode 100644 | |
index 0000000..5ada8a4 | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogContent.vue | |
@@ -0,0 +1,58 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { X } from "lucide-vue-next"; | |
+import { | |
+ DialogClose, | |
+ DialogContent, | |
+ DialogOverlay, | |
+ DialogPortal, | |
+ useForwardPropsEmits, | |
+} from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ forceMount: { type: Boolean, required: false }, | |
+ disableOutsidePointerEvents: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits([ | |
+ "escapeKeyDown", | |
+ "pointerDownOutside", | |
+ "focusOutside", | |
+ "interactOutside", | |
+ "openAutoFocus", | |
+ "closeAutoFocus", | |
+]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+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> | |
diff --git a/src/components/base/dialog/DialogDescription.vue b/src/components/base/dialog/DialogDescription.vue | |
new file mode 100644 | |
index 0000000..544211e | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogDescription.vue | |
@@ -0,0 +1,24 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { DialogDescription, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <DialogDescription | |
+ v-bind="forwardedProps" | |
+ :class="cn('text-sm text-muted-foreground', props.class)" | |
+ > | |
+ <slot /> | |
+ </DialogDescription> | |
+</template> | |
diff --git a/src/components/base/dialog/DialogFooter.vue b/src/components/base/dialog/DialogFooter.vue | |
new file mode 100644 | |
index 0000000..f623c10 | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogFooter.vue | |
@@ -0,0 +1,20 @@ | |
+<script setup> | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ class: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <div | |
+ :class=" | |
+ cn( | |
+ 'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <slot /> | |
+ </div> | |
+</template> | |
diff --git a/src/components/base/dialog/DialogHeader.vue b/src/components/base/dialog/DialogHeader.vue | |
new file mode 100644 | |
index 0000000..e745386 | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogHeader.vue | |
@@ -0,0 +1,15 @@ | |
+<script setup> | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ class: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <div | |
+ :class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)" | |
+ > | |
+ <slot /> | |
+ </div> | |
+</template> | |
diff --git a/src/components/base/dialog/DialogScrollContent.vue b/src/components/base/dialog/DialogScrollContent.vue | |
new file mode 100644 | |
index 0000000..95cbf42 | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogScrollContent.vue | |
@@ -0,0 +1,71 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { X } from "lucide-vue-next"; | |
+import { | |
+ DialogClose, | |
+ DialogContent, | |
+ DialogOverlay, | |
+ DialogPortal, | |
+ useForwardPropsEmits, | |
+} from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ forceMount: { type: Boolean, required: false }, | |
+ disableOutsidePointerEvents: { type: Boolean, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits([ | |
+ "escapeKeyDown", | |
+ "pointerDownOutside", | |
+ "focusOutside", | |
+ "interactOutside", | |
+ "openAutoFocus", | |
+ "closeAutoFocus", | |
+]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+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; | |
+ if ( | |
+ originalEvent.offsetX > target.clientWidth || | |
+ originalEvent.offsetY > target.clientHeight | |
+ ) { | |
+ event.preventDefault(); | |
+ } | |
+ } | |
+ " | |
+ > | |
+ <slot /> | |
+ | |
+ <DialogClose | |
+ class="absolute top-4 right-4 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> | |
diff --git a/src/components/base/dialog/DialogTitle.vue b/src/components/base/dialog/DialogTitle.vue | |
new file mode 100644 | |
index 0000000..6093ccf | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogTitle.vue | |
@@ -0,0 +1,26 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { DialogTitle, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+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> | |
diff --git a/src/components/base/dialog/DialogTrigger.vue b/src/components/base/dialog/DialogTrigger.vue | |
new file mode 100644 | |
index 0000000..c7e102b | |
--- /dev/null | |
+++ b/src/components/base/dialog/DialogTrigger.vue | |
@@ -0,0 +1,14 @@ | |
+<script setup> | |
+import { DialogTrigger } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <DialogTrigger v-bind="props"> | |
+ <slot /> | |
+ </DialogTrigger> | |
+</template> | |
diff --git a/src/components/base/dialog/index.js b/src/components/base/dialog/index.js | |
new file mode 100644 | |
index 0000000..e2b3a15 | |
--- /dev/null | |
+++ b/src/components/base/dialog/index.js | |
@@ -0,0 +1,9 @@ | |
+export { default as Dialog } from "./Dialog.vue"; | |
+export { default as DialogClose } from "./DialogClose.vue"; | |
+export { default as DialogContent } from "./DialogContent.vue"; | |
+export { default as DialogDescription } from "./DialogDescription.vue"; | |
+export { default as DialogFooter } from "./DialogFooter.vue"; | |
+export { default as DialogHeader } from "./DialogHeader.vue"; | |
+export { default as DialogScrollContent } from "./DialogScrollContent.vue"; | |
+export { default as DialogTitle } from "./DialogTitle.vue"; | |
+export { default as DialogTrigger } from "./DialogTrigger.vue"; | |
diff --git a/src/components/base/popover/Popover.vue b/src/components/base/popover/Popover.vue | |
new file mode 100644 | |
index 0000000..a5210da | |
--- /dev/null | |
+++ b/src/components/base/popover/Popover.vue | |
@@ -0,0 +1,18 @@ | |
+<script setup> | |
+import { PopoverRoot, useForwardPropsEmits } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ defaultOpen: { type: Boolean, required: false }, | |
+ open: { type: Boolean, required: false }, | |
+ modal: { type: Boolean, required: false }, | |
+}); | |
+const emits = defineEmits(["update:open"]); | |
+ | |
+const forwarded = useForwardPropsEmits(props, emits); | |
+</script> | |
+ | |
+<template> | |
+ <PopoverRoot v-bind="forwarded"> | |
+ <slot /> | |
+ </PopoverRoot> | |
+</template> | |
diff --git a/src/components/base/popover/PopoverContent.vue b/src/components/base/popover/PopoverContent.vue | |
new file mode 100644 | |
index 0000000..789014f | |
--- /dev/null | |
+++ b/src/components/base/popover/PopoverContent.vue | |
@@ -0,0 +1,62 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { PopoverContent, PopoverPortal, useForwardPropsEmits } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+defineOptions({ | |
+ inheritAttrs: false, | |
+}); | |
+ | |
+const props = defineProps({ | |
+ forceMount: { type: Boolean, required: false }, | |
+ side: { type: null, required: false }, | |
+ sideOffset: { type: Number, required: false, default: 4 }, | |
+ sideFlip: { type: Boolean, required: false }, | |
+ align: { type: null, required: false, default: "center" }, | |
+ alignOffset: { type: Number, required: false }, | |
+ alignFlip: { type: Boolean, required: false }, | |
+ avoidCollisions: { type: Boolean, required: false }, | |
+ collisionBoundary: { type: null, required: false }, | |
+ collisionPadding: { type: [Number, Object], required: false }, | |
+ arrowPadding: { type: Number, required: false }, | |
+ sticky: { type: String, required: false }, | |
+ hideWhenDetached: { type: Boolean, required: false }, | |
+ positionStrategy: { type: String, required: false }, | |
+ updatePositionStrategy: { type: String, required: false }, | |
+ disableUpdateOnLayoutShift: { type: Boolean, required: false }, | |
+ prioritizePosition: { type: Boolean, required: false }, | |
+ reference: { type: null, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ disableOutsidePointerEvents: { type: Boolean, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits([ | |
+ "escapeKeyDown", | |
+ "pointerDownOutside", | |
+ "focusOutside", | |
+ "interactOutside", | |
+ "openAutoFocus", | |
+ "closeAutoFocus", | |
+]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+</script> | |
+ | |
+<template> | |
+ <PopoverPortal> | |
+ <PopoverContent | |
+ v-bind="{ ...forwarded, ...$attrs }" | |
+ :class=" | |
+ cn( | |
+ 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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 /> | |
+ </PopoverContent> | |
+ </PopoverPortal> | |
+</template> | |
diff --git a/src/components/base/popover/PopoverTrigger.vue b/src/components/base/popover/PopoverTrigger.vue | |
new file mode 100644 | |
index 0000000..ed1cdee | |
--- /dev/null | |
+++ b/src/components/base/popover/PopoverTrigger.vue | |
@@ -0,0 +1,14 @@ | |
+<script setup> | |
+import { PopoverTrigger } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <PopoverTrigger v-bind="props"> | |
+ <slot /> | |
+ </PopoverTrigger> | |
+</template> | |
diff --git a/src/components/base/popover/index.js b/src/components/base/popover/index.js | |
new file mode 100644 | |
index 0000000..ce5130a | |
--- /dev/null | |
+++ b/src/components/base/popover/index.js | |
@@ -0,0 +1,4 @@ | |
+export { default as Popover } from "./Popover.vue"; | |
+export { default as PopoverContent } from "./PopoverContent.vue"; | |
+export { default as PopoverTrigger } from "./PopoverTrigger.vue"; | |
+export { PopoverAnchor } from "reka-ui"; | |
diff --git a/src/components/base/select/Select.vue b/src/components/base/select/Select.vue | |
new file mode 100644 | |
index 0000000..3980c72 | |
--- /dev/null | |
+++ b/src/components/base/select/Select.vue | |
@@ -0,0 +1,26 @@ | |
+<script setup> | |
+import { SelectRoot, useForwardPropsEmits } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ open: { type: Boolean, required: false }, | |
+ defaultOpen: { type: Boolean, required: false }, | |
+ defaultValue: { type: null, required: false }, | |
+ modelValue: { type: null, required: false }, | |
+ by: { type: [String, Function], required: false }, | |
+ dir: { type: String, required: false }, | |
+ multiple: { type: Boolean, required: false }, | |
+ autocomplete: { type: String, required: false }, | |
+ disabled: { type: Boolean, required: false }, | |
+ name: { type: String, required: false }, | |
+ required: { type: Boolean, required: false }, | |
+}); | |
+const emits = defineEmits(["update:modelValue", "update:open"]); | |
+ | |
+const forwarded = useForwardPropsEmits(props, emits); | |
+</script> | |
+ | |
+<template> | |
+ <SelectRoot v-bind="forwarded"> | |
+ <slot /> | |
+ </SelectRoot> | |
+</template> | |
diff --git a/src/components/base/select/SelectContent.vue b/src/components/base/select/SelectContent.vue | |
new file mode 100644 | |
index 0000000..f9cfbc8 | |
--- /dev/null | |
+++ b/src/components/base/select/SelectContent.vue | |
@@ -0,0 +1,80 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { | |
+ SelectContent, | |
+ SelectPortal, | |
+ SelectViewport, | |
+ useForwardPropsEmits, | |
+} from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { SelectScrollDownButton, SelectScrollUpButton } from "."; | |
+ | |
+defineOptions({ | |
+ inheritAttrs: false, | |
+}); | |
+ | |
+const props = defineProps({ | |
+ forceMount: { type: Boolean, required: false }, | |
+ position: { type: String, required: false, default: "popper" }, | |
+ bodyLock: { type: Boolean, required: false }, | |
+ side: { type: null, required: false }, | |
+ sideOffset: { type: Number, required: false }, | |
+ sideFlip: { type: Boolean, required: false }, | |
+ align: { type: null, required: false }, | |
+ alignOffset: { type: Number, required: false }, | |
+ alignFlip: { type: Boolean, required: false }, | |
+ avoidCollisions: { type: Boolean, required: false }, | |
+ collisionBoundary: { type: null, required: false }, | |
+ collisionPadding: { type: [Number, Object], required: false }, | |
+ arrowPadding: { type: Number, required: false }, | |
+ sticky: { type: String, required: false }, | |
+ hideWhenDetached: { type: Boolean, required: false }, | |
+ positionStrategy: { type: String, required: false }, | |
+ updatePositionStrategy: { type: String, required: false }, | |
+ disableUpdateOnLayoutShift: { type: Boolean, required: false }, | |
+ prioritizePosition: { type: Boolean, required: false }, | |
+ reference: { type: null, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+const emits = defineEmits([ | |
+ "closeAutoFocus", | |
+ "escapeKeyDown", | |
+ "pointerDownOutside", | |
+]); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwarded = useForwardPropsEmits(delegatedProps, emits); | |
+</script> | |
+ | |
+<template> | |
+ <SelectPortal> | |
+ <SelectContent | |
+ v-bind="{ ...forwarded, ...$attrs }" | |
+ :class=" | |
+ cn( | |
+ 'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover 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', | |
+ position === 'popper' && | |
+ 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <SelectScrollUpButton /> | |
+ <SelectViewport | |
+ :class=" | |
+ cn( | |
+ 'p-1', | |
+ position === 'popper' && | |
+ 'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]', | |
+ ) | |
+ " | |
+ > | |
+ <slot /> | |
+ </SelectViewport> | |
+ <SelectScrollDownButton /> | |
+ </SelectContent> | |
+ </SelectPortal> | |
+</template> | |
diff --git a/src/components/base/select/SelectGroup.vue b/src/components/base/select/SelectGroup.vue | |
new file mode 100644 | |
index 0000000..f1f2d8a | |
--- /dev/null | |
+++ b/src/components/base/select/SelectGroup.vue | |
@@ -0,0 +1,19 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { SelectGroup } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+</script> | |
+ | |
+<template> | |
+ <SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps"> | |
+ <slot /> | |
+ </SelectGroup> | |
+</template> | |
diff --git a/src/components/base/select/SelectItem.vue b/src/components/base/select/SelectItem.vue | |
new file mode 100644 | |
index 0000000..ec569d1 | |
--- /dev/null | |
+++ b/src/components/base/select/SelectItem.vue | |
@@ -0,0 +1,46 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { Check } from "lucide-vue-next"; | |
+import { | |
+ SelectItem, | |
+ SelectItemIndicator, | |
+ SelectItemText, | |
+ useForwardProps, | |
+} from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ value: { type: null, required: true }, | |
+ disabled: { type: Boolean, required: false }, | |
+ textValue: { type: String, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <SelectItem | |
+ v-bind="forwardedProps" | |
+ :class=" | |
+ cn( | |
+ 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <span class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> | |
+ <SelectItemIndicator> | |
+ <Check class="h-4 w-4" /> | |
+ </SelectItemIndicator> | |
+ </span> | |
+ | |
+ <SelectItemText> | |
+ <slot /> | |
+ </SelectItemText> | |
+ </SelectItem> | |
+</template> | |
diff --git a/src/components/base/select/SelectItemText.vue b/src/components/base/select/SelectItemText.vue | |
new file mode 100644 | |
index 0000000..fa595bf | |
--- /dev/null | |
+++ b/src/components/base/select/SelectItemText.vue | |
@@ -0,0 +1,14 @@ | |
+<script setup> | |
+import { SelectItemText } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <SelectItemText v-bind="props"> | |
+ <slot /> | |
+ </SelectItemText> | |
+</template> | |
diff --git a/src/components/base/select/SelectLabel.vue b/src/components/base/select/SelectLabel.vue | |
new file mode 100644 | |
index 0000000..0a77b37 | |
--- /dev/null | |
+++ b/src/components/base/select/SelectLabel.vue | |
@@ -0,0 +1,17 @@ | |
+<script setup> | |
+import { SelectLabel } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ for: { type: String, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <SelectLabel :class="cn('px-2 py-1.5 text-sm font-semibold', props.class)"> | |
+ <slot /> | |
+ </SelectLabel> | |
+</template> | |
diff --git a/src/components/base/select/SelectScrollDownButton.vue b/src/components/base/select/SelectScrollDownButton.vue | |
new file mode 100644 | |
index 0000000..e21948b | |
--- /dev/null | |
+++ b/src/components/base/select/SelectScrollDownButton.vue | |
@@ -0,0 +1,29 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ChevronDown } from "lucide-vue-next"; | |
+import { SelectScrollDownButton, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <SelectScrollDownButton | |
+ v-bind="forwardedProps" | |
+ :class=" | |
+ cn('flex cursor-default items-center justify-center py-1', props.class) | |
+ " | |
+ > | |
+ <slot> | |
+ <ChevronDown /> | |
+ </slot> | |
+ </SelectScrollDownButton> | |
+</template> | |
diff --git a/src/components/base/select/SelectScrollUpButton.vue b/src/components/base/select/SelectScrollUpButton.vue | |
new file mode 100644 | |
index 0000000..63f5868 | |
--- /dev/null | |
+++ b/src/components/base/select/SelectScrollUpButton.vue | |
@@ -0,0 +1,29 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ChevronUp } from "lucide-vue-next"; | |
+import { SelectScrollUpButton, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <SelectScrollUpButton | |
+ v-bind="forwardedProps" | |
+ :class=" | |
+ cn('flex cursor-default items-center justify-center py-1', props.class) | |
+ " | |
+ > | |
+ <slot> | |
+ <ChevronUp /> | |
+ </slot> | |
+ </SelectScrollUpButton> | |
+</template> | |
diff --git a/src/components/base/select/SelectSeparator.vue b/src/components/base/select/SelectSeparator.vue | |
new file mode 100644 | |
index 0000000..c7f1bf7 | |
--- /dev/null | |
+++ b/src/components/base/select/SelectSeparator.vue | |
@@ -0,0 +1,20 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { SelectSeparator } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+</script> | |
+ | |
+<template> | |
+ <SelectSeparator | |
+ v-bind="delegatedProps" | |
+ :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" | |
+ /> | |
+</template> | |
diff --git a/src/components/base/select/SelectTrigger.vue b/src/components/base/select/SelectTrigger.vue | |
new file mode 100644 | |
index 0000000..35127a9 | |
--- /dev/null | |
+++ b/src/components/base/select/SelectTrigger.vue | |
@@ -0,0 +1,35 @@ | |
+<script setup> | |
+import { reactiveOmit } from "@vueuse/core"; | |
+import { ChevronDown } from "lucide-vue-next"; | |
+import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+const props = defineProps({ | |
+ disabled: { type: Boolean, required: false }, | |
+ reference: { type: null, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+}); | |
+ | |
+const delegatedProps = reactiveOmit(props, "class"); | |
+ | |
+const forwardedProps = useForwardProps(delegatedProps); | |
+</script> | |
+ | |
+<template> | |
+ <SelectTrigger | |
+ v-bind="forwardedProps" | |
+ :class=" | |
+ cn( | |
+ 'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start', | |
+ props.class, | |
+ ) | |
+ " | |
+ > | |
+ <slot /> | |
+ <SelectIcon as-child> | |
+ <ChevronDown class="w-4 h-4 opacity-50 shrink-0" /> | |
+ </SelectIcon> | |
+ </SelectTrigger> | |
+</template> | |
diff --git a/src/components/base/select/SelectValue.vue b/src/components/base/select/SelectValue.vue | |
new file mode 100644 | |
index 0000000..d890e8a | |
--- /dev/null | |
+++ b/src/components/base/select/SelectValue.vue | |
@@ -0,0 +1,15 @@ | |
+<script setup> | |
+import { SelectValue } from "reka-ui"; | |
+ | |
+const props = defineProps({ | |
+ placeholder: { type: String, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <SelectValue v-bind="props"> | |
+ <slot /> | |
+ </SelectValue> | |
+</template> | |
diff --git a/src/components/base/select/index.js b/src/components/base/select/index.js | |
new file mode 100644 | |
index 0000000..d911c4e | |
--- /dev/null | |
+++ b/src/components/base/select/index.js | |
@@ -0,0 +1,11 @@ | |
+export { default as Select } from "./Select.vue"; | |
+export { default as SelectContent } from "./SelectContent.vue"; | |
+export { default as SelectGroup } from "./SelectGroup.vue"; | |
+export { default as SelectItem } from "./SelectItem.vue"; | |
+export { default as SelectItemText } from "./SelectItemText.vue"; | |
+export { default as SelectLabel } from "./SelectLabel.vue"; | |
+export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"; | |
+export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"; | |
+export { default as SelectSeparator } from "./SelectSeparator.vue"; | |
+export { default as SelectTrigger } from "./SelectTrigger.vue"; | |
+export { default as SelectValue } from "./SelectValue.vue"; | |
diff --git a/src/components/common/buttons/BaseButton.vue b/src/components/common/buttons/BaseButton.vue | |
new file mode 100644 | |
index 0000000..81493d5 | |
--- /dev/null | |
+++ b/src/components/common/buttons/BaseButton.vue | |
@@ -0,0 +1,49 @@ | |
+<script setup> | |
+const { as, type, variant, size, disabled } = defineProps({ | |
+ as: { type: String, default: "button" }, // Could be 'a', 'router-link', etc. | |
+ type: { type: String, default: "button" }, // Only relevant if as=button | |
+ variant: { type: String, default: "gray" }, // primary, secondary, ghost... | |
+ size: { type: String, default: "md" }, // sm, md, lg | |
+ disabled: { type: Boolean, default: false }, | |
+}); | |
+ | |
+const baseStyles = | |
+ "font-body inline-flex items-center justify-center rounded-lg transition duration-200 focus:outline-2 focus:outline-offset-2"; | |
+const disabledStyles = | |
+ "disabled:bg-neutrals-300 disabled:text-neutrals-600 disabled:pointer-events-none"; | |
+const variants = { | |
+ dark: "text-white-transp-1000 bg-neutrals-900 hover:bg-neutrals-800 active:bg-neutrals-900 focus:outline-neutrals-900", | |
+ gray: "text-neutrals-900 bg-neutrals-200 hover:bg-neutrals-300 active:bg-neutrals-300 focus:outline-neutrals-300", | |
+ cta: "text-white-transp-1000 bg-gradient-to-r from-magenta-600 to-laranja-600 hover:bg-gradient-to-l", | |
+ rioMarket: "text-white bg-vermelho-600", | |
+ underline: | |
+ "text-neutrals-700 border-neutrals-900/0 border-b-[1.5px] hover:border-neutrals-900/100 active:text-neutrals-900 active:border-none focus:outline-none focus:border-b-[1.5px] focus:border-neutrals-900 rounded-none", | |
+}; | |
+ | |
+const sizes = { | |
+ xs: "px-200 py-150 text-sm font-regular leading-[21px]", | |
+ sm: "px-300 py-200 text-sm font-semibold leading-[19.6px]", | |
+ md: "px-400 py-400 text-md font-semibold leading-[22.4px]", | |
+ lg: "px-200 py-400 text-sm font-semibold leading-[19.6px]", | |
+}; | |
+// px-200 py-400 | |
+// text-sm font-body font-semibold leading-[19.6px] | |
+// text-neutrals-700 | |
+// border-neutrals-900 | |
+// hover:border-b-[1.5px] | |
+// active:text-neutrals-900 active:border-none | |
+// focus:outline-none focus:border-b-[1.5px] | |
+</script> | |
+ | |
+<template> | |
+ <component | |
+ :is="as" | |
+ :type="as === 'button' ? type : undefined" | |
+ :disabled="disabled" | |
+ :class="[baseStyles, disabledStyles, sizes[size], variants[variant]]" | |
+ > | |
+ <slot /> | |
+ </component> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/common/buttons/Button.vue b/src/components/common/buttons/Button.vue | |
new file mode 100644 | |
index 0000000..a6d6776 | |
--- /dev/null | |
+++ b/src/components/common/buttons/Button.vue | |
@@ -0,0 +1,23 @@ | |
+<script setup> | |
+import { Primitive } from "reka-ui"; | |
+import { cn } from "@/lib/utils"; | |
+import { buttonVariants } from "."; | |
+ | |
+const props = defineProps({ | |
+ variant: { type: null, required: false }, | |
+ size: { type: null, required: false }, | |
+ class: { type: null, required: false }, | |
+ asChild: { type: Boolean, required: false }, | |
+ as: { type: null, required: false, default: "button" }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <Primitive | |
+ :as="as" | |
+ :as-child="asChild" | |
+ :class="cn(buttonVariants({ variant, size }), props.class)" | |
+ > | |
+ <slot /> | |
+ </Primitive> | |
+</template> | |
diff --git a/src/components/common/buttons/ButtonText.vue b/src/components/common/buttons/ButtonText.vue | |
new file mode 100644 | |
index 0000000..cb34898 | |
--- /dev/null | |
+++ b/src/components/common/buttons/ButtonText.vue | |
@@ -0,0 +1,48 @@ | |
+<script setup> | |
+import { computed } from "vue"; | |
+ | |
+const variants = { | |
+ dark: "text-neutrals-900 hover:text-neutrals-700 active:text-neutrals-800 disabled:text-neutrals-600", | |
+ light: "text-white-transp-1000", | |
+ color: | |
+ "bg-clip-text text-transparent bg-gradient-to-r from-magenta-600 to-laranja-600 hover:bg-gradient-to-l active:bg-gradient-to-r", | |
+}; | |
+ | |
+const sizes = { | |
+ sm: "text-sm leading-[19.6px]", | |
+ md: "text-md leading-[22.4px]", | |
+}; | |
+ | |
+const { variant, size, text, tag, href } = defineProps({ | |
+ variant: { | |
+ type: String, | |
+ validator: (value) => ["dark", "light", "color"].includes(value), | |
+ default: "dark", | |
+ }, | |
+ size: { | |
+ type: String, | |
+ validator: (value) => ["sm", "md"].includes(value), | |
+ default: "md", | |
+ }, | |
+ text: { type: String, default: "" }, | |
+ tag: { type: String, default: "a" }, | |
+ href: { type: String, default: "#" }, | |
+}); | |
+ | |
+const variantClass = computed(() => variants[variant]); | |
+const sizeClass = computed(() => sizes[size]); | |
+</script> | |
+ | |
+<template> | |
+ <component | |
+ :is="tag" | |
+ :href="href" | |
+ class="p-100 inline-flex items-center justify-center max-w-fit font-body font-semibold" | |
+ :class="[sizeClass, variantClass]" | |
+ > | |
+ <slot name="icon" /> | |
+ {{ text }} | |
+ </component> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/common/buttons/NavButtonContext.vue b/src/components/common/buttons/NavButtonContext.vue | |
new file mode 100644 | |
index 0000000..2f0648d | |
--- /dev/null | |
+++ b/src/components/common/buttons/NavButtonContext.vue | |
@@ -0,0 +1,56 @@ | |
+<script setup> | |
+import { computed, ref } from "vue"; | |
+ | |
+import { useRoute } from "vue-router"; | |
+ | |
+const props = defineProps({ | |
+ content: { type: String, required: true }, | |
+ route: { type: String, default: "#" }, | |
+}); | |
+const isHovered = ref(false); | |
+const handleMouseEnter = () => { | |
+ isHovered.value = true; | |
+}; | |
+const handleMouseLeave = () => { | |
+ isHovered.value = false; | |
+}; | |
+ | |
+const isFocused = ref(false); | |
+const handleFocus = () => { | |
+ isFocused.value = true; | |
+}; | |
+const handleBlur = () => { | |
+ isFocused.value = false; | |
+}; | |
+ | |
+const isActive = computed(() => isHovered.value || isFocused.value); | |
+ | |
+const currentRoute = useRoute(); | |
+const isRouteActive = computed(() => currentRoute.path == props.route); | |
+const isIconActive = computed(() => isActive.value || isRouteActive.value); | |
+</script> | |
+ | |
+<template> | |
+ <router-link | |
+ :to="props.route" | |
+ class="" | |
+ :class="{ 'route-active-TEST': isRouteActive }" | |
+ :aria-label="`Navegar para ${props.content}`" | |
+ @mouseenter="handleMouseEnter" | |
+ @mouseleave="handleMouseLeave" | |
+ @focus="handleFocus" | |
+ @blur="handleBlur" | |
+ > | |
+ <div class="flex flex-col items-center gap-200"> | |
+ <slot | |
+ name="icon" | |
+ :hovered="isHovered" | |
+ :active="isIconActive" | |
+ :routeActive="isRouteActive" | |
+ /> | |
+ <p class="text-body-strong-xs text-primary text-center uppercase"> | |
+ {{ props.content }} | |
+ </p> | |
+ </div> | |
+ </router-link> | |
+</template> | |
diff --git a/src/components/common/buttons/index.js b/src/components/common/buttons/index.js | |
new file mode 100644 | |
index 0000000..e78fe92 | |
--- /dev/null | |
+++ b/src/components/common/buttons/index.js | |
@@ -0,0 +1,37 @@ | |
+import { cva } from "class-variance-authority"; | |
+ | |
+export { default as Button } from "./Button.vue"; | |
+export { default as BaseButton } from "./BaseButton.vue"; | |
+export { default as ButtonText } from "./ButtonText.vue"; | |
+export { default as NavButtonContext } from "./NavButtonContext.vue"; | |
+ | |
+export const buttonVariants = cva( | |
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", | |
+ { | |
+ variants: { | |
+ variant: { | |
+ default: | |
+ "bg-primary text-primary-foreground shadow hover:bg-primary/90", | |
+ destructive: | |
+ "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", | |
+ outline: | |
+ "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", | |
+ secondary: | |
+ "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", | |
+ ghost: "hover:bg-accent hover:text-accent-foreground", | |
+ link: "text-primary underline-offset-4 hover:underline", | |
+ }, | |
+ size: { | |
+ default: "h-9 px-4 py-2", | |
+ xs: "h-7 rounded px-2", | |
+ sm: "h-8 rounded-md px-3 text-xs", | |
+ lg: "h-10 rounded-md px-8", | |
+ icon: "h-9 w-9", | |
+ }, | |
+ }, | |
+ defaultVariants: { | |
+ variant: "default", | |
+ size: "default", | |
+ }, | |
+ }, | |
+); | |
diff --git a/src/components/common/cards/ArticleCard.vue b/src/components/common/cards/ArticleCard.vue | |
new file mode 100644 | |
index 0000000..ba6f05a | |
--- /dev/null | |
+++ b/src/components/common/cards/ArticleCard.vue | |
@@ -0,0 +1,59 @@ | |
+<script setup> | |
+import { computed } from "vue"; | |
+const props = defineProps({ | |
+ variant: { | |
+ type: String, | |
+ default: "primary", | |
+ validator: (value) => ["primary", "secondary", "simple"].includes(value), | |
+ }, | |
+ backgroundImage: { | |
+ type: String, | |
+ required: true, | |
+ }, | |
+ heightClass: { | |
+ type: String, | |
+ default: "", | |
+ }, | |
+ title: { type: String, required: true }, | |
+ content: { type: String, required: false }, | |
+ date: { type: String, required: true }, | |
+ category: { type: String, required: true }, | |
+}); | |
+ | |
+const backgroundImageStyle = computed(() => ({ | |
+ backgroundImage: `url(${props.backgroundImage})`, | |
+ ...(props.variant === "secondary" && { | |
+ minHeight: "182px", | |
+ maxHeight: "182px", | |
+ }), | |
+})); | |
+</script> | |
+ | |
+<template> | |
+ <div class="flex flex-col gap-y-200" :class="heightClass"> | |
+ <div | |
+ class="flex-grow self-stretch bg-no-repeat bg-cover bg-center rounded-200" | |
+ :style="backgroundImageStyle" | |
+ ></div> | |
+ <div v-if="date && category" class="flex gap-x-200 items-center"> | |
+ <span class="text-overline text-primary"> | |
+ {{ date }} | |
+ </span> | |
+ <img src="@assets/icons/divisor.svg" alt="divisor" style="height: 16px" /> | |
+ <span class="text-overline text-primary"> | |
+ {{ category }} | |
+ </span> | |
+ </div> | |
+ <h3 class="text-header-sm text-primary"> | |
+ {{ props.title }} | |
+ </h3> | |
+ <p | |
+ v-if="props.variant === 'primary'" | |
+ class="text-body-regular text-primary" | |
+ > | |
+ {{ props.content }} | |
+ </p> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/ui/cards/ListCard.vue b/src/components/common/cards/ListCard.vue | |
similarity index 100% | |
rename from src/components/ui/cards/ListCard.vue | |
rename to src/components/common/cards/ListCard.vue | |
diff --git a/src/components/common/cards/QuickLinkCard.vue b/src/components/common/cards/QuickLinkCard.vue | |
new file mode 100644 | |
index 0000000..9f237e8 | |
--- /dev/null | |
+++ b/src/components/common/cards/QuickLinkCard.vue | |
@@ -0,0 +1,39 @@ | |
+<script setup> | |
+import { ButtonText } from "@/components/common/buttons"; | |
+ | |
+const props = defineProps({ | |
+ title: { | |
+ type: String, | |
+ required: true, | |
+ }, | |
+ description: { | |
+ type: String, | |
+ required: true, | |
+ }, | |
+ href: { | |
+ type: String, | |
+ default: "#", | |
+ }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <div | |
+ class="flex flex-col gap-y-5 border-l border-neutrals-300 px-[1.25rem] py-0" | |
+ > | |
+ <ButtonText | |
+ tag="a" | |
+ :href="href" | |
+ variant="dark" | |
+ size="md" | |
+ :text="props.title" | |
+ /> | |
+ <p | |
+ class="text-neutrals-900 font-body text-md font-light leading-[24px] tracking-wid" | |
+ > | |
+ {{ props.description }} | |
+ </p> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/common/forms/components/ComboboxComponent.vue b/src/components/common/forms/components/ComboboxComponent.vue | |
new file mode 100644 | |
index 0000000..2f47f8b | |
--- /dev/null | |
+++ b/src/components/common/forms/components/ComboboxComponent.vue | |
@@ -0,0 +1,121 @@ | |
+<script setup> | |
+import { Check, ChevronsUpDown } from "lucide-vue-next"; | |
+import { watch, ref } from "vue"; | |
+import { Button } from "@/components/common/buttons"; | |
+import { | |
+ Command, | |
+ CommandEmpty, | |
+ CommandGroup, | |
+ CommandInput, | |
+ CommandItem, | |
+ CommandList, | |
+} from "@/components/base/command"; | |
+import { | |
+ Popover, | |
+ PopoverContent, | |
+ PopoverTrigger, | |
+} from "@/components/base/popover"; | |
+import { cn } from "@/lib/utils"; | |
+ | |
+// const frameworks = [ | |
+// { value: 'next.js', label: 'Next.js' }, | |
+// { value: 'sveltekit', label: 'SvelteKit' }, | |
+// { value: 'nuxt', label: 'Nuxt' }, | |
+// { value: 'remix', label: 'Remix' }, | |
+// { value: 'astro', label: 'Astro' }, | |
+// ] | |
+ | |
+const open = ref(false); | |
+ | |
+// Props definition for v-model support | |
+const props = defineProps({ | |
+ collection: { type: Array, required: true }, | |
+ modelValue: { type: String, default: "" }, // v-model prop | |
+ placeholder: { type: String, default: "placeholder.select" }, | |
+ withIcon: { type: Boolean, default: false }, | |
+}); | |
+ | |
+// Emit definition for v-model support | |
+const emit = defineEmits(["update:modelValue"]); | |
+ | |
+// Internal value that syncs with modelValue | |
+const value = ref(props.modelValue); | |
+ | |
+// Watch external changes and update internal state | |
+watch( | |
+ () => props.modelValue, | |
+ (newValue) => { | |
+ value.value = newValue; | |
+ }, | |
+); | |
+ | |
+// Watch internal changes and notify parent | |
+watch(value, (newValue) => { | |
+ emit("update:modelValue", newValue); | |
+}); | |
+ | |
+const handleSelect = (selectedValue) => { | |
+ value.value = selectedValue; // Update internal value | |
+ open.value = false; // Close dropdown | |
+ // The watch will automatically emit the change to parent | |
+}; | |
+ | |
+const iconColor = (value) => | |
+ props.collection.find((item) => item.value === value)?.iconColor; | |
+</script> | |
+ | |
+<template> | |
+ <Popover v-model:open="open"> | |
+ <PopoverTrigger as-child> | |
+ <Button | |
+ variant="outline" | |
+ role="combobox" | |
+ :aria-expanded="open" | |
+ class="w-[-webkit-fill-available] justify-between m-100" | |
+ > | |
+ <div | |
+ class="flex gap-300 items-center" | |
+ :class="value ? 'text-primary' : 'text-secondary-gray'" | |
+ > | |
+ <span | |
+ v-if="withIcon && value" | |
+ :class="iconColor(value)" | |
+ style="width: 8px; height: 8px; border-radius: 50%" | |
+ ></span> | |
+ {{ | |
+ value | |
+ ? props.collection.find((item) => item.value === value)?.label | |
+ : $t(props.placeholder) | |
+ }} | |
+ </div> | |
+ <ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" /> | |
+ </Button> | |
+ </PopoverTrigger> | |
+ <PopoverContent class="min-w-[var(--reka-popper-anchor-width)] p-0"> | |
+ <Command v-model="value"> | |
+ <CommandInput placeholder="Search..." /> | |
+ <CommandEmpty>No framework found.</CommandEmpty> | |
+ <CommandList> | |
+ <CommandGroup> | |
+ <CommandItem | |
+ v-for="item in collection" | |
+ :key="item.value" | |
+ :value="item.value" | |
+ @select="handleSelect(item.value)" | |
+ > | |
+ <Check | |
+ :class=" | |
+ cn( | |
+ 'mr-2 h-4 w-4', | |
+ value === item.value ? 'opacity-100' : 'opacity-0', | |
+ ) | |
+ " | |
+ /> | |
+ {{ item.label }} | |
+ </CommandItem> | |
+ </CommandGroup> | |
+ </CommandList> | |
+ </Command> | |
+ </PopoverContent> | |
+ </Popover> | |
+</template> | |
diff --git a/src/components/common/forms/components/DatePickerComponent.vue b/src/components/common/forms/components/DatePickerComponent.vue | |
new file mode 100644 | |
index 0000000..ec68b2b | |
--- /dev/null | |
+++ b/src/components/common/forms/components/DatePickerComponent.vue | |
@@ -0,0 +1,72 @@ | |
+<script setup> | |
+import { | |
+ DateFormatter, | |
+ // DateValue, | |
+ getLocalTimeZone, | |
+} from "@internationalized/date"; | |
+import { CalendarIcon } from "lucide-vue-next"; | |
+ | |
+import { watch, ref } from "vue"; | |
+import { cn } from "@/lib/utils"; | |
+import { Button } from "@/components/common/buttons"; | |
+import { Calendar } from "@/components/base/calendar"; | |
+import { | |
+ Popover, | |
+ PopoverContent, | |
+ PopoverTrigger, | |
+} from "@/components/base/popover"; | |
+ | |
+import { useI18n } from "vue-i18n"; | |
+const { locale } = useI18n(); | |
+ | |
+const df = new DateFormatter(locale.value, { | |
+ dateStyle: "long", | |
+}); | |
+ | |
+const props = defineProps({ | |
+ modelValue: { type: Object, default: null }, | |
+}); | |
+// Emit definition for v-model support | |
+const emit = defineEmits(["update:modelValue"]); | |
+ | |
+const value = ref(props.modelValue); | |
+ | |
+// Watch external changes and update internal state | |
+watch( | |
+ () => props.modelValue, | |
+ (newValue) => { | |
+ value.value = newValue; | |
+ }, | |
+); | |
+ | |
+// Watch internal changes and notify parent | |
+watch(value, (newValue) => { | |
+ emit("update:modelValue", newValue); | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <Popover> | |
+ <PopoverTrigger as-child> | |
+ <Button | |
+ variant="outline" | |
+ :class=" | |
+ cn( | |
+ 'w-[-webkit-fill-available] m-100 justify-start text-left font-normal', | |
+ !value && 'text-muted-foreground', | |
+ ) | |
+ " | |
+ > | |
+ <CalendarIcon class="mr-2 h-4 w-4" /> | |
+ {{ | |
+ value | |
+ ? df.format(value.toDate(getLocalTimeZone())) | |
+ : $t("datepicker.pick_date") | |
+ }} | |
+ </Button> | |
+ </PopoverTrigger> | |
+ <PopoverContent class="w-auto p-0"> | |
+ <Calendar v-model="value" :locale="locale" initial-focus /> | |
+ </PopoverContent> | |
+ </Popover> | |
+</template> | |
diff --git a/src/components/common/forms/components/SelectComponent.vue b/src/components/common/forms/components/SelectComponent.vue | |
new file mode 100644 | |
index 0000000..233f120 | |
--- /dev/null | |
+++ b/src/components/common/forms/components/SelectComponent.vue | |
@@ -0,0 +1,41 @@ | |
+<script setup> | |
+import { ref } from "vue"; | |
+import { | |
+ Select, | |
+ SelectContent, | |
+ SelectGroup, | |
+ SelectItem, | |
+ SelectLabel, | |
+ SelectTrigger, | |
+ SelectValue, | |
+} from "@/components/base/select"; | |
+ | |
+const props = defineProps({ | |
+ options: { type: Array, required: true }, | |
+ placeholder: { type: String, default: "placeholder.select" }, | |
+ label: { type: String, default: undefined }, | |
+}); | |
+ | |
+const value = ref(); | |
+</script> | |
+ | |
+<template> | |
+ <Select> | |
+ <SelectTrigger class="m-100"> | |
+ <SelectValue :placeholder="$t(props.placeholder)" /> | |
+ </SelectTrigger> | |
+ <SelectContent> | |
+ <SelectGroup> | |
+ <SelectLabel>{{ props.label }}</SelectLabel> | |
+ <SelectItem | |
+ v-for="option in props.options" | |
+ :key="option.value" | |
+ v-model="value" | |
+ :value="option.value" | |
+ > | |
+ {{ option.label }} | |
+ </SelectItem> | |
+ </SelectGroup> | |
+ </SelectContent> | |
+ </Select> | |
+</template> | |
diff --git a/src/components/inputs/CheckboxInput.vue b/src/components/common/forms/inputs/CheckboxInput.vue | |
similarity index 69% | |
rename from src/components/inputs/CheckboxInput.vue | |
rename to src/components/common/forms/inputs/CheckboxInput.vue | |
index 13de1f5..3a118bd 100644 | |
--- a/src/components/inputs/CheckboxInput.vue | |
+++ b/src/components/common/forms/inputs/CheckboxInput.vue | |
@@ -1,6 +1,6 @@ | |
<script setup> | |
-import { computed } from 'vue'; | |
-import { IconCheck } from '@/components/ui/icons' | |
+import { computed } from "vue"; | |
+import { IconCheck } from "@/components/common/icons"; | |
// Props | |
const props = defineProps({ | |
label: { type: String, required: true }, | |
@@ -10,14 +10,15 @@ const props = defineProps({ | |
const checkedColorClass = computed(() => { | |
const colors = { | |
- default: 'checked:bg-white-transp-1000 checked:border-neutrals-900' | |
- } | |
- return colors.default | |
-}) | |
+ default: "checked:bg-white-transp-1000 checked:border-neutrals-900", | |
+ }; | |
+ return colors.default; | |
+}); | |
const modelValue = defineModel({ type: Boolean, default: false }); | |
-const checkboxId = computed(() => props.id || `checkbox-${Math.random().toString(36).slice(2, 9)}`); | |
- | |
+const checkboxId = computed( | |
+ () => props.id || `checkbox-${Math.random().toString(36).slice(2, 9)}`, | |
+); | |
</script> | |
<template> | |
@@ -38,13 +39,15 @@ const checkboxId = computed(() => props.id || `checkbox-${Math.random().toString | |
v-model="modelValue" | |
:aria-checked="modelValue" | |
/> | |
- <IconCheck class="opacity-0 peer-checked:opacity-100 pointer-events-none absolute" color="text-neutrals-900"/> | |
- <span class="text-gray-900 font-body text-sm leading-[150%]">{{ props.label }}</span> | |
+ <IconCheck | |
+ class="opacity-0 peer-checked:opacity-100 pointer-events-none absolute" | |
+ color="text-neutrals-900" | |
+ /> | |
+ <span class="text-gray-900 font-body text-sm leading-[150%]">{{ | |
+ props.label | |
+ }}</span> | |
</label> | |
- | |
</div> | |
- | |
- | |
</template> | |
<style scoped></style> | |
diff --git a/src/components/common/forms/inputs/TextInput.vue b/src/components/common/forms/inputs/TextInput.vue | |
new file mode 100644 | |
index 0000000..6ab652a | |
--- /dev/null | |
+++ b/src/components/common/forms/inputs/TextInput.vue | |
@@ -0,0 +1,33 @@ | |
+<script setup> | |
+const props = defineProps({ | |
+ id: { type: [String], required: true }, | |
+ label: { type: String, default: null }, | |
+ name: { type: String, default: null }, | |
+ value: { type: String, default: undefined }, | |
+ placeholder: { type: String, default: undefined }, | |
+ disabled: { type: Boolean, default: false }, | |
+}); | |
+ | |
+const emit = defineEmits(["update:value"]); | |
+ | |
+const baseStyle = | |
+ "px-400 py-200 border border-neutrals-300 text-neutrals-900 rounded-100 text-sm leading-[150%] font-body placeholder-neutrals-400"; | |
+const disableStyle = | |
+ "disabled:bg-neutrals-300 disabled:placeholder-neutrals-600 disabled:text-neutrals-600 disabled:border-neutrals-300 disabled:shadow-none"; | |
+const focusStyle = "focus:outline-none focus:border-neutrals-600"; | |
+</script> | |
+<template> | |
+ <div> | |
+ <label v-if="props.label" :for="props.id">{{ props.label }}</label> | |
+ <input | |
+ type="text" | |
+ :name="props.name || props.id" | |
+ :id="props.id" | |
+ :placeholder="props.placeholder" | |
+ :disabled="props.disabled" | |
+ :value="props.value" | |
+ @input="emit('update:value', $event.target.value)" | |
+ :class="[baseStyle, disableStyle, focusStyle]" | |
+ /> | |
+ </div> | |
+</template> | |
diff --git a/src/components/ui/icons/BaseIcon.vue b/src/components/common/icons/BaseIcon.vue | |
similarity index 77% | |
rename from src/components/ui/icons/BaseIcon.vue | |
rename to src/components/common/icons/BaseIcon.vue | |
index d73bdb0..b69d54d 100644 | |
--- a/src/components/ui/icons/BaseIcon.vue | |
+++ b/src/components/common/icons/BaseIcon.vue | |
@@ -1,19 +1,17 @@ | |
<script setup> | |
-import { computed } from 'vue'; | |
+import { computed } from "vue"; | |
const props = defineProps({ | |
width: { type: String, default: "20" }, | |
height: { type: String, default: "20" }, | |
className: { type: String, default: "text-neutrals-1000" }, | |
viewbox: { type: String, default: "0 0 20 20" }, | |
title: { type: String, default: "" }, | |
- active: { type: Boolean, default: false } | |
+ active: { type: Boolean, default: false }, | |
}); | |
-const fillColor = computed(() => ( | |
- props.active | |
- ? 'url(#grad)' | |
- : 'currentColor' | |
-)); | |
+const fillColor = computed(() => | |
+ props.active ? "url(#grad)" : "currentColor", | |
+); | |
</script> | |
<template> | |
@@ -36,8 +34,8 @@ const fillColor = computed(() => ( | |
y2="15" | |
gradientUnits="userSpaceOnUse" | |
> | |
- <stop stop-color="#FF007F"/> | |
- <stop offset="1" stop-color="#FF7F00"/> | |
+ <stop stop-color="#FF007F" /> | |
+ <stop offset="1" stop-color="#FF7F00" /> | |
</linearGradient> | |
</defs> | |
<title v-if="props.title">{{ props.title }}</title> | |
diff --git a/src/components/ui/icons/actions/IconClose.vue b/src/components/common/icons/actions/IconClose.vue | |
similarity index 88% | |
rename from src/components/ui/icons/actions/IconClose.vue | |
rename to src/components/common/icons/actions/IconClose.vue | |
index e220ec1..66f44dc 100644 | |
--- a/src/components/ui/icons/actions/IconClose.vue | |
+++ b/src/components/common/icons/actions/IconClose.vue | |
@@ -1,12 +1,15 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
+const props = defineProps({ | |
+ color: { type: String, default: "text-neutrals-900" }, | |
+}); | |
</script> | |
<template> | |
- <BaseIcon viewBox="0 0 21 20"> | |
+ <BaseIcon viewBox="0 0 21 20" :className="props.color"> | |
<path | |
d="M5.97659 5.8074C6.03464 5.74919 6.10361 5.70301 6.17954 5.67151C6.25547 5.64 6.33688 5.62378 6.41909 5.62378C6.50129 5.62378 6.5827 5.64 6.65863 5.67151C6.73456 5.70301 6.80353 5.74919 6.86159 5.8074L10.1691 9.11615L13.4766 5.8074C13.5347 5.74929 13.6037 5.70319 13.6796 5.67174C13.7555 5.64029 13.8369 5.62411 13.9191 5.62411C14.0013 5.62411 14.0826 5.64029 14.1586 5.67174C14.2345 5.70319 14.3035 5.74929 14.3616 5.8074C14.4197 5.86551 14.4658 5.93449 14.4972 6.01042C14.5287 6.08634 14.5449 6.16772 14.5449 6.2499C14.5449 6.33208 14.5287 6.41345 14.4972 6.48938C14.4658 6.5653 14.4197 6.63429 14.3616 6.6924L11.0528 9.9999L14.3616 13.3074C14.4197 13.3655 14.4658 13.4345 14.4972 13.5104C14.5287 13.5863 14.5449 13.6677 14.5449 13.7499C14.5449 13.8321 14.5287 13.9135 14.4972 13.9894C14.4658 14.0653 14.4197 14.1343 14.3616 14.1924C14.3035 14.2505 14.2345 14.2966 14.1586 14.328C14.0826 14.3595 14.0013 14.3757 13.9191 14.3757C13.8369 14.3757 13.7555 14.3595 13.6796 14.328C13.6037 14.2966 13.5347 14.2505 13.4766 14.1924L10.1691 10.8836L6.86159 14.1924C6.80348 14.2505 6.73449 14.2966 6.65857 14.328C6.58264 14.3595 6.50127 14.3757 6.41909 14.3757C6.33691 14.3757 6.25553 14.3595 6.17961 14.328C6.10368 14.2966 6.0347 14.2505 5.97659 14.1924C5.91848 14.1343 5.87238 14.0653 5.84093 13.9894C5.80948 13.9135 5.7933 13.8321 5.7933 13.7499C5.7933 13.6677 5.80948 13.5863 5.84093 13.5104C5.87238 13.4345 5.91848 13.3655 5.97659 13.3074L9.28534 9.9999L5.97659 6.6924C5.91838 6.63434 5.8722 6.56537 5.84069 6.48944C5.80919 6.41351 5.79297 6.33211 5.79297 6.2499C5.79297 6.16769 5.80919 6.08629 5.84069 6.01035C5.8722 5.93442 5.91838 5.86545 5.97659 5.8074Z" | |
- fill="black" | |
+ fill="currentColor" | |
/> | |
</BaseIcon> | |
</template> | |
diff --git a/src/components/ui/icons/actions/IconFilter.vue b/src/components/common/icons/actions/IconFilter.vue | |
similarity index 84% | |
rename from src/components/ui/icons/actions/IconFilter.vue | |
rename to src/components/common/icons/actions/IconFilter.vue | |
index 071be1f..bf6b1db 100644 | |
--- a/src/components/ui/icons/actions/IconFilter.vue | |
+++ b/src/components/common/icons/actions/IconFilter.vue | |
@@ -1,12 +1,15 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
+const props = defineProps({ | |
+ color: { Type: String, default: "text-primary" }, | |
+}); | |
</script> | |
<template> | |
- <BaseIcon viewBox="0 0 21 20"> | |
+ <BaseIcon viewBox="0 0 21 20" :className="props.color"> | |
<path | |
d="M6.71429 14.2857C6.71429 14.0963 6.78954 13.9146 6.9235 13.7806C7.05745 13.6467 7.23913 13.5714 7.42857 13.5714H11.7143C11.9037 13.5714 12.0854 13.6467 12.2194 13.7806C12.3533 13.9146 12.4286 14.0963 12.4286 14.2857C12.4286 14.4752 12.3533 14.6568 12.2194 14.7908C12.0854 14.9247 11.9037 15 11.7143 15H7.42857C7.23913 15 7.05745 14.9247 6.9235 14.7908C6.78954 14.6568 6.71429 14.4752 6.71429 14.2857ZM3.85714 10C3.85714 9.81056 3.9324 9.62888 4.06635 9.49492C4.20031 9.36097 4.38199 9.28571 4.57143 9.28571H14.5714C14.7609 9.28571 14.9426 9.36097 15.0765 9.49492C15.2105 9.62888 15.2857 9.81056 15.2857 10C15.2857 10.1894 15.2105 10.3711 15.0765 10.5051C14.9426 10.639 14.7609 10.7143 14.5714 10.7143H4.57143C4.38199 10.7143 4.20031 10.639 4.06635 10.5051C3.9324 10.3711 3.85714 10.1894 3.85714 10ZM1 5.71429C1 5.52485 1.07526 5.34316 1.20921 5.20921C1.34316 5.07526 1.52485 5 1.71429 5H17.4286C17.618 5 17.7997 5.07526 17.9337 5.20921C18.0676 5.34316 18.1429 5.52485 18.1429 5.71429C18.1429 5.90373 18.0676 6.08541 17.9337 6.21936C17.7997 6.35332 17.618 6.42857 17.4286 6.42857H1.71429C1.52485 6.42857 1.34316 6.35332 1.20921 6.21936C1.07526 6.08541 1 5.90373 1 5.71429Z" | |
- fill="black" | |
+ fill="currentColor" | |
/> | |
</BaseIcon> | |
</template> | |
diff --git a/src/components/ui/icons/actions/IconPlus.vue b/src/components/common/icons/actions/IconPlus.vue | |
similarity index 91% | |
rename from src/components/ui/icons/actions/IconPlus.vue | |
rename to src/components/common/icons/actions/IconPlus.vue | |
index 687e472..c1f729e 100644 | |
--- a/src/components/ui/icons/actions/IconPlus.vue | |
+++ b/src/components/common/icons/actions/IconPlus.vue | |
@@ -1,11 +1,11 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
const props = defineProps({ | |
iconColor: { | |
type: String, | |
- default: "inherit" | |
- } | |
-}) | |
+ default: "inherit", | |
+ }, | |
+}); | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/actions/IconSearch.vue b/src/components/common/icons/actions/IconSearch.vue | |
similarity index 85% | |
rename from src/components/ui/icons/actions/IconSearch.vue | |
rename to src/components/common/icons/actions/IconSearch.vue | |
index 565d38f..b5d73b9 100644 | |
--- a/src/components/ui/icons/actions/IconSearch.vue | |
+++ b/src/components/common/icons/actions/IconSearch.vue | |
@@ -1,12 +1,14 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
+const props = defineProps({ | |
+ color: { Type: String, default: "text-primary" }, | |
+}); | |
</script> | |
- | |
<template> | |
- <BaseIcon viewBox="0 0 21 20"> | |
+ <BaseIcon viewBox="0 0 21 20" :className="props.color"> | |
<path | |
d="M13.9259 12.5048C14.9093 11.1627 15.3498 9.49876 15.1592 7.84583C14.9686 6.19289 14.161 4.67288 12.8979 3.5899C11.6349 2.50692 10.0096 1.94083 8.34715 2.0049C6.68472 2.06897 5.10778 2.75846 3.93181 3.93543C2.75585 5.1124 2.06759 6.69005 2.00472 8.35276C1.94185 10.0155 2.50902 11.6406 3.59275 12.903C4.67648 14.1655 6.19685 14.9721 7.8497 15.1616C9.50254 15.351 11.166 14.9093 12.5072 13.9248H12.5061C12.5359 13.9654 12.5691 14.0044 12.6057 14.0416L16.5157 17.9522C16.7061 18.1428 16.9644 18.2499 17.2338 18.25C17.5033 18.2501 17.7617 18.1432 17.9522 17.9527C18.1428 17.7622 18.2499 17.5039 18.25 17.2344C18.2501 16.965 18.1432 16.7065 17.9527 16.5159L14.0427 12.6054C14.0064 12.5686 13.9674 12.5357 13.9259 12.5048ZM14.188 8.60036C14.188 9.33399 14.0435 10.0604 13.7628 10.7382C13.4821 11.416 13.0706 12.0319 12.5519 12.5506C12.0332 13.0694 11.4175 13.4809 10.7398 13.7616C10.0621 14.0424 9.33575 14.1869 8.60223 14.1869C7.8687 14.1869 7.14235 14.0424 6.46466 13.7616C5.78697 13.4809 5.1712 13.0694 4.65252 12.5506C4.13384 12.0319 3.7224 11.416 3.44169 10.7382C3.16098 10.0604 3.0165 9.33399 3.0165 8.60036C3.0165 7.11873 3.60499 5.69777 4.65252 4.6501C5.70005 3.60243 7.1208 3.01385 8.60223 3.01385C10.0837 3.01385 11.5044 3.60243 12.5519 4.6501C13.5995 5.69777 14.188 7.11873 14.188 8.60036Z" | |
- fill="#3B3935" | |
+ fill="currentColor" | |
/> | |
</BaseIcon> | |
</template> | |
diff --git a/src/components/common/icons/index.js b/src/components/common/icons/index.js | |
new file mode 100644 | |
index 0000000..da2bfad | |
--- /dev/null | |
+++ b/src/components/common/icons/index.js | |
@@ -0,0 +1,26 @@ | |
+export { default as BaseIcon } from "./BaseIcon.vue"; | |
+ | |
+// Actions | |
+export { default as IconClose } from "./actions/IconClose.vue"; | |
+export { default as IconFilter } from "./actions/IconFilter.vue"; | |
+export { default as IconPlus } from "./actions/IconPlus.vue"; | |
+export { default as IconSearch } from "./actions/IconSearch.vue"; | |
+ | |
+// Navigation | |
+export { default as IconCarretUp } from "./navigation/IconCarretUp.vue"; | |
+export { default as IconChevronLeft } from "./navigation/IconChevronLeft.vue"; | |
+export { default as IconChevronRight } from "./navigation/IconChevronRight.vue"; | |
+export { default as IconMenu } from "./navigation/IconMenu.vue"; | |
+ | |
+// Status | |
+export { default as IconCheck } from "./status/IconCheck.vue"; | |
+ | |
+// Misc | |
+export { default as IconChange } from "./misc/IconChange.vue"; | |
+export { default as IconClock } from "./misc/IconClock.vue"; | |
+export { default as IconDash } from "./misc/IconDash.vue"; | |
+export { default as IconInfo } from "./misc/IconInfo.vue"; | |
+export { default as IconLink } from "./misc/IconLink.vue"; | |
+export { default as IconNewUser } from "./misc/IconNewUser.vue"; | |
+export { default as IconPin } from "./misc/IconPin.vue"; | |
+export { default as IconProgram } from "./misc/IconProgram.vue"; | |
diff --git a/src/components/common/icons/misc/IconChange.vue b/src/components/common/icons/misc/IconChange.vue | |
new file mode 100644 | |
index 0000000..4af2fbb | |
--- /dev/null | |
+++ b/src/components/common/icons/misc/IconChange.vue | |
@@ -0,0 +1,32 @@ | |
+<script setup> | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
+const props = defineProps({ | |
+ color: { type: String, default: undefined }, | |
+ active: { type: Boolean, default: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <BaseIcon viewBox="0 0 21 20" :active="active" :className="props.color"> | |
+ <template #default="{ fill }"> | |
+ <g clip-path="url(#clip0_28_1820)"> | |
+ <path | |
+ d="M10.1693 0.666748C5.01727 0.666748 0.835938 4.84808 0.835938 10.0001C0.835938 15.1521 5.01727 19.3334 10.1693 19.3334C15.3213 19.3334 19.5026 15.1521 19.5026 10.0001C19.5026 4.84808 15.3213 0.666748 10.1693 0.666748ZM11.0186 15.7401C10.9534 15.804 10.8708 15.8474 10.7812 15.8649C10.6915 15.8824 10.5987 15.8732 10.5142 15.8384C10.4297 15.8036 10.3573 15.7448 10.306 15.6692C10.2546 15.5937 10.2266 15.5048 10.2253 15.4134V14.6667H10.1693C8.9746 14.6667 7.77994 14.2094 6.86527 13.3041C6.23156 12.6688 5.79422 11.8645 5.60553 10.9873C5.41685 10.1101 5.4848 9.19704 5.80127 8.35741C5.9786 7.88141 6.60394 7.76008 6.9586 8.12408C7.16394 8.32941 7.2106 8.62808 7.11727 8.88941C6.68794 10.0467 6.9306 11.3907 7.86394 12.3241C8.51727 12.9774 9.37594 13.2854 10.2346 13.2667V12.3894C10.2346 11.9694 10.7386 11.7641 11.0279 12.0627L12.5399 13.5747C12.7266 13.7614 12.7266 14.0507 12.5399 14.2374L11.0186 15.7401ZM13.3799 11.8854C13.2835 11.7861 13.2173 11.6614 13.1892 11.5258C13.1611 11.3903 13.1722 11.2495 13.2213 11.1201C13.6506 9.96275 13.4079 8.61875 12.4746 7.68541C11.8213 7.03208 10.9626 6.71475 10.1133 6.73341V7.61075C10.1133 8.03075 9.60927 8.23608 9.31994 7.93741L7.7986 6.43475C7.61194 6.24808 7.61194 5.95875 7.7986 5.77208L9.3106 4.26008C9.37583 4.19613 9.45839 4.15273 9.54805 4.13525C9.63771 4.11778 9.73053 4.12701 9.815 4.16179C9.89947 4.19657 9.97187 4.25537 10.0232 4.33091C10.0746 4.40646 10.1027 4.49541 10.1039 4.58675V5.34275C11.3173 5.32408 12.5399 5.76275 13.4639 6.69608C14.0976 7.33133 14.535 8.13566 14.7237 9.01288C14.9124 9.89011 14.8444 10.8031 14.5279 11.6427C14.3506 12.1281 13.7346 12.2494 13.3799 11.8854Z" | |
+ :fill="fill" | |
+ /> | |
+ </g> | |
+ <defs> | |
+ <clipPath id="clip0_28_1820"> | |
+ <rect | |
+ width="20" | |
+ height="20" | |
+ fill="white" | |
+ transform="translate(0.167969)" | |
+ /> | |
+ </clipPath> | |
+ </defs> | |
+ </template> | |
+ </BaseIcon> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/ui/icons/misc/IconClock.vue b/src/components/common/icons/misc/IconClock.vue | |
similarity index 83% | |
rename from src/components/ui/icons/misc/IconClock.vue | |
rename to src/components/common/icons/misc/IconClock.vue | |
index 66cee30..2f0a3b8 100644 | |
--- a/src/components/ui/icons/misc/IconClock.vue | |
+++ b/src/components/common/icons/misc/IconClock.vue | |
@@ -1,13 +1,18 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue"; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
const props = defineProps({ | |
color: { type: String, default: undefined }, | |
- active: { type: Boolean, default: false } | |
-}) | |
+ active: { type: Boolean, default: false }, | |
+}); | |
</script> | |
<template> | |
- <BaseIcon viewBox="0 0 21 20" width="20" :active="active" :className="props.color"> | |
+ <BaseIcon | |
+ viewBox="0 0 21 20" | |
+ width="20" | |
+ :active="active" | |
+ :className="props.color" | |
+ > | |
<template #default="{ fill }"> | |
<g clip-path="url(#clip0_28_1817)"> | |
<path | |
diff --git a/src/components/ui/icons/misc/IconDash.vue b/src/components/common/icons/misc/IconDash.vue | |
similarity index 89% | |
rename from src/components/ui/icons/misc/IconDash.vue | |
rename to src/components/common/icons/misc/IconDash.vue | |
index f3377f2..40dfce8 100644 | |
--- a/src/components/ui/icons/misc/IconDash.vue | |
+++ b/src/components/common/icons/misc/IconDash.vue | |
@@ -1,5 +1,5 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/misc/IconInfo.vue b/src/components/common/icons/misc/IconInfo.vue | |
similarity index 96% | |
rename from src/components/ui/icons/misc/IconInfo.vue | |
rename to src/components/common/icons/misc/IconInfo.vue | |
index 4a27b4a..1ee3b0a 100644 | |
--- a/src/components/ui/icons/misc/IconInfo.vue | |
+++ b/src/components/common/icons/misc/IconInfo.vue | |
@@ -1,5 +1,5 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/misc/IconLink.vue b/src/components/common/icons/misc/IconLink.vue | |
similarity index 95% | |
rename from src/components/ui/icons/misc/IconLink.vue | |
rename to src/components/common/icons/misc/IconLink.vue | |
index 6cb7f5b..23277ed 100644 | |
--- a/src/components/ui/icons/misc/IconLink.vue | |
+++ b/src/components/common/icons/misc/IconLink.vue | |
@@ -1,9 +1,9 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue"; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
const props = defineProps({ | |
color: { type: String, default: undefined }, | |
- active: { type: Boolean, default: false } | |
-}) | |
+ active: { type: Boolean, default: false }, | |
+}); | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/misc/IconNewUser.vue b/src/components/common/icons/misc/IconNewUser.vue | |
similarity index 88% | |
rename from src/components/ui/icons/misc/IconNewUser.vue | |
rename to src/components/common/icons/misc/IconNewUser.vue | |
index c3c8ff4..77afed1 100644 | |
--- a/src/components/ui/icons/misc/IconNewUser.vue | |
+++ b/src/components/common/icons/misc/IconNewUser.vue | |
@@ -1,13 +1,18 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue"; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
const props = defineProps({ | |
color: { type: String, default: undefined }, | |
- active: { type: Boolean, default: false } | |
-}) | |
+ active: { type: Boolean, default: false }, | |
+}); | |
</script> | |
<template> | |
- <BaseIcon viewBox="0 0 19 20" width="19" :active="active" :className="props.color"> | |
+ <BaseIcon | |
+ viewBox="0 0 19 20" | |
+ width="19" | |
+ :active="active" | |
+ :className="props.color" | |
+ > | |
<template #default="{ fill }"> | |
<path | |
d="M12.5573 3.65356C13.0493 5.73742 11.7589 7.82577 9.67496 8.31763C7.59124 8.80979 5.50311 7.5194 5.01087 5.4355C4.51886 3.35163 5.80926 1.26328 7.89316 0.771426C9.97703 0.279266 12.0654 1.56966 12.5573 3.65356Z" | |
diff --git a/src/components/ui/icons/misc/IconPin.vue b/src/components/common/icons/misc/IconPin.vue | |
similarity index 92% | |
rename from src/components/ui/icons/misc/IconPin.vue | |
rename to src/components/common/icons/misc/IconPin.vue | |
index f42662e..8dc44a7 100644 | |
--- a/src/components/ui/icons/misc/IconPin.vue | |
+++ b/src/components/common/icons/misc/IconPin.vue | |
@@ -1,5 +1,5 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/misc/IconProgram.vue b/src/components/common/icons/misc/IconProgram.vue | |
similarity index 96% | |
rename from src/components/ui/icons/misc/IconProgram.vue | |
rename to src/components/common/icons/misc/IconProgram.vue | |
index 69dd26d..fa41526 100644 | |
--- a/src/components/ui/icons/misc/IconProgram.vue | |
+++ b/src/components/common/icons/misc/IconProgram.vue | |
@@ -1,9 +1,9 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue"; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
const props = defineProps({ | |
color: { type: String, default: undefined }, | |
- active: { type: Boolean, default: false } | |
-}) | |
+ active: { type: Boolean, default: false }, | |
+}); | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/navigation/IconCarretUp.vue b/src/components/common/icons/navigation/IconCarretUp.vue | |
similarity index 100% | |
rename from src/components/ui/icons/navigation/IconCarretUp.vue | |
rename to src/components/common/icons/navigation/IconCarretUp.vue | |
diff --git a/src/components/ui/icons/navigation/IconChevronLeft.vue b/src/components/common/icons/navigation/IconChevronLeft.vue | |
similarity index 94% | |
rename from src/components/ui/icons/navigation/IconChevronLeft.vue | |
rename to src/components/common/icons/navigation/IconChevronLeft.vue | |
index 1090f29..f8476ad 100644 | |
--- a/src/components/ui/icons/navigation/IconChevronLeft.vue | |
+++ b/src/components/common/icons/navigation/IconChevronLeft.vue | |
@@ -1,5 +1,5 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/navigation/IconChevronRight.vue b/src/components/common/icons/navigation/IconChevronRight.vue | |
similarity index 94% | |
rename from src/components/ui/icons/navigation/IconChevronRight.vue | |
rename to src/components/common/icons/navigation/IconChevronRight.vue | |
index a7f9893..e31aa71 100644 | |
--- a/src/components/ui/icons/navigation/IconChevronRight.vue | |
+++ b/src/components/common/icons/navigation/IconChevronRight.vue | |
@@ -1,5 +1,5 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/navigation/IconMenu.vue b/src/components/common/icons/navigation/IconMenu.vue | |
similarity index 95% | |
rename from src/components/ui/icons/navigation/IconMenu.vue | |
rename to src/components/common/icons/navigation/IconMenu.vue | |
index 520b680..0f7f98c 100644 | |
--- a/src/components/ui/icons/navigation/IconMenu.vue | |
+++ b/src/components/common/icons/navigation/IconMenu.vue | |
@@ -1,5 +1,5 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
</script> | |
<template> | |
diff --git a/src/components/ui/icons/status/IconCheck.vue b/src/components/common/icons/status/IconCheck.vue | |
similarity index 89% | |
rename from src/components/ui/icons/status/IconCheck.vue | |
rename to src/components/common/icons/status/IconCheck.vue | |
index c00fbb2..deb4aef 100644 | |
--- a/src/components/ui/icons/status/IconCheck.vue | |
+++ b/src/components/common/icons/status/IconCheck.vue | |
@@ -1,8 +1,8 @@ | |
<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue";; | |
+import BaseIcon from "@/components/common/icons/BaseIcon.vue"; | |
const props = defineProps({ | |
- color: { type: String, default: 'text-neutral-1000' } | |
-}) | |
+ color: { type: String, default: "text-neutral-1000" }, | |
+}); | |
</script> | |
<template> | |
diff --git a/src/components/common/notifications/ToastNotification.vue b/src/components/common/notifications/ToastNotification.vue | |
new file mode 100644 | |
index 0000000..b66f035 | |
--- /dev/null | |
+++ b/src/components/common/notifications/ToastNotification.vue | |
@@ -0,0 +1,97 @@ | |
+<script setup> | |
+import { computed, ref, onMounted } from "vue"; | |
+ | |
+const props = defineProps({ | |
+ message: { type: String, required: true }, | |
+ description: { type: String, default: "" }, | |
+ type: { type: String, default: "success" }, // success | error | info | warning | |
+ duration: { type: Number, default: 3000 }, // Auto-close in ms | |
+}); | |
+ | |
+const emit = defineEmits(["close"]); | |
+ | |
+const visible = ref(true); | |
+ | |
+onMounted(() => { | |
+ if (props.duration > 0) { | |
+ setTimeout(() => { | |
+ visible.value = false; | |
+ emit("close"); | |
+ }, props.duration); | |
+ } | |
+}); | |
+ | |
+const icon = computed(() => { | |
+ switch (props.type) { | |
+ case "success": | |
+ return "✔"; // You can replace with an SVG icon | |
+ case "error": | |
+ return `<svg | |
+ xmlns="http://www.w3.org/2000/svg" | |
+ class="text-vermelho-400" | |
+ width="50" | |
+ height="50" | |
+ viewBox="0 0 20 20" | |
+ fill="none" | |
+ aria-hidden="true" | |
+ > | |
+ <path | |
+ d="M5.97659 5.8074C6.03464 5.74919 6.10361 5.70301 6.17954 5.67151C6.25547 5.64 6.33688 5.62378 6.41909 5.62378C6.50129 5.62378 6.5827 5.64 6.65863 5.67151C6.73456 5.70301 6.80353 5.74919 6.86159 5.8074L10.1691 9.11615L13.4766 5.8074C13.5347 5.74929 13.6037 5.70319 13.6796 5.67174C13.7555 5.64029 13.8369 5.62411 13.9191 5.62411C14.0013 5.62411 14.0826 5.64029 14.1586 5.67174C14.2345 5.70319 14.3035 5.74929 14.3616 5.8074C14.4197 5.86551 14.4658 5.93449 14.4972 6.01042C14.5287 6.08634 14.5449 6.16772 14.5449 6.2499C14.5449 6.33208 14.5287 6.41345 14.4972 6.48938C14.4658 6.5653 14.4197 6.63429 14.3616 6.6924L11.0528 9.9999L14.3616 13.3074C14.4197 13.3655 14.4658 13.4345 14.4972 13.5104C14.5287 13.5863 14.5449 13.6677 14.5449 13.7499C14.5449 13.8321 14.5287 13.9135 14.4972 13.9894C14.4658 14.0653 14.4197 14.1343 14.3616 14.1924C14.3035 14.2505 14.2345 14.2966 14.1586 14.328C14.0826 14.3595 14.0013 14.3757 13.9191 14.3757C13.8369 14.3757 13.7555 14.3595 13.6796 14.328C13.6037 14.2966 13.5347 14.2505 13.4766 14.1924L10.1691 10.8836L6.86159 14.1924C6.80348 14.2505 6.73449 14.2966 6.65857 14.328C6.58264 14.3595 6.50127 14.3757 6.41909 14.3757C6.33691 14.3757 6.25553 14.3595 6.17961 14.328C6.10368 14.2966 6.0347 14.2505 5.97659 14.1924C5.91848 14.1343 5.87238 14.0653 5.84093 13.9894C5.80948 13.9135 5.7933 13.8321 5.7933 13.7499C5.7933 13.6677 5.80948 13.5863 5.84093 13.5104C5.87238 13.4345 5.91848 13.3655 5.97659 13.3074L9.28534 9.9999L5.97659 6.6924C5.91838 6.63434 5.8722 6.56537 5.84069 6.48944C5.80919 6.41351 5.79297 6.33211 5.79297 6.2499C5.79297 6.16769 5.80919 6.08629 5.84069 6.01035C5.8722 5.93442 5.91838 5.86545 5.97659 5.8074Z" | |
+ fill="currentColor" | |
+ /> | |
+ </svg>`; | |
+ case "info": | |
+ return "ℹ"; | |
+ case "warning": | |
+ return "⚠"; | |
+ default: | |
+ return ""; | |
+ } | |
+}); | |
+ | |
+const styleDefault = | |
+ "align-self: baseline; justify-self: anchor-center; bottom: 2rem;"; | |
+</script> | |
+ | |
+<template> | |
+ <transition name="fade"> | |
+ <div | |
+ v-if="visible" | |
+ :style="styleDefault" | |
+ class="absolute botom-800 right-800 flex items-center gap-3 p-4 bg-white rounded-lg shadow-lg border border-gray-200 w-full max-w-sm" | |
+ > | |
+ <!-- Icon --> | |
+ <div v-html="icon" class="flex-shrink-0 text-green-500 text-xl"></div> | |
+ | |
+ <!-- Message --> | |
+ <div class="flex-1"> | |
+ <p class="font-semibold text-gray-800">{{ message }}</p> | |
+ <p v-if="description" class="text-sm text-gray-500"> | |
+ {{ description }} | |
+ </p> | |
+ </div> | |
+ | |
+ <!-- Close Button --> | |
+ <button | |
+ @click=" | |
+ visible = false; | |
+ emit('close'); | |
+ " | |
+ class="text-gray-400 hover:text-gray-600" | |
+ > | |
+ ✖ | |
+ </button> | |
+ </div> | |
+ </transition> | |
+</template> | |
+ | |
+<style scoped> | |
+.fade-enter-active, | |
+.fade-leave-active { | |
+ transition: opacity 0.3s; | |
+} | |
+.fade-enter-from, | |
+.fade-leave-to { | |
+ opacity: 0; | |
+} | |
+</style> | |
diff --git a/src/components/common/tags/TagFilter.vue b/src/components/common/tags/TagFilter.vue | |
new file mode 100644 | |
index 0000000..89f4914 | |
--- /dev/null | |
+++ b/src/components/common/tags/TagFilter.vue | |
@@ -0,0 +1,23 @@ | |
+<script setup> | |
+import { IconClose } from "@/components/common/icons"; | |
+const { text } = defineProps({ | |
+ text: { | |
+ type: String, | |
+ required: true, | |
+ }, | |
+}); | |
+ | |
+const emit = defineEmits(["remove-filter"]); | |
+const removeSelf = () => { | |
+ emit("remove-filter", text); | |
+}; | |
+</script> | |
+ | |
+<template> | |
+ <span | |
+ class="max-w-fit inline-flex items-center gap-100 px-200 py-100 border rounded-full border-neutrals-300 font-body text-xs text-neutrals-700 font-regular leading-[18px]" | |
+ >{{ text }} <IconClose class="cursor-pointer" @click="removeSelf" | |
+ /></span> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/common/tags/TagMostra.vue b/src/components/common/tags/TagMostra.vue | |
new file mode 100644 | |
index 0000000..4cd6df8 | |
--- /dev/null | |
+++ b/src/components/common/tags/TagMostra.vue | |
@@ -0,0 +1,94 @@ | |
+<script> | |
+const DEFAULT_CLASS = "bg-magenta-600 text-white-transp-1000"; | |
+const VARIANT_CLASSES = { | |
+ "gala-abertura": { | |
+ filled: "bg-magenta-600 text-white-transp-1000", | |
+ outline: "border-l-4 border-magenta-600 text-neutrals-900", | |
+ }, | |
+ "gala-encerramento": { | |
+ filled: "bg-magenta-800 text-white", | |
+ outline: "border-l-4 border-magenta-800 text-neutrals-900", | |
+ }, | |
+ resistencias: { | |
+ filled: "bg-white-transp-1000 text-neutrals-900", | |
+ outline: "border-l-4 border-white-transp-1000 text-neutrals-900", | |
+ }, | |
+ "cine-memoria": { | |
+ filled: "bg-violeta-200 text-neutrals-900", | |
+ outline: "border-l-4 border-violeta-200 text-neutrals-900", | |
+ }, | |
+ "midnight-movies": { | |
+ filled: "bg-violeta-600 text-white-transp-1000", | |
+ outline: "border-l-4 border-violeta-600 text-neutrals-900", | |
+ }, | |
+ "cinema-capacete": { | |
+ filled: "bg-neutrals-400 text-neutrals-900", | |
+ outline: "border-l-4 border-neutrals-400 text-neutrals-900", | |
+ }, | |
+ "classicos--cults": { | |
+ filled: "bg-neutrals-900 text-white-transp-1000", | |
+ outline: "border-l-4 border-neutrals-900 text-neutrals-900", | |
+ }, | |
+ expectativas: { | |
+ filled: "bg-azul-600 text-white-transp-1000", | |
+ outline: "border-l-4 border-azul-600 text-neutrals-900", | |
+ }, | |
+ "itinerarios-unicos": { | |
+ filled: "bg-verde-600 text-white-transp-1000", | |
+ outline: "border-l-4 border-verde-600 text-neutrals-900", | |
+ }, | |
+ "panorama-mundial": { | |
+ filled: "bg-vermelho-600 text-white-transp-1000", | |
+ outline: "border-l-4 border-vermelho-600 text-neutrals-900", | |
+ }, | |
+ "premiere-latina": { | |
+ filled: "bg-amarelo-800 text-neutrals-900", | |
+ outline: "border-l-4 border-amarelo-800 text-neutrals-900", | |
+ }, | |
+ "premiere-brasil": { | |
+ filled: "bg-laranja-600 text-neutrals-900", | |
+ outline: "border-l-4 border-laranja-600 text-neutrals-900", | |
+ }, | |
+}; | |
+</script> | |
+<script setup> | |
+import { computed } from "vue"; | |
+const props = defineProps({ | |
+ variant: { | |
+ type: String, | |
+ required: true, | |
+ // validator: (value) => Object.keys(VARIANT_CLASSES).includes(value), | |
+ default: "cinema-capacete", | |
+ }, | |
+ text: { type: String, default: "TBD" }, | |
+ mode: { | |
+ type: String, | |
+ validator: (value) => ["filled", "outline"].includes(value), | |
+ default: "filled", | |
+ }, | |
+}); | |
+ | |
+const finalClass = computed(() => { | |
+ const variantClass = VARIANT_CLASSES[props.variant]?.[props.mode]; | |
+ | |
+ if (!variantClass) { | |
+ // console.warn( | |
+ // `Invalid combination: variant="${props.variant}" mode="${props.mode}. DISPLAYING DEFAULT"`, | |
+ // ); | |
+ return DEFAULT_CLASS; | |
+ } | |
+ | |
+ return variantClass; | |
+}); | |
+const displayText = computed(() => props.text?.trim() || "TBD"); | |
+</script> | |
+ | |
+<template> | |
+ <span | |
+ class="py-100 px-250 uppercase font-body text-2xs font-medium leading-[16px] tracking-widest rounded-br-100 max-w-fit" | |
+ :class="finalClass" | |
+ role="label" | |
+ :aria-label="`${variant} tag`" | |
+ >{{ displayText }}</span | |
+ > | |
+</template> | |
diff --git a/src/components/common/tags/TagScreening.vue b/src/components/common/tags/TagScreening.vue | |
new file mode 100644 | |
index 0000000..67217a6 | |
--- /dev/null | |
+++ b/src/components/common/tags/TagScreening.vue | |
@@ -0,0 +1,36 @@ | |
+<script setup> | |
+import { computed } from "vue"; | |
+ | |
+const props = defineProps({ | |
+ state: { | |
+ type: String, | |
+ default: "default", | |
+ validator: (value) => ["default", "active", "disabled"].includes(value), | |
+ }, | |
+ time: { | |
+ type: String, | |
+ default: "21h30", | |
+ }, | |
+}); | |
+ | |
+const baseClasses = `inline-flex items-center px-200 py-100 border rounded-sm max-w-fit | |
+ text-2xs leading-[16px] tracking-widest | |
+ font-body font-medium uppercase`; | |
+ | |
+const stateClasses = computed(() => ({ | |
+ "bg-neutrals-100 border-neutrals-300 text-neutrals-900": | |
+ props.state === "default", | |
+ "bg-neutrals-100 border-neutrals-900 text-neutrals-900": | |
+ props.state === "active", | |
+ "bg-neutrals-300 text-neutrals-600 cursor-not-allowed": | |
+ props.state === "disabled", | |
+})); | |
+ | |
+const isDisabled = computed(() => props.state === "disabled"); | |
+</script> | |
+ | |
+<template> | |
+ <p :class="[baseClasses, stateClasses]" :aria-disabled="isDisabled"> | |
+ {{ time }} | |
+ </p> | |
+</template> | |
diff --git a/src/components/features/filters/components/FilterActions.vue b/src/components/features/filters/components/FilterActions.vue | |
new file mode 100644 | |
index 0000000..0c0bc85 | |
--- /dev/null | |
+++ b/src/components/features/filters/components/FilterActions.vue | |
@@ -0,0 +1,30 @@ | |
+<script setup> | |
+import { BaseButton } from "@/components/common/buttons"; | |
+import { ButtonText } from "@/components/common/buttons"; | |
+ | |
+const emit = defineEmits(["clear", "apply"]); | |
+const props = defineProps({ | |
+ hasActiveFilters: { type: Boolean, default: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <div class="flex justify-between"> | |
+ <ButtonText | |
+ size="sm" | |
+ tag="button" | |
+ text="Limpar tudo" | |
+ @click="emit('clear')" | |
+ :disabled="!props.hasActiveFilters" | |
+ /> | |
+ <BaseButton | |
+ size="sm" | |
+ variant="dark" | |
+ @click="emit('apply')" | |
+ :disabled="!props.hasActiveFilters" | |
+ >Aplicar filtros</BaseButton | |
+ > | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/features/filters/components/FilterForm.vue b/src/components/features/filters/components/FilterForm.vue | |
new file mode 100644 | |
index 0000000..2bb28fe | |
--- /dev/null | |
+++ b/src/components/features/filters/components/FilterForm.vue | |
@@ -0,0 +1,251 @@ | |
+<script setup> | |
+import { computed } from "vue"; | |
+import AccordionGroup from "@/components/base/accordion/AccordionGroup.vue"; | |
+import ComboboxComponent from "@/components/common/forms/components/ComboboxComponent.vue"; | |
+import DatePickerComponent from "@/components/common/forms/components/DatePickerComponent.vue"; | |
+import SelectComponent from "@/components/common/forms/components/SelectComponent.vue"; | |
+import { collection, showcases } from "@/lib/fakeData"; | |
+import { generateTimeOptions } from "@/components/features/filters/composables/useTimeOptions"; | |
+ | |
+const props = defineProps({ | |
+ modelValue: { type: Object, required: true }, | |
+}); | |
+const emit = defineEmits(["update:modelValue"]); | |
+const updateField = (key, value) => { | |
+ emit("update:modelValue", { ...props.modelValue, [key]: value }); | |
+}; | |
+const timeOptions = computed(() => generateTimeOptions()); | |
+const submostras = [ | |
+ { | |
+ label: "Clássicos & Cults", | |
+ value: "Clássicos & Cults", | |
+ iconColor: "bg-neutrals-900", | |
+ }, | |
+ { | |
+ label: "Première Latina", | |
+ value: "Première Latina", | |
+ iconColor: "bg-amarelo-800", | |
+ }, | |
+ { | |
+ label: "Itinerários Únicos", | |
+ value: "Itinerários Únicos", | |
+ iconColor: "bg-verde-600", | |
+ }, | |
+ { | |
+ label: "Première Brasil", | |
+ value: "Première Brasil", | |
+ iconColor: "bg-laranja-600", | |
+ }, | |
+ { | |
+ label: "Midnight Movies", | |
+ value: "Midnight Movies", | |
+ iconColor: "bg-violeta-600", | |
+ }, | |
+ { | |
+ label: "Expectativas", | |
+ value: "Expectativas", | |
+ iconColor: "bg-azul-600", | |
+ }, | |
+ { | |
+ label: "Especial COP 30", | |
+ value: "Especial COP 30", | |
+ iconColor: "bg-violeta-600", | |
+ }, | |
+ { | |
+ label: "Cinema Capacete", | |
+ value: "Cinema Capacete", | |
+ iconColor: "bg-laranja-600", | |
+ }, | |
+ { | |
+ label: "Panorama Mundial", | |
+ value: "Panorama Mundial", | |
+ iconColor: "bg-vermelho-600", | |
+ }, | |
+]; | |
+</script> | |
+ | |
+<template> | |
+ <div class="flex-grow flex flex-col space-y-600 overflow-y-auto"> | |
+ <AccordionGroup | |
+ :text="$t('filter.date')" | |
+ :isOpen="props.modelValue.date != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400"> | |
+ <DatePickerComponent | |
+ :modelValue="props.modelValue.date" | |
+ @update:modelValue="(val) => updateField('date', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.time')" | |
+ :isOpen=" | |
+ props.modelValue.startTime != null || props.modelValue.endTime != null | |
+ " | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <div class="flex items-center gap-400"> | |
+ <SelectComponent | |
+ class="m-400" | |
+ :modelValue="props.modelValue.startTime" | |
+ @update:modelValue="(val) => updateField('startTime', val)" | |
+ :options="timeOptions" | |
+ /> | |
+ <SelectComponent | |
+ class="m-400" | |
+ :modelValue="props.modelValue.endTime" | |
+ @update:modelValue="(val) => updateField('endTime', val)" | |
+ :options="timeOptions" | |
+ /> | |
+ </div> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.submostra')" | |
+ :isOpen="props.modelValue.submostra != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :with-icon="true" | |
+ :collection="submostras" | |
+ :modelValue="props.modelValue.submostra" | |
+ @update:modelValue="(val) => updateField('submostra', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.cinema')" | |
+ :isOpen="props.modelValue.cinema != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.cinema" | |
+ @update:modelValue="(val) => updateField('cinema', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.genero')" | |
+ :isOpen="props.modelValue.genero != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.genero" | |
+ @update:modelValue="(val) => updateField('genero', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.pais')" | |
+ :isOpen="props.modelValue.pais != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.pais" | |
+ @update:modelValue="(val) => updateField('pais', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.direcao')" | |
+ :isOpen="props.modelValue.direcao != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="collection" | |
+ :modelValue="props.modelValue.direcao" | |
+ @update:modelValue="(val) => updateField('direcao', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.elenco')" | |
+ :isOpen="props.modelValue.elenco != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.elenco" | |
+ @update:modelValue="(val) => updateField('elenco', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.selo')" | |
+ :isOpen="props.modelValue.selo != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.selo" | |
+ @update:modelValue="(val) => updateField('selo', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.festivais')" | |
+ :isOpen="props.modelValue.festivais != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.festivais" | |
+ @update:modelValue="(val) => updateField('festivais', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.premios')" | |
+ :isOpen="props.modelValue.premios != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.premios" | |
+ @update:modelValue="(val) => updateField('premios', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ <AccordionGroup | |
+ :text="$t('filter.palavras_chaves')" | |
+ :isOpen="props.modelValue.palavrasChaves != null" | |
+ > | |
+ <template v-slot:content> | |
+ <div class="pt-400 overflow-hidden"> | |
+ <ComboboxComponent | |
+ :collection="showcases" | |
+ :modelValue="props.modelValue.palavrasChaves" | |
+ @update:modelValue="(val) => updateField('palavrasChaves', val)" | |
+ /> | |
+ </div> | |
+ </template> | |
+ </AccordionGroup> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/features/filters/components/MobileFilterMenu.vue b/src/components/features/filters/components/MobileFilterMenu.vue | |
new file mode 100644 | |
index 0000000..dec43fe | |
--- /dev/null | |
+++ b/src/components/features/filters/components/MobileFilterMenu.vue | |
@@ -0,0 +1,56 @@ | |
+<script setup> | |
+import { ref, watch } from "vue"; | |
+import { IconClose } from "@/components/common/icons"; | |
+import SearchFilter from "@/components/features/filters/components/SearchFilter.vue"; | |
+import TwContainer from "@/components/layout/TwContainer.vue"; | |
+ | |
+const props = defineProps({ | |
+ isOpen: { type: Boolean, required: true }, | |
+ modelValue: { type: Object, required: true }, | |
+}); | |
+ | |
+const internalFilters = ref(props.modelValue); | |
+ | |
+watch(internalFilters, (val) => emit("update:modelValue", val), { deep: true }); | |
+ | |
+const emit = defineEmits([ | |
+ "update:modelValue", | |
+ "filtersApplied", | |
+ "filtersCleared", | |
+ "close-filter-menu", | |
+]); | |
+</script> | |
+ | |
+<template> | |
+ <div | |
+ v-if="props.isOpen" | |
+ style="margin-top: 0" | |
+ class="fixed inset-0 z-50 bg-white flex flex-col w-full max-w-full h-[100vh] right-0 shadow-lg overflow-y-auto" | |
+ > | |
+ <TwContainer> | |
+ <div class="flex flex-col"> | |
+ <!-- Filter header --> | |
+ <div | |
+ class="shrink-0 flex justify-between items-center py-400 sticky top-0 bg-white-transp-1000 z-10" | |
+ > | |
+ <p class="text-header-sm text-primary uppercase"> | |
+ {{ $t("filtro", 2) }} | |
+ </p> | |
+ <button @click="emit('close-filter-menu')" class="text-neutrals-900"> | |
+ <IconClose height="32px" width="32px" /> | |
+ </button> | |
+ </div> | |
+ <!-- Filter header --> | |
+ <SearchFilter | |
+ v-model="internalFilters" | |
+ @update:modelValue="(val) => emit('update:modelValue', val)" | |
+ @filtersApplied="emit('filtersApplied', $event)" | |
+ @filtersCleared="emit('filtersCleared', $event)" | |
+ @close-filter-menu="emit('close-filter-menu')" | |
+ /> | |
+ </div> | |
+ </TwContainer> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/features/filters/components/SearchBar.vue b/src/components/features/filters/components/SearchBar.vue | |
new file mode 100644 | |
index 0000000..d3a5247 | |
--- /dev/null | |
+++ b/src/components/features/filters/components/SearchBar.vue | |
@@ -0,0 +1,45 @@ | |
+<script setup> | |
+import { IconSearch, IconClose } from "@/components/common/icons"; | |
+ | |
+defineProps({ | |
+ modelValue: String, | |
+}); | |
+ | |
+const emit = defineEmits(["update:modelValue", "search", "clear"]); | |
+ | |
+const handleInput = (event) => { | |
+ emit("update:modelValue", event.target.value); | |
+}; | |
+ | |
+const cleanInput = () => { | |
+ emit("update:modelValue", ""); | |
+ emit("clear"); | |
+}; | |
+</script> | |
+ | |
+<template> | |
+ <div class="input"> | |
+ <div class="relative"> | |
+ <div | |
+ class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none" | |
+ > | |
+ <IconSearch color="text-primary" /> | |
+ </div> | |
+ <input | |
+ :value="modelValue" | |
+ @input="handleInput" | |
+ @keyup.enter="emit('search')" | |
+ type="text" | |
+ placeholder="Pesquisar" | |
+ class="w-full md:w-96 pl-10 pr-8 py-2.5 border border-neutrals-300 rounded-[5px] font-body leading-[150%] text-sm text-neutrals-900 placeholder-neutrals-400 focus:outline-none focus:border-neutrals-600 disabled:bg-neutrals-300 disabled:placeholder-neutrals-600 disabled:text-neutrals-600 disabled:border-neutrals-300 disabled:shadow-none transition-all duration-200" | |
+ /> | |
+ <div class="absolute inset-y-0 right-0 pr-3 flex items-center"> | |
+ <button v-if="modelValue" @click="cleanInput"> | |
+ <IconClose /> | |
+ </button> | |
+ </div> | |
+ </div> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/features/filters/components/SearchFilter.vue b/src/components/features/filters/components/SearchFilter.vue | |
new file mode 100644 | |
index 0000000..015dfaa | |
--- /dev/null | |
+++ b/src/components/features/filters/components/SearchFilter.vue | |
@@ -0,0 +1,68 @@ | |
+<script setup> | |
+import { computed } from "vue"; | |
+import { toRaw } from "vue"; | |
+// import { IconClose, } from "@/components/common/icons"; | |
+// import TwContainer from "@/components/layout/TwContainer.vue"; | |
+import { cleanObject, toHHMM } from "@/utils/helpers/objectHelpers"; | |
+import FilterActions from "@/components/features/filters/components/FilterActions.vue"; | |
+import FilterForm from "./FilterForm.vue"; | |
+ | |
+const closeMenu = () => { | |
+ emit("close-filter-menu", false); | |
+}; | |
+ | |
+const props = defineProps({ | |
+ modelValue: { type: Object, required: true }, | |
+}); | |
+ | |
+const emit = defineEmits([ | |
+ "filtersApplied", | |
+ "filtersCleared", | |
+ "close-filter-menu", | |
+ "update:modelValue", | |
+]); | |
+ | |
+const applyFilters = () => { | |
+ const rawFilters = toRaw(props.modelValue); | |
+ const cleanedFilters = cleanObject(rawFilters); | |
+ if (cleanedFilters.startTime) { | |
+ // debugger | |
+ cleanedFilters.startTime = toHHMM(cleanedFilters.startTime); | |
+ } | |
+ if (cleanedFilters.endTime) { | |
+ // debugger | |
+ cleanedFilters.endTime = toHHMM(cleanedFilters.endTime); | |
+ } | |
+ emit("filtersApplied", cleanedFilters); | |
+ closeMenu(); | |
+}; | |
+ | |
+const clearAllFilters = () => { | |
+ const cleared = Object.fromEntries( | |
+ Object.keys(props.modelValue).map((key) => [key, null]), | |
+ ); | |
+ emit("update:modelValue", cleared); | |
+ emit("filtersCleared", cleared); | |
+}; | |
+ | |
+const hasActiveFilters = computed(() => { | |
+ return Object.values(props.modelValue).some((value) => value !== null); | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <FilterForm | |
+ :modelValue="props.modelValue" | |
+ @update:modelValue="emit('update:modelValue', $event)" | |
+ /> | |
+ | |
+ <div | |
+ class="shrink-0 py-400 actions sticky bottom-0 bg-white-transp-1000 z-10" | |
+ > | |
+ <FilterActions | |
+ @clear="clearAllFilters" | |
+ @apply="applyFilters" | |
+ :hasActiveFilters="hasActiveFilters" | |
+ /> | |
+ </div> | |
+</template> | |
diff --git a/src/components/features/filters/composables/useFilters.js b/src/components/features/filters/composables/useFilters.js | |
new file mode 100644 | |
index 0000000..9c2b67c | |
--- /dev/null | |
+++ b/src/components/features/filters/composables/useFilters.js | |
@@ -0,0 +1,145 @@ | |
+import { useDebounce } from "@vueuse/core"; | |
+import { ref, computed } from "vue"; | |
+import { formatDate } from "@/utils/helpers/formatDateHelper"; | |
+ | |
+export function useFilters(allMovies = ref([])) { | |
+ const searchValue = ref(""); | |
+ const debouncedSearch = useDebounce(searchValue, 300); | |
+ | |
+ const handleClear = () => { | |
+ searchValue.value = ""; | |
+ // console.log("Clear filter by text?", searchValue.value == ""); | |
+ }; | |
+ | |
+ const handleSearch = () => { | |
+ // console.log(`Query: ${searchValue.value}`); | |
+ // console.warn(`QUERY API using ${searchValue.value}`); | |
+ // TODO: Trigger refetch or recompute | |
+ }; | |
+ | |
+ const filtersQuery = ref({}); | |
+ const filters = ref({ | |
+ date: null, | |
+ startTime: null, | |
+ endTime: null, | |
+ submostra: null, | |
+ cinema: null, | |
+ genero: null, | |
+ pais: null, | |
+ direcao: null, | |
+ elenco: null, | |
+ selo: null, | |
+ festivais: null, | |
+ premios: null, | |
+ palavrasChaves: null, | |
+ }); | |
+ | |
+ const filterSearch = (submittedFilters) => { | |
+ // await refetchFilters(); | |
+ console.log(submittedFilters); | |
+ | |
+ if (submittedFilters.date) { | |
+ submittedFilters.date = formatDate(submittedFilters.date); | |
+ } | |
+ filtersQuery.value = submittedFilters; | |
+ // console.log("Applied filters:", filtersQuery.value); | |
+ // console.warn("QUERY API/FILTER RESULT FROM API using:") | |
+ // console.log(filtersQuery.value) | |
+ }; | |
+ | |
+ const clearSearchQuery = (newValue) => { | |
+ filtersQuery.value = newValue; | |
+ Object.keys(filters.value).forEach((key) => (filters.value[key] = null)); | |
+ // console.log("CLEAR FILTERING"); | |
+ }; | |
+ | |
+ const removeQuery = (queryValue) => { | |
+ for (const key in filters.value) { | |
+ if (filtersQuery.value[key] === queryValue) { | |
+ delete filtersQuery.value[key]; | |
+ break; | |
+ } | |
+ } | |
+ | |
+ // update filters to keep UI in sync | |
+ Object.keys(filters.value).forEach((key) => { | |
+ filters.value[key] = Object.prototype.hasOwnProperty.call( | |
+ filtersQuery.value, | |
+ key, | |
+ ) | |
+ ? filtersQuery.value[key] | |
+ : null; | |
+ }); | |
+ | |
+ // console.log("Applied filters:", filtersQuery.value); | |
+ // console.warn("QUERY API/FILTER RESULT FROM API using:") | |
+ // console.log(filtersQuery.value) | |
+ }; | |
+ | |
+ // 🧠 Business logic - Filtering logic based on search text | |
+ const filteredMovies = computed(() => { | |
+ console.log(filtersQuery.value); | |
+ | |
+ const searchTerm = debouncedSearch.value.toLowerCase(); | |
+ const hasSearch = !!searchTerm; | |
+ const hasFilters = Object.values(filtersQuery.value).some( | |
+ (v) => v !== null && v !== "", | |
+ ); | |
+ | |
+ // ⛔ Nothing to filter? Return everything. | |
+ if (!hasSearch && !hasFilters) { | |
+ return allMovies.value; | |
+ } | |
+ | |
+ // ✅ Step 1: filter by search | |
+ const filteredBySearch = allMovies.value.filter((item) => { | |
+ const normalized = item._normalized || {}; | |
+ return ( | |
+ normalized.titulo_original.includes(searchTerm) || | |
+ normalized.titulo_ingles.includes(searchTerm) || | |
+ normalized.titulo_portugues.includes(searchTerm) | |
+ ); | |
+ }); | |
+ | |
+ // ✅ Step 2: filter by active filters | |
+ return filteredBySearch.filter((movie) => { | |
+ return Object.entries(filtersQuery.value).every(([key, value]) => { | |
+ if (!value) return true; | |
+ | |
+ if (key === "startTime" || key === "endTime") { | |
+ // debugger | |
+ const sessionTimeStr = movie.sessao; // "18:30", etc. | |
+ if (!sessionTimeStr) return false; | |
+ | |
+ // Convert to minutes since midnight for comparison | |
+ const [movieHour, movieMin] = sessionTimeStr.split(":").map(Number); | |
+ const movieMinutes = movieHour * 60 + movieMin; | |
+ | |
+ if (key === "startTime") { | |
+ const [filterHour, filterMin] = value.split(":").map(Number); | |
+ const filterMinutes = filterHour * 60 + filterMin; | |
+ return movieMinutes >= filterMinutes; | |
+ } | |
+ } | |
+ | |
+ const movieValue = movie[key]?.DATA || movie[key]; | |
+ | |
+ return normalize(movieValue).includes(normalize(value)); | |
+ }); | |
+ }); | |
+ }); | |
+ | |
+ const normalize = (str) => String(str).toLowerCase().trim(); | |
+ | |
+ return { | |
+ searchValue, | |
+ handleSearch, | |
+ handleClear, | |
+ filtersQuery, | |
+ filters, | |
+ filterSearch, | |
+ clearSearchQuery, | |
+ removeQuery, | |
+ filteredMovies, | |
+ }; | |
+} | |
diff --git a/src/components/features/filters/composables/useTimeOptions.js b/src/components/features/filters/composables/useTimeOptions.js | |
new file mode 100644 | |
index 0000000..0a03343 | |
--- /dev/null | |
+++ b/src/components/features/filters/composables/useTimeOptions.js | |
@@ -0,0 +1,39 @@ | |
+import { | |
+ CalendarDateTime, | |
+ Time, | |
+ DateFormatter, | |
+ getLocalTimeZone, | |
+} from "@internationalized/date"; | |
+import { useI18n } from "@/composables/useI18n"; | |
+ | |
+export function generateTimeOptions(intervalMinutes = 30) { | |
+ // Create a DateFormatter for time | |
+ const { locale } = useI18n(); | |
+ const timeFormatter = new DateFormatter(locale.value, { | |
+ timeStyle: "short", | |
+ }); | |
+ const options = []; | |
+ | |
+ for (let hour = 0; hour < 24; hour++) { | |
+ for (let minutes = 0; minutes < 60; minutes += intervalMinutes) { | |
+ const time = new Time(hour, minutes); | |
+ const dateTime = new CalendarDateTime( | |
+ 2025, | |
+ 1, | |
+ 1, // Dummy date | |
+ time.hour, | |
+ time.minute, | |
+ 0, | |
+ ); | |
+ const formatted = timeFormatter.format( | |
+ dateTime.toDate(getLocalTimeZone()), | |
+ ); | |
+ options.push({ | |
+ value: time.toString(), // "08:30" | |
+ label: formatted, // "8:30 AM" or "08:30" | |
+ }); | |
+ } | |
+ } | |
+ | |
+ return options; | |
+} | |
diff --git a/src/components/ui/HomeBanner.vue b/src/components/features/home/components/HomeBanner.vue | |
similarity index 58% | |
rename from src/components/ui/HomeBanner.vue | |
rename to src/components/features/home/components/HomeBanner.vue | |
index fb16e59..9248e19 100644 | |
--- a/src/components/ui/HomeBanner.vue | |
+++ b/src/components/features/home/components/HomeBanner.vue | |
@@ -1,31 +1,30 @@ | |
<script setup> | |
-import { computed } from 'vue'; | |
-import TwContainer from '../layout/TwContainer.vue'; | |
+import { computed } from "vue"; | |
+import TwContainer from "@/components/layout/TwContainer.vue"; | |
const props = defineProps({ | |
imagePath: { | |
type: String, | |
- required: true | |
+ required: true, | |
}, | |
height: { | |
type: String, | |
- default: "h-[573px]" | |
- } | |
-}) | |
+ default: "h-[573px]", | |
+ }, | |
+}); | |
-const mobileSizing = "height: 708px;" | |
+const mobileSizing = "height: 708px;"; | |
const backgroundImageStyle = computed(() => { | |
return `background-image: url(${props.imagePath}); | |
background-position: center; | |
background-size: cover; | |
- background-repeat: no-repeat;` | |
-}) | |
+ background-repeat: no-repeat;`; | |
+}); | |
</script> | |
<template> | |
- <div class="flex items-end" | |
- :style="[backgroundImageStyle, mobileSizing]"> | |
+ <div class="flex items-end" :style="[backgroundImageStyle, mobileSizing]"> | |
<TwContainer extra-classes="pb-[58px] lg:pb-[77px]"> | |
<slot /> | |
</TwContainer> | |
diff --git a/src/components/features/home/components/HomeView.vue b/src/components/features/home/components/HomeView.vue | |
new file mode 100644 | |
index 0000000..7c3d732 | |
--- /dev/null | |
+++ b/src/components/features/home/components/HomeView.vue | |
@@ -0,0 +1,163 @@ | |
+<script setup> | |
+import { computed } from "vue"; | |
+import { IconPlus } from "@/components/common/icons"; | |
+import QuickLinksSection from "@/components/features/home/components/QuickLinksSection.vue"; | |
+import HomeBanner from "@/components/features/home/components/HomeBanner.vue"; | |
+import TwContainer from "@/components/layout/TwContainer.vue"; | |
+import { ButtonText } from "@/components/common/buttons"; | |
+import ArticleCard from "@/components/common/cards/ArticleCard.vue"; | |
+import ContextMenu from "@/components/layout/navbar/ContextMenu.vue"; | |
+import { useI18n } from "@/composables/useI18n"; | |
+ | |
+import noticiaOneImage from "@/assets/poc-poster.jpg"; | |
+ | |
+const { t } = useI18n(); | |
+ | |
+// const quickLinks = [ | |
+// { | |
+// id: 1, | |
+// title: "PROGRAMAÇÃO", | |
+// description: | |
+// "Veja a programação completa ou filtre de acordo com o que deseja.", | |
+// href: "/programacao", | |
+// }, | |
+// { | |
+// id: 2, | |
+// title: "INGRESSOS", | |
+// description: "Descubra como garantir sua entrada nos cinemas e eventos.", | |
+// href: "/filmes", | |
+// }, | |
+// { | |
+// id: 3, | |
+// title: "MUDANÇAS NA PROGRAMAÇÃO", | |
+// description: "Planeje-se verificando as mudanças na programação.", | |
+// href: "/mudancas", | |
+// }, | |
+// ]; | |
+ | |
+// After (optimized with translation keys) | |
+const quickLinksConfig = [ | |
+ { | |
+ id: 1, | |
+ titleKey: "quickLinks.programming.title", | |
+ descriptionKey: "quickLinks.programming.description", | |
+ href: "/programacao", | |
+ }, | |
+ { | |
+ id: 2, | |
+ titleKey: "quickLinks.tickets.title", | |
+ descriptionKey: "quickLinks.tickets.description", | |
+ href: "/filmes", | |
+ }, | |
+ { | |
+ id: 3, | |
+ titleKey: "quickLinks.schedule.title", | |
+ descriptionKey: "quickLinks.schedule.description", | |
+ href: "/mudancas", | |
+ }, | |
+]; | |
+ | |
+const translatedLinks = computed(() => | |
+ quickLinksConfig.map((link) => ({ | |
+ ...link, | |
+ title: t(link.titleKey), | |
+ description: t(link.descriptionKey), | |
+ })), | |
+); | |
+</script> | |
+ | |
+<template> | |
+ <ContextMenu /> | |
+ <HomeBanner | |
+ imagePath="/src/assets/images/mobile-banner.png" | |
+ alt="Banner promocional" | |
+ > | |
+ <h1 class="text-header-base text-2xl lg:text-3xl mb-200 text-primary"> | |
+ {{ $t("home.banner_title") }} | |
+ </h1> | |
+ <p class="text-subheading text-primary"> | |
+ {{ $t("home.banner_subtitle") }} | |
+ </p> | |
+ </HomeBanner> | |
+ | |
+ <QuickLinksSection v-bind:links="translatedLinks" /> | |
+ | |
+ <TwContainer> | |
+ <div class="py-1200"> | |
+ <div class="flex flex-col gap-y-800"> | |
+ <h2 class="text-header-base text-3xl text-primary">Últimas notícias</h2> | |
+ | |
+ <div | |
+ class="flex flex-col gap-y-800 lg:grid lg:gap-x-800 lg:grid-cols-[2fr_1fr]" | |
+ > | |
+ <ArticleCard | |
+ variant="primary" | |
+ :background-image="noticiaOneImage" | |
+ heightClass="h-[589px]" | |
+ title="Pedaço de Mim, de Anne-Sophie Bailly, e Apocalipse nos Trópicos, de Petra Costa, estão entre as principais estreias da semana" | |
+ content="Os longas-metragens têm em comum a direção de cineastas mulheres e a presença destacada na programação do 26º Festival do Rio (2024)." | |
+ date="22.07.2025" | |
+ category="estreias da semana" | |
+ /> | |
+ <ArticleCard | |
+ variant="primary" | |
+ background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/507dcb8456c3c6939126d891a11725d5.jpeg" | |
+ heightClass="h-[472px]" | |
+ title="Festival do Rio na Bienal do Livro: O Auto da Compadecida será exibido na feira literária carioca" | |
+ content="Longa que adaptada célebre obra do escritor Ariano Suassuna terá uma projeção especial na Praça Além da Página Shell, nesta segunda-feira (16). A sessão é promovida pelo Festival do Rio em parceria com a Bienal do Livro, com apoio da Globo Filmes" | |
+ date="22.07.2025" | |
+ category="festival do rio" | |
+ /> | |
+ </div> | |
+ | |
+ <!-- secundaria --> | |
+ <div | |
+ class="flex flex-col gap-y-800 lg:grid lg:gap-x-800 lg:grid-cols-4" | |
+ > | |
+ <ArticleCard | |
+ variant="secondary" | |
+ background-image="../src/assets/images/noticia-two.png" | |
+ title="Talents Rio 2025: Projeto Paradiso renova apoio ao programa de formação de profissionais do audiovisual" | |
+ date="22.07.2025" | |
+ category="talents rio" | |
+ /> | |
+ <ArticleCard | |
+ variant="secondary" | |
+ background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/77ebda55831201b57c8b342db86e65a6.jpeg" | |
+ title="Dia Nacional do Documentário Brasileiro: conheça todos os vencedores do Troféu Redentor de melhor documentário no Festival do Rio" | |
+ date="22.05.2025" | |
+ category="premiere brasil" | |
+ /> | |
+ <ArticleCard | |
+ variant="secondary" | |
+ background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/c15a563d6d839971cc16c34aef3cf32e.jpg" | |
+ title="Festival do Rio celebra os vencedores do Prêmio Grande Otelo 2025" | |
+ date="12.04.2025" | |
+ category="festival do rio" | |
+ /> | |
+ <ArticleCard | |
+ variant="secondary" | |
+ background-image="https://s3.amazonaws.com/festivaldorio/files/imagens/21dc8f4c81fbb402b1725cb997d3a1ea.jpg" | |
+ title="Festival do Rio indica: 15 filmes românticos disponíveis no streaming para assistir no Dia dos Namorados" | |
+ date="22.03.2025" | |
+ category="festival do rio" | |
+ /> | |
+ </div> | |
+ | |
+ <ButtonText | |
+ tag="a" | |
+ text="Ver mais" | |
+ variant="dark" | |
+ size="md" | |
+ class="self-center hover:text-neutrals-700" | |
+ > | |
+ <template v-slot:icon> | |
+ <IconPlus class="me-100" /> | |
+ </template> | |
+ </ButtonText> | |
+ </div> | |
+ </div> | |
+ </TwContainer> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/layout/sections/QuickLinksSection.vue b/src/components/features/home/components/QuickLinksSection.vue | |
similarity index 64% | |
rename from src/components/layout/sections/QuickLinksSection.vue | |
rename to src/components/features/home/components/QuickLinksSection.vue | |
index b77e5c0..db2267d 100644 | |
--- a/src/components/layout/sections/QuickLinksSection.vue | |
+++ b/src/components/features/home/components/QuickLinksSection.vue | |
@@ -1,21 +1,20 @@ | |
<script setup> | |
-import QuickLinkCard from "@/components/ui/cards/QuickLinkCard.vue"; | |
-import TwContainer from "../TwContainer.vue"; | |
-import BaseHeader from "../../ui/typography/BaseHeader.vue"; | |
+import QuickLinkCard from "@/components/common/cards/QuickLinkCard.vue"; | |
+import TwContainer from "@/components/layout/TwContainer.vue"; | |
const props = defineProps({ | |
links: { | |
type: Array, | |
required: true, | |
- } | |
-}) | |
+ }, | |
+}); | |
</script> | |
<template> | |
<div class="bg-neutrals-200"> | |
<TwContainer> | |
<div class="py-800 flex flex-col space-y-800"> | |
- <BaseHeader font-size="text-xl" text-color="text-neutrals-900">Links Rápidos</BaseHeader> | |
+ <h3 class="text-header-base text-xl text-primary">Links Rápidos</h3> | |
<div class="flex flex-col gap-y-800 lg:flex-row"> | |
<QuickLinkCard | |
v-for="link in props.links" | |
@@ -30,6 +29,4 @@ const props = defineProps({ | |
</div> | |
</template> | |
-<style scoped> | |
- | |
-</style> | |
+<style scoped></style> | |
diff --git a/src/components/features/movies/components/MovieCard.vue b/src/components/features/movies/components/MovieCard.vue | |
new file mode 100644 | |
index 0000000..9afbd2a | |
--- /dev/null | |
+++ b/src/components/features/movies/components/MovieCard.vue | |
@@ -0,0 +1,139 @@ | |
+<script setup> | |
+import { computed, ref } from "vue"; | |
+ | |
+import TagMostra from "@/components/common/tags/TagMostra.vue"; | |
+import TagScreening from "@/components/common/tags/TagScreening.vue"; | |
+import { IconPin } from "@/components/common/icons"; | |
+import { useMovieLocalization } from "@/components/features/movies/composables/useMovieLocalization"; | |
+ | |
+// Hover state | |
+const isHovered = ref(false); | |
+const props = defineProps({ | |
+ movie: { type: Object, required: true }, | |
+}); | |
+ | |
+const moviePoster = computed(() => { | |
+ return props.movie.poster || "/public/poc-poster.jpg"; | |
+}); | |
+ | |
+const movieGenre = computed(() => { | |
+ return props.movie.genero || "TBD"; | |
+}); | |
+ | |
+const screenings = computed(() => { | |
+ if (props.movie.sessao) { | |
+ return [ | |
+ { time: "08h30", state: "disabled" }, | |
+ { time: props.movie.sessao.replace(":", "h"), state: "active" }, | |
+ ]; | |
+ } else { | |
+ return [ | |
+ { time: "08h30", state: "disabled" }, | |
+ { time: "21h00", state: "active" }, | |
+ ]; | |
+ } | |
+}); | |
+ | |
+const normalizeString = (str) => | |
+ str | |
+ .normalize("NFD") // Decompose accented characters | |
+ .replace(/[\u0300-\u036f]/g, "") // Remove diacritics | |
+ .replace(/[^a-zA-Z0-9\s-]/g, "") // Remove special characters (keep letters/numbers/spaces) | |
+ .trim(); | |
+ | |
+const mostraVariantName = computed(() => { | |
+ const lowerCaseName = normalizeString(props.movie.submostra.DATA) | |
+ .replaceAll(" ", "-") | |
+ .toLowerCase(); | |
+ return lowerCaseName; | |
+}); | |
+const { getLocalizedTitle } = useMovieLocalization(); | |
+</script> | |
+ | |
+<template> | |
+ <!-- w-[380px] --> | |
+ <div | |
+ class="flex flex-col items-start gap-200" | |
+ @mouseenter="isHovered = true" | |
+ @mouseleave="isHovered = false" | |
+ > | |
+ <!-- image --> | |
+ <div class="relative w-[697px]"> | |
+ <img | |
+ :src="moviePoster" | |
+ alt="movie-name poster" | |
+ class="rounded-200 h-[326px] w-full object-cover" | |
+ /> | |
+ <!-- Overlay --> | |
+ <div | |
+ class="absolute inset-0 rounded-200 pointer-events-none" | |
+ style=" | |
+ background: linear-gradient( | |
+ 180deg, | |
+ rgba(0, 0, 0, 0.3) 36.54%, | |
+ rgba(0, 0, 0, 0.45) 100% | |
+ ); | |
+ " | |
+ ></div> | |
+ <!-- tag --> | |
+ <TagMostra | |
+ class="absolute top-0 left-0 rounded-tl-200" | |
+ :variant="mostraVariantName" | |
+ :text="props.movie.submostra.DATA" | |
+ /> | |
+ | |
+ <div class="content absolute bottom-250 left-250 flex flex-col gap-[5px]"> | |
+ <!-- movie title --> | |
+ <h2 class="text-header-sm text-on-dark"> | |
+ {{ getLocalizedTitle(props.movie) }} | |
+ </h2> | |
+ <div class="flex items-center gap-200"> | |
+ <span class="text-overline text-on-dark-secondary">{{ | |
+ props.movie.paiscompleto_coord_int | |
+ }}</span> | |
+ <img | |
+ src="@assets/icons/divisor.svg" | |
+ alt="divisor" | |
+ height="16px" | |
+ width="1px" | |
+ /> | |
+ <span class="text-overline text-on-dark-secondary">{{ | |
+ movieGenre | |
+ }}</span> | |
+ <img | |
+ src="@assets/icons/divisor.svg" | |
+ alt="divisor" | |
+ height="16px" | |
+ width="1px" | |
+ /> | |
+ <span class="text-overline text-on-dark-secondary" | |
+ >{{ props.movie.duracao.DATA }}'</span | |
+ > | |
+ </div> | |
+ <!-- Animated underline --> | |
+ <span | |
+ class="w-full bg-white-transp-900 transition-height duration-100" | |
+ :style="{ height: isHovered ? '1px' : '0px' }" | |
+ ></span> | |
+ </div> | |
+ </div> | |
+ <div class="px-200 space-y-250 w-full"> | |
+ <div class="flex items-center gap-[6px]"> | |
+ <IconPin width="16" height="16" /> | |
+ <p class="text-body-regular text-primary"> | |
+ {{ props.movie.Cinema }} | |
+ </p> | |
+ </div> | |
+ <div class="flex items-center space-x-200"> | |
+ <TagScreening | |
+ v-for="screening in screenings" | |
+ :key="screening" | |
+ :time="screening.time" | |
+ :state="screening.state" | |
+ /> | |
+ </div> | |
+ </div> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/features/movies/components/MovieList.vue b/src/components/features/movies/components/MovieList.vue | |
new file mode 100644 | |
index 0000000..828a0be | |
--- /dev/null | |
+++ b/src/components/features/movies/components/MovieList.vue | |
@@ -0,0 +1,49 @@ | |
+<!-- src/components/layout/sections/MovieList.vue --> | |
+<script setup> | |
+import MovieCard from "@/components/features/movies/components/MovieCard.vue"; | |
+import ToastNotification from "@/components/common/notifications/ToastNotification.vue"; | |
+import { ref } from "vue"; | |
+ | |
+const showToast = ref(true); | |
+ | |
+const props = defineProps({ | |
+ movies: { required: false }, | |
+ isPending: { required: false }, | |
+ isFetching: { required: false }, | |
+ isError: { required: false }, | |
+ error: { required: false }, | |
+}); | |
+</script> | |
+ | |
+<template> | |
+ <section class="grid grid-cols-1 gap-800"> | |
+ <p v-if="isFetching">{{ $t("loading.title") }}</p> | |
+ <!-- Shows during background refresh --> | |
+ <template v-if="isPending"> | |
+ <ToastNotification | |
+ v-if="showToast" | |
+ :message="$t('loading.movies')" | |
+ description="Very very soon." | |
+ type="info" | |
+ :duration="5000" | |
+ @close="showToast = false" | |
+ /> | |
+ <!-- Or skeleton loader --> | |
+ </template> | |
+ <template v-else-if="isError"> | |
+ <p class="text-red-500">{{ console.log(error) }}</p> | |
+ <ToastNotification | |
+ :description="error.message" | |
+ :message="error.name" | |
+ type="error" | |
+ /> | |
+ </template> | |
+ <template v-else> | |
+ <MovieCard | |
+ v-for="movie in props.movies || []" | |
+ :key="movie.RECORDID" | |
+ :movie="movie" | |
+ /> | |
+ </template> | |
+ </section> | |
+</template> | |
diff --git a/src/components/features/movies/composables/useMovieLocalization.js b/src/components/features/movies/composables/useMovieLocalization.js | |
new file mode 100644 | |
index 0000000..2ebd4ef | |
--- /dev/null | |
+++ b/src/components/features/movies/composables/useMovieLocalization.js | |
@@ -0,0 +1,25 @@ | |
+// composables/useMovieLocalization.js | |
+// import { computed } from 'vue' | |
+import { useI18n } from "vue-i18n"; | |
+ | |
+export function useMovieLocalization() { | |
+ const { locale } = useI18n(); | |
+ | |
+ const getLocalizedTitle = (movie) => { | |
+ return locale.value === "pt" | |
+ ? movie.titulo_original?.DATA | |
+ : movie.titulo_ingles?.DATA || movie.titulo_original?.DATA; // fallback | |
+ }; | |
+ | |
+ // Easy to add more fields later | |
+ // const getLocalizedDescription = (movie) => { | |
+ // return locale.value === 'pt' | |
+ // ? movie.sinopse_pt?.DATA | |
+ // : movie.sinopse_en?.DATA || movie.sinopse_pt?.DATA | |
+ // } | |
+ | |
+ return { | |
+ getLocalizedTitle, | |
+ // getLocalizedDescription | |
+ }; | |
+} | |
diff --git a/src/components/features/movies/composables/useMovies.js b/src/components/features/movies/composables/useMovies.js | |
new file mode 100644 | |
index 0000000..48170ab | |
--- /dev/null | |
+++ b/src/components/features/movies/composables/useMovies.js | |
@@ -0,0 +1,17 @@ | |
+import { useQuery } from "@tanstack/vue-query"; | |
+import { fetchMovies } from "@/services/api/endpoints/movies"; | |
+ | |
+export function useMoviesQuery() { | |
+ return useQuery({ | |
+ queryKey: ["programming"], | |
+ queryFn: fetchMovies, | |
+ // staleTime: 5 * 60 * 1000, // 5 minutes | |
+ gcTime: 4 * 60 * 60 * 1000, // 4 * 60 minutes | |
+ refetchOnWindowFocus: false, | |
+ refetchOnReconnect: true, | |
+ retry: 3, | |
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), | |
+ refetchOnMount: true, | |
+ throwOnError: false, | |
+ }); | |
+} | |
diff --git a/src/components/features/programming/components/ProgrammingView.vue b/src/components/features/programming/components/ProgrammingView.vue | |
new file mode 100644 | |
index 0000000..44b7585 | |
--- /dev/null | |
+++ b/src/components/features/programming/components/ProgrammingView.vue | |
@@ -0,0 +1,169 @@ | |
+<script setup> | |
+// 🔁 Reusable UI Components | |
+// import MovieCard from "@/components/features/movies/components/MovieCard.vue"; | |
+// import ToastNotification from "@/components/common/notifications/ToastNotification.vue"; | |
+import ContextMenu from "@/components/layout/navbar/ContextMenu.vue"; | |
+import TwContainer from "@/components/layout/TwContainer.vue"; | |
+import MobileFilterMenu from "@/components/features/filters/components/MobileFilterMenu.vue"; | |
+import MovieList from "@/components/features/movies/components/MovieList.vue"; | |
+import SearchFilter from "@/components/features/filters/components/SearchFilter.vue"; | |
+import TagFilter from "@/components/common/tags/TagFilter.vue"; | |
+import { IconFilter } from "@/components/common/icons"; | |
+import SearchBar from "@/components/features/filters/components/SearchBar.vue"; | |
+ | |
+// ✅ Composables | |
+import { useFilters } from "@/components/features/filters/composables/useFilters"; | |
+import { useMoviesQuery } from "@/components/features/movies/composables/useMovies"; | |
+import { ref, watch } from "vue"; | |
+ | |
+// 📦 UI state - mobile filter menu open/close | |
+const isFilterMenuOpen = ref(false); | |
+ | |
+// 👇 DOM side effect - lock scroll when menu is open | |
+const openMenu = () => { | |
+ isFilterMenuOpen.value = true; | |
+ document.body.style.overflow = "hidden"; | |
+}; | |
+ | |
+// 👇 DOM side effect - unlock scroll when menu closes | |
+const closeMenu = () => { | |
+ isFilterMenuOpen.value = false; | |
+ document.body.style.overflow = ""; | |
+}; | |
+ | |
+// 📦 UI state - holds original API response | |
+const programming = ref([]); | |
+ | |
+// 🧠 Business logic - Filter composable (source of truth for filters) | |
+const { | |
+ searchValue, | |
+ handleSearch, | |
+ handleClear, | |
+ filters, | |
+ filtersQuery, | |
+ filterSearch, | |
+ clearSearchQuery, | |
+ removeQuery, | |
+ filteredMovies, | |
+} = useFilters(programming); | |
+ | |
+// ⚙️ Data fetching - Movies from API | |
+const { data, isPending, isFetching, isError, error } = useMoviesQuery(); | |
+ | |
+// 🧠 Watcher - on API data change, update local state | |
+watch( | |
+ data, | |
+ (dataFetched) => { | |
+ const newData = dataFetched?.FMPDSORESULT?.ROW; | |
+ | |
+ if (newData) { | |
+ // 🧼 Normalize data once | |
+ const normalized = newData.map((movie) => ({ | |
+ ...movie, | |
+ _normalized: { | |
+ titulo_original: | |
+ movie.titulo_original?.DATA?.trim().toLowerCase() || "", | |
+ titulo_ingles: movie.titulo_ingles?.DATA?.trim().toLowerCase() || "", | |
+ titulo_portugues: | |
+ movie.titulo_portugues?.DATA?.trim().toLowerCase() || "", | |
+ // You can normalize other keys too if needed for filter fields | |
+ // e.g., genero, pais, direcao, etc. | |
+ }, | |
+ })); | |
+ | |
+ programming.value = normalized; | |
+ console.log("Programming stored: ", programming.value); | |
+ } | |
+ }, | |
+ { immediate: true }, | |
+); | |
+</script> | |
+ | |
+<template> | |
+ <ContextMenu /> | |
+ <TwContainer class="pt-400 space-y-400"> | |
+ <h2 class="text-header-sm text-primary"> | |
+ {{ $t("navigation.programming") }} 2025 | |
+ </h2> | |
+ <div | |
+ class="w-full flex flex-col gap-400 md:flex-row md:justify-between md:gap-600" | |
+ > | |
+ <SearchBar | |
+ v-model="searchValue" | |
+ @search="handleSearch" | |
+ @clear="handleClear" | |
+ /> | |
+ <div | |
+ class="filter flex items-center justify-between md:gap-800 lg:gap-1200" | |
+ > | |
+ <!-- FilterMobileTrigger --> | |
+ <button | |
+ @click="openMenu" | |
+ class="p-100 flex items-center gap-200 text-body-strong-sm text-primary md:order-2 lg:hidden" | |
+ > | |
+ <IconFilter height="16px" width="16px" color="text-primary" /> | |
+ {{ $t("filter.title") }} | |
+ </button> | |
+ <!-- FilterMobileTrigger --> | |
+ | |
+ <!-- Ordering --> | |
+ <div class="flex items-center gap-300"> | |
+ <span class="text-body-strong-sm uppercase text-secondary-gray" | |
+ >A - Z</span | |
+ > | |
+ <img | |
+ src="@assets/icons/divisor.svg" | |
+ alt="divisor" | |
+ height="16px" | |
+ width="1px" | |
+ /> | |
+ <span class="text-body-strong-sm uppercase text-primary">{{ | |
+ $t("filter_by.date") | |
+ }}</span> | |
+ </div> | |
+ <!-- Ordering --> | |
+ </div> | |
+ <transition name="slide-left"> | |
+ <MobileFilterMenu | |
+ :is-open="isFilterMenuOpen" | |
+ :model-value="filters" | |
+ @filtersApplied="filterSearch" | |
+ @filtersCleared="clearSearchQuery" | |
+ @close-filter-menu="closeMenu" | |
+ /> | |
+ </transition> | |
+ </div> | |
+ <div | |
+ class="flex gap-300" | |
+ v-if="Object.values(filtersQuery).some((item) => item !== null)" | |
+ > | |
+ <TagFilter | |
+ v-for="(value, key) in filtersQuery" | |
+ :key="key" | |
+ :text="value" | |
+ @remove-filter="removeQuery" | |
+ /> | |
+ </div> | |
+ <div class="grid grid-cols-12 gap-800"> | |
+ <div class="col-span-12 lg:col-span-6"> | |
+ <MovieList | |
+ :movies="filteredMovies" | |
+ :error="error" | |
+ :is-error="isError" | |
+ :is-fetching="isFetching" | |
+ :is-pending="isPending" | |
+ /> | |
+ </div> | |
+ <div class="hidden lg:block lg:col-start-8 lg:col-end-13"> | |
+ <SearchFilter | |
+ v-model="filters" | |
+ @filtersApplied="filterSearch" | |
+ @filtersCleared="clearSearchQuery" | |
+ @close-filter-menu="closeMenu" | |
+ /> | |
+ </div> | |
+ </div> | |
+ </TwContainer> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/inputs/TextInput.vue b/src/components/inputs/TextInput.vue | |
deleted file mode 100644 | |
index d679a49..0000000 | |
--- a/src/components/inputs/TextInput.vue | |
+++ /dev/null | |
@@ -1,29 +0,0 @@ | |
-<script setup> | |
-const props = defineProps({ | |
- id: { type: [String], required: true }, | |
- label: {type: String, default: null }, | |
- name: {type: String, default: null }, | |
- value: {type: String, default: undefined }, | |
- placeholder: {type: String, default: undefined }, | |
- disabled: {type: Boolean, default: false } | |
-}) | |
- | |
-const emit = defineEmits(['update:value']) | |
- | |
-const baseStyle = 'px-400 py-200 border border-neutrals-300 text-neutrals-900 rounded-100 text-sm leading-[150%] font-body placeholder-neutrals-400' | |
-const disableStyle = 'disabled:bg-neutrals-300 disabled:placeholder-neutrals-600 disabled:text-neutrals-600 disabled:border-neutrals-300 disabled:shadow-none' | |
-const focusStyle = 'focus:outline-none focus:border-neutrals-600' | |
-</script> | |
-<template> | |
- <div> | |
- <label v-if="props.label" :for="props.id">{{ props.label }}</label> | |
- <input type="text" | |
- :name="props.name || props.id" | |
- :id="props.id" | |
- :placeholder="props.placeholder" | |
- :disabled="props.disabled" | |
- :value="props.value" | |
- @input="emit('update:value', $event.target.value)" | |
- :class="[baseStyle, disableStyle, focusStyle]"> | |
- </div> | |
-</template> | |
diff --git a/src/components/layout/TwContainer.vue b/src/components/layout/TwContainer.vue | |
index b98c9b0..c2e413b 100644 | |
--- a/src/components/layout/TwContainer.vue | |
+++ b/src/components/layout/TwContainer.vue | |
@@ -2,17 +2,16 @@ | |
const { extraClasses, breakpoints } = defineProps({ | |
extraClasses: { | |
type: String, | |
- default: "" | |
+ default: "", | |
}, | |
breakpoints: { | |
type: String, | |
- default: "lg:max-w-6xl xl:max-w-7xl xxl:max-w-xxl" | |
- } | |
-}) | |
+ default: "lg:max-w-6xl xl:max-w-7xl xxl:max-w-xxl", | |
+ }, | |
+}); | |
</script> | |
<template> | |
- <div class="w-full px-400 mx-auto" | |
- :class="[breakpoints, extraClasses]"> | |
+ <div class="w-full px-400 mx-auto" :class="[breakpoints, extraClasses]"> | |
<slot /> | |
</div> | |
</template> | |
diff --git a/src/components/layout/headers/SponsorHeader.vue b/src/components/layout/headers/SponsorHeader.vue | |
index abd3f19..ecd0198 100644 | |
--- a/src/components/layout/headers/SponsorHeader.vue | |
+++ b/src/components/layout/headers/SponsorHeader.vue | |
@@ -1,9 +1,14 @@ | |
-<script setup> | |
-</script> | |
+<script setup></script> | |
<template> | |
<div class="font-body flex items-center justify-center h-[72px] p-300"> | |
- <p class="text-sm md:text-md uppercase leading-[22.4px] text-center"><span class="font-semibold">MINISTÉRIO DA CULTURA</span>, <span class="font-semibold">SHELL</span> e <span class="font-semibold">PREFEITURA DO RIO</span> apresentam</p> | |
+ <p | |
+ class="text-sm md:text-md uppercase leading-[22.4px] text-center text-neutrals-900" | |
+ > | |
+ <span class="font-semibold">MINISTÉRIO DA CULTURA</span>, | |
+ <span class="font-semibold">SHELL</span> e | |
+ <span class="font-semibold">PREFEITURA DO RIO</span> apresentam | |
+ </p> | |
</div> | |
</template> | |
diff --git a/src/components/layout/navbar/ContextMenu.vue b/src/components/layout/navbar/ContextMenu.vue | |
index 5239bdb..6429b8f 100644 | |
--- a/src/components/layout/navbar/ContextMenu.vue | |
+++ b/src/components/layout/navbar/ContextMenu.vue | |
@@ -1,31 +1,36 @@ | |
<script setup> | |
-import NavButtonContext from "@/components/ui/buttons/NavButtonContext.vue"; | |
-import { IconChange } from "@/components/ui/icons"; | |
-import { IconNewUser } from "@/components/ui/icons"; | |
-import { IconClock } from "@/components/ui/icons"; | |
-import { IconProgram } from "@/components/ui/icons"; | |
+import { NavButtonContext } from "@/components/common/buttons"; | |
+import { IconChange } from "@/components/common/icons"; | |
+import { IconNewUser } from "@/components/common/icons"; | |
+import { IconClock } from "@/components/common/icons"; | |
+import { IconProgram } from "@/components/common/icons"; | |
+ | |
+const items = [ | |
+ { name: "programming", route: "/programming", component: IconProgram }, | |
+ { name: "sessoes_com_convidados", route: "/", component: IconNewUser }, | |
+ { name: "mudancas_na_programacao", route: "/", component: IconChange }, | |
+ { name: "sessoes_ao_ar_livre", route: "/", component: IconClock }, | |
+]; | |
</script> | |
<template> | |
- <div class="flex gap-800 px-400 py-0 lg:gap-1600 lg:py-800 lg:justify-center overflow-x-auto"> | |
- <NavButtonContext class="flex-shrink-0" content="programação" route="/programacao"> | |
- <template v-slot:icon> | |
- <IconProgram height="30px" width="30px" :active="true" /> | |
- </template> | |
- </NavButtonContext> | |
- <NavButtonContext class="flex-shrink-0" content="Sessões com convidados"> | |
- <template #icon="{ hovered }"> | |
- <IconNewUser height="30px" width="30px" :active="hovered" /> | |
- </template> | |
- </NavButtonContext> | |
- <NavButtonContext class="flex-shrink-0" content="Mudanças na programação"> | |
- <template #icon="{ hovered }"> | |
- <IconChange height="30px" width="30px" :active="hovered" /> | |
- </template> | |
- </NavButtonContext> | |
- <NavButtonContext class="flex-shrink-0" content="Sessões ao ar livre"> | |
- <template #icon="{ hovered }"> | |
- <IconClock height="30px" width="30px" :active="hovered" /> | |
+ <div | |
+ class="flex gap-800 px-400 py-200 lg:gap-1600 lg:py-800 lg:justify-center overflow-x-auto" | |
+ > | |
+ <NavButtonContext | |
+ v-for="item in items" | |
+ :key="item" | |
+ class="flex-shrink-0" | |
+ :content="$t(`navigation.${item.name}`)" | |
+ :route="item.route" | |
+ > | |
+ <template #icon="{ active }"> | |
+ <component | |
+ :is="item.component" | |
+ height="30px" | |
+ width="30px" | |
+ :active="active" | |
+ /> | |
</template> | |
</NavButtonContext> | |
</div> | |
diff --git a/src/components/layout/navbar/LanguageSwitcher.vue b/src/components/layout/navbar/LanguageSwitcher.vue | |
deleted file mode 100644 | |
index 69acc1a..0000000 | |
--- a/src/components/layout/navbar/LanguageSwitcher.vue | |
+++ /dev/null | |
@@ -1,33 +0,0 @@ | |
-<script setup> | |
-const emit = defineEmits(['update:modelValue']) | |
- | |
-const props = defineProps({ | |
- langs: { | |
- type: Array, | |
- required: true | |
- }, | |
- modelValue: { | |
- type: String, | |
- default: 'pt' | |
- } | |
-}) | |
- | |
-</script> | |
- | |
-<template> | |
- <div class="languages flex items-center gap-400 text-neutrals-700" | |
- aria-label="language selection"> | |
- <template v-for="lang in props.langs" :key="lang"> | |
- <button | |
- class="font-body text-sm font-semibold leading-[19.6px] uppercase" | |
- :class="{ 'text-neutrals-900': modelValue === lang }" | |
- @click="emit('update:modelValue', lang)" | |
- :aria-label="`Alterar para ${lang.toUpperCase()}`" | |
- :aria-pressed="modelValue === lang" | |
- > | |
- {{ lang }} | |
- </button> | |
- <img v-if="lang !== props.langs.at(-1)" src="@assets/divisor.svg" alt="Divisor" aria-hidden="true" /> | |
- </template> | |
- </div> | |
-</template> | |
diff --git a/src/components/layout/navbar/MobileMenu.vue b/src/components/layout/navbar/MobileMenu.vue | |
index c8a99c6..3c2b91f 100644 | |
--- a/src/components/layout/navbar/MobileMenu.vue | |
+++ b/src/components/layout/navbar/MobileMenu.vue | |
@@ -1,44 +1,71 @@ | |
<script setup> | |
- import { ref } from "vue" | |
+import { ref } from "vue"; | |
- import AccordionGroup from "@/components/ui/accordion/AccordionGroup.vue"; | |
- import LanguageSwitcher from "@/components/layout/navbar/LanguageSwitcher.vue"; | |
+import AccordionGroup from "@/components/base/accordion/AccordionGroup.vue"; | |
+import LanguageSwitcher from "@/components/app/TheLanguageSwitcher.vue"; | |
- // Controls menu visibility | |
- const isMobileMenuOpen = ref(false); | |
+// Controls menu visibility | |
+const isMobileMenuOpen = ref(false); | |
- const openMenu = () => { | |
- isMobileMenuOpen.value = true; | |
- document.body.style.overflow = "hidden"; // prevent background scroll | |
- }; | |
- const closeMenu = () => { | |
- isMobileMenuOpen.value = false; | |
- document.body.style.overflow = ""; // restore scroll | |
- }; | |
+const openMenu = () => { | |
+ isMobileMenuOpen.value = true; | |
+ document.body.style.overflow = "hidden"; // prevent background scroll | |
+}; | |
+const closeMenu = () => { | |
+ isMobileMenuOpen.value = false; | |
+ document.body.style.overflow = ""; // restore scroll | |
+}; | |
</script> | |
<template> | |
- <div class="md:hidden pt-100" > | |
- <button @click="openMenu" | |
- class="text-neutrals-900 hover:bg-white/5 focus:outline-2 focus:-outline-offset-1 focus:outline-laranja-600" | |
- aria-controls="mobile-menu" | |
- aria-label="Open navigation menu" | |
- v-bind:aria-expanded="isMobileMenuOpen"> | |
- <svg xmlns="http://www.w3.org/2000/svg" width="33" height="33" viewBox="0 0 33 33" fill="currentColor" aria-hidden="true"> | |
- <path fill-rule="evenodd" clip-rule="evenodd" d="M5.55078 24.5C5.55078 24.2348 5.65614 23.9804 5.84367 23.7929C6.03121 23.6054 6.28556 23.5 6.55078 23.5H26.5508C26.816 23.5 27.0704 23.6054 27.2579 23.7929C27.4454 23.9804 27.5508 24.2348 27.5508 24.5C27.5508 24.7652 27.4454 25.0196 27.2579 25.2071C27.0704 25.3946 26.816 25.5 26.5508 25.5H6.55078C6.28556 25.5 6.03121 25.3946 5.84367 25.2071C5.65614 25.0196 5.55078 24.7652 5.55078 24.5ZM5.55078 16.5C5.55078 16.2348 5.65614 15.9804 5.84367 15.7929C6.03121 15.6054 6.28556 15.5 6.55078 15.5H26.5508C26.816 15.5 27.0704 15.6054 27.2579 15.7929C27.4454 15.9804 27.5508 16.2348 27.5508 16.5C27.5508 16.7652 27.4454 17.0196 27.2579 17.2071C27.0704 17.3946 26.816 17.5 26.5508 17.5H6.55078C6.28556 17.5 6.03121 17.3946 5.84367 17.2071C5.65614 17.0196 5.55078 16.7652 5.55078 16.5ZM5.55078 8.5C5.55078 8.23478 5.65614 7.98043 5.84367 7.79289C6.03121 7.60536 6.28556 7.5 6.55078 7.5H26.5508C26.816 7.5 27.0704 7.60536 27.2579 7.79289C27.4454 7.98043 27.5508 8.23478 27.5508 8.5C27.5508 8.76522 27.4454 9.01957 27.2579 9.20711C27.0704 9.39464 26.816 9.5 26.5508 9.5H6.55078C6.28556 9.5 6.03121 9.39464 5.84367 9.20711C5.65614 9.01957 5.55078 8.76522 5.55078 8.5Z" /> | |
+ <div class="md:hidden pt-100"> | |
+ <button | |
+ @click="openMenu" | |
+ class="text-neutrals-900 hover:bg-white/5 focus:outline-2 focus:-outline-offset-1 focus:outline-laranja-600" | |
+ aria-controls="mobile-menu" | |
+ aria-label="Open navigation menu" | |
+ v-bind:aria-expanded="isMobileMenuOpen" | |
+ > | |
+ <svg | |
+ xmlns="http://www.w3.org/2000/svg" | |
+ width="33" | |
+ height="33" | |
+ viewBox="0 0 33 33" | |
+ fill="currentColor" | |
+ aria-hidden="true" | |
+ > | |
+ <path | |
+ fill-rule="evenodd" | |
+ clip-rule="evenodd" | |
+ d="M5.55078 24.5C5.55078 24.2348 5.65614 23.9804 5.84367 23.7929C6.03121 23.6054 6.28556 23.5 6.55078 23.5H26.5508C26.816 23.5 27.0704 23.6054 27.2579 23.7929C27.4454 23.9804 27.5508 24.2348 27.5508 24.5C27.5508 24.7652 27.4454 25.0196 27.2579 25.2071C27.0704 25.3946 26.816 25.5 26.5508 25.5H6.55078C6.28556 25.5 6.03121 25.3946 5.84367 25.2071C5.65614 25.0196 5.55078 24.7652 5.55078 24.5ZM5.55078 16.5C5.55078 16.2348 5.65614 15.9804 5.84367 15.7929C6.03121 15.6054 6.28556 15.5 6.55078 15.5H26.5508C26.816 15.5 27.0704 15.6054 27.2579 15.7929C27.4454 15.9804 27.5508 16.2348 27.5508 16.5C27.5508 16.7652 27.4454 17.0196 27.2579 17.2071C27.0704 17.3946 26.816 17.5 26.5508 17.5H6.55078C6.28556 17.5 6.03121 17.3946 5.84367 17.2071C5.65614 17.0196 5.55078 16.7652 5.55078 16.5ZM5.55078 8.5C5.55078 8.23478 5.65614 7.98043 5.84367 7.79289C6.03121 7.60536 6.28556 7.5 6.55078 7.5H26.5508C26.816 7.5 27.0704 7.60536 27.2579 7.79289C27.4454 7.98043 27.5508 8.23478 27.5508 8.5C27.5508 8.76522 27.4454 9.01957 27.2579 9.20711C27.0704 9.39464 26.816 9.5 26.5508 9.5H6.55078C6.28556 9.5 6.03121 9.39464 5.84367 9.20711C5.65614 9.01957 5.55078 8.76522 5.55078 8.5Z" | |
+ /> | |
</svg> | |
</button> | |
<!-- Mobile menu --> | |
<transition name="slide"> | |
- <div v-if="isMobileMenuOpen" | |
- class="fixed inset-0 z-50 bg-white flex flex-col w-full max-w-full right-0 shadow-lg overflow-y-auto"> | |
+ <div | |
+ v-if="isMobileMenuOpen" | |
+ class="fixed inset-0 z-50 bg-white flex flex-col w-full max-w-full right-0 shadow-lg overflow-y-auto" | |
+ > | |
<!-- Close Button --> | |
<div class="flex justify-between p-400"> | |
- <img src="@assets/festival-logo-mobile.svg" alt="Logo Festival do Rio"> | |
+ <img | |
+ src="@assets/images/logos/festival-logo-mobile.svg" | |
+ alt="Logo Festival do Rio" | |
+ /> | |
- <LanguageSwitcher :langs="['pt', 'en']" v-model="currentLanguage" /> | |
+ <LanguageSwitcher /> | |
<button @click="closeMenu" class="text-neutrals-900"> | |
- <svg xmlns="http://www.w3.org/2000/svg" width="32" height="33" viewBox="0 0 32 33" fill="none"> | |
- <path d="M9.29379 9.79183C9.38668 9.69871 9.49703 9.62482 9.61852 9.57441C9.74001 9.524 9.87025 9.49805 10.0018 9.49805C10.1333 9.49805 10.2636 9.524 10.3851 9.57441C10.5065 9.62482 10.6169 9.69871 10.7098 9.79183L16.0018 15.0858L21.2938 9.79183C21.3868 9.69886 21.4971 9.62511 21.6186 9.57479C21.7401 9.52447 21.8703 9.49857 22.0018 9.49857C22.1333 9.49857 22.2635 9.52447 22.385 9.57479C22.5064 9.62511 22.6168 9.69886 22.7098 9.79183C22.8028 9.88481 22.8765 9.99519 22.9268 10.1167C22.9771 10.2381 23.003 10.3683 23.003 10.4998C23.003 10.6313 22.9771 10.7615 22.9268 10.883C22.8765 11.0045 22.8028 11.1149 22.7098 11.2078L17.4158 16.4998L22.7098 21.7918C22.8028 21.8848 22.8765 21.9952 22.9268 22.1167C22.9771 22.2381 23.003 22.3683 23.003 22.4998C23.003 22.6313 22.9771 22.7615 22.9268 22.883C22.8765 23.0045 22.8028 23.1149 22.7098 23.2078C22.6168 23.3008 22.5064 23.3746 22.385 23.4249C22.2635 23.4752 22.1333 23.5011 22.0018 23.5011C21.8703 23.5011 21.7401 23.4752 21.6186 23.4249C21.4971 23.3746 21.3868 23.3008 21.2938 23.2078L16.0018 17.9138L10.7098 23.2078C10.6168 23.3008 10.5064 23.3746 10.385 23.4249C10.2635 23.4752 10.1333 23.5011 10.0018 23.5011C9.8703 23.5011 9.7401 23.4752 9.61862 23.4249C9.49714 23.3746 9.38676 23.3008 9.29379 23.2078C9.20081 23.1149 9.12706 23.0045 9.07674 22.883C9.02642 22.7615 9.00052 22.6313 9.00052 22.4998C9.00052 22.3683 9.02642 22.2381 9.07674 22.1167C9.12706 21.9952 9.20081 21.8848 9.29379 21.7918L14.5878 16.4998L9.29379 11.2078C9.20066 11.1149 9.12677 11.0046 9.07636 10.8831C9.02595 10.7616 9 10.6314 9 10.4998C9 10.3683 9.02595 10.2381 9.07636 10.1166C9.12677 9.99508 9.20066 9.88473 9.29379 9.79183Z" fill="#3B3935"/> | |
+ <svg | |
+ xmlns="http://www.w3.org/2000/svg" | |
+ width="32" | |
+ height="33" | |
+ viewBox="0 0 32 33" | |
+ fill="none" | |
+ > | |
+ <path | |
+ d="M9.29379 9.79183C9.38668 9.69871 9.49703 9.62482 9.61852 9.57441C9.74001 9.524 9.87025 9.49805 10.0018 9.49805C10.1333 9.49805 10.2636 9.524 10.3851 9.57441C10.5065 9.62482 10.6169 9.69871 10.7098 9.79183L16.0018 15.0858L21.2938 9.79183C21.3868 9.69886 21.4971 9.62511 21.6186 9.57479C21.7401 9.52447 21.8703 9.49857 22.0018 9.49857C22.1333 9.49857 22.2635 9.52447 22.385 9.57479C22.5064 9.62511 22.6168 9.69886 22.7098 9.79183C22.8028 9.88481 22.8765 9.99519 22.9268 10.1167C22.9771 10.2381 23.003 10.3683 23.003 10.4998C23.003 10.6313 22.9771 10.7615 22.9268 10.883C22.8765 11.0045 22.8028 11.1149 22.7098 11.2078L17.4158 16.4998L22.7098 21.7918C22.8028 21.8848 22.8765 21.9952 22.9268 22.1167C22.9771 22.2381 23.003 22.3683 23.003 22.4998C23.003 22.6313 22.9771 22.7615 22.9268 22.883C22.8765 23.0045 22.8028 23.1149 22.7098 23.2078C22.6168 23.3008 22.5064 23.3746 22.385 23.4249C22.2635 23.4752 22.1333 23.5011 22.0018 23.5011C21.8703 23.5011 21.7401 23.4752 21.6186 23.4249C21.4971 23.3746 21.3868 23.3008 21.2938 23.2078L16.0018 17.9138L10.7098 23.2078C10.6168 23.3008 10.5064 23.3746 10.385 23.4249C10.2635 23.4752 10.1333 23.5011 10.0018 23.5011C9.8703 23.5011 9.7401 23.4752 9.61862 23.4249C9.49714 23.3746 9.38676 23.3008 9.29379 23.2078C9.20081 23.1149 9.12706 23.0045 9.07674 22.883C9.02642 22.7615 9.00052 22.6313 9.00052 22.4998C9.00052 22.3683 9.02642 22.2381 9.07674 22.1167C9.12706 21.9952 9.20081 21.8848 9.29379 21.7918L14.5878 16.4998L9.29379 11.2078C9.20066 11.1149 9.12677 11.0046 9.07636 10.8831C9.02595 10.7616 9 10.6314 9 10.4998C9 10.3683 9.02595 10.2381 9.07636 10.1166C9.12677 9.99508 9.20066 9.88473 9.29379 9.79183Z" | |
+ fill="#3B3935" | |
+ /> | |
</svg> | |
</button> | |
</div> | |
@@ -48,7 +75,11 @@ | |
<AccordionGroup text="Programação" :isOpen="true"> | |
<template v-slot:content> | |
<ul class="ps-600 pt-400 space-y-400"> | |
- <li>Programação completa</li> | |
+ <li> | |
+ <router-link @click="closeMenu" :to="{ name: 'programming' }"> | |
+ Programação completa | |
+ </router-link> | |
+ </li> | |
<li>Sessões com convidados</li> | |
<li>Mudanças na programação</li> | |
<li>Programação gratuita</li> | |
@@ -64,8 +95,11 @@ | |
<ul class="ps-600 pt-400 space-y-400"></ul> | |
</AccordionGroup> | |
- <a href="#" | |
- class="font-body font-semibold text-neutrals-900 leadgin-[19.6px] uppercase flex justify-between pb-300 border-b">Notícias</a> | |
+ <a | |
+ href="#" | |
+ class="font-body font-semibold text-neutrals-900 leadgin-[19.6px] uppercase flex justify-between pb-300 border-b" | |
+ >Notícias</a | |
+ > | |
<AccordionGroup text="mídias"> | |
<ul class="ps-600 pt-400 space-y-400"></ul> | |
diff --git a/src/components/layout/navbar/NavbarMain.vue b/src/components/layout/navbar/NavbarMain.vue | |
index f2a74d8..ec7ac3e 100644 | |
--- a/src/components/layout/navbar/NavbarMain.vue | |
+++ b/src/components/layout/navbar/NavbarMain.vue | |
@@ -1,34 +1,45 @@ | |
<script setup> | |
-import { ref } from "vue"; | |
- | |
-import BaseButton from "@/components/ui/buttons/BaseButton.vue" | |
-import LanguageSwitcher from "@/components/layout/navbar/LanguageSwitcher.vue"; | |
+import { BaseButton } from "@/components/common/buttons"; | |
+import LanguageSwitcher from "@/components/app/TheLanguageSwitcher.vue"; | |
import MobileMenu from "@/components/layout/navbar/MobileMenu.vue"; | |
-import { IconSearch } from "@/components/ui/icons"; | |
- | |
-// v-model for LanguageSwitcher | |
-const currentLanguage = ref('pt') | |
- | |
- | |
+import { IconSearch } from "@/components/common/icons"; | |
</script> | |
<template> | |
- <nav class="p-400 mx-auto lg:max-w-7xl flex items-start md:items-center justify-between" | |
- role="navigation" | |
- aria-label="Navegação primária"> | |
- <div class="flex flex-col md:flex-row md:gap-400 items-start md:items-center"> | |
+ <nav | |
+ class="p-400 mx-auto lg:max-w-7xl flex items-start md:items-center justify-between" | |
+ role="navigation" | |
+ aria-label="Navegação primária" | |
+ > | |
+ <div | |
+ class="flex flex-col md:flex-row md:gap-400 items-start md:items-center" | |
+ > | |
<!-- logo link --> | |
- <a href="#" class="focus:outline-2 focus:-outline-offset-2 focus:outline-laranja-600"> | |
- <img class="py-200" src="@assets/festival-logo.svg" alt="Logo do Festival do Rio" width="221" /> | |
- </a> | |
+ <router-link | |
+ :to="{ name: 'home' }" | |
+ class="focus:outline-2 focus:-outline-offset-2 focus:outline-laranja-600" | |
+ > | |
+ <img | |
+ class="py-200" | |
+ src="@assets/images/logos/festival-logo.svg" | |
+ alt="Logo do Festival do Rio" | |
+ width="221" | |
+ /> | |
+ </router-link> | |
<!-- divisor --> | |
<div class="hidden md:block"> | |
- <img src="@assets/divisor.svg" alt="divisor" aria-hidden="true" /> | |
+ <img src="@assets/icons/divisor.svg" alt="divisor" aria-hidden="true" /> | |
</div> | |
<!-- festival header --> | |
<div class="festival-dates py-100"> | |
- <p class="font-body text-neutrals-900 text-sm font-regular leading-[21px] uppercase">rio international film festival</p> | |
- <p class="font-body text-md font-bold leading-[22.4px]">2 - 12 OUT 2025</p> | |
+ <p | |
+ class="font-body text-neutrals-900 text-sm font-regular leading-[21px] uppercase" | |
+ > | |
+ rio international film festival | |
+ </p> | |
+ <p class="font-body text-md font-bold leading-[22.4px]"> | |
+ 2 - 12 OUT 2025 | |
+ </p> | |
</div> | |
</div> | |
<!-- mobile TOGGLER --> | |
@@ -37,13 +48,17 @@ const currentLanguage = ref('pt') | |
<!-- navbar actions --> | |
<div class="hidden md:flex items-center gap-1200"> | |
<IconSearch /> | |
- <LanguageSwitcher :langs="['pt', 'en']" v-model="currentLanguage" /> | |
+ <LanguageSwitcher /> | |
<BaseButton as="button" size="md" variant="cta"> | |
- RioMarket <img src="@assets/Vector.svg" alt="carret" class="w-full ms-300" /> | |
+ RioMarket | |
+ <img | |
+ src="@assets/icons/Vector.svg" | |
+ alt="carret" | |
+ class="w-full ms-300" | |
+ /> | |
</BaseButton> | |
</div> | |
</nav> | |
</template> | |
-<style scoped> | |
-</style> | |
+<style scoped></style> | |
diff --git a/src/components/layout/navbar/NavbarSecondary.vue b/src/components/layout/navbar/NavbarSecondary.vue | |
new file mode 100644 | |
index 0000000..3bb7fb9 | |
--- /dev/null | |
+++ b/src/components/layout/navbar/NavbarSecondary.vue | |
@@ -0,0 +1,46 @@ | |
+<script setup> | |
+import { BaseButton } from "@/components/common/buttons"; | |
+ | |
+const mainItems = [ | |
+ "programming", | |
+ "edition2024", | |
+ "aboutUs", | |
+ "news", | |
+ "media", | |
+ "information", | |
+]; | |
+ | |
+const secondaryItems = [ | |
+ { name: "press", tag: "button" }, | |
+ { name: "archive", tag: "a", href: "https://www.globo.com" }, | |
+ { name: "registrations", tag: "button" }, | |
+ { name: "contact", tag: "button" }, | |
+]; | |
+</script> | |
+ | |
+<template> | |
+ <div | |
+ class="p-400 lg:pb-0 mx-auto lg:max-w-7xl hidden md:flex items-center justify-between" | |
+ > | |
+ <ul class="flex flex-grow gap-600 justify-start items-center me-400 h-1600"> | |
+ <li v-for="item in mainItems" :key="item" class="h-full"> | |
+ <BaseButton | |
+ class="h-full uppercase" | |
+ as="button" | |
+ variant="underline" | |
+ size="lg" | |
+ >{{ $t(`navigation.${item}`) }}</BaseButton | |
+ > | |
+ </li> | |
+ </ul> | |
+ <ul class="hidden md:flex items-center space-x-400"> | |
+ <li v-for="item in secondaryItems" :key="item"> | |
+ <BaseButton :as="item.tag" :href="item.href" variant="gray" size="xs">{{ | |
+ $t(`navigation.${item.name}`) | |
+ }}</BaseButton> | |
+ </li> | |
+ </ul> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/src/components/layout/navbar/NavbarSecundary.vue b/src/components/layout/navbar/NavbarSecundary.vue | |
deleted file mode 100644 | |
index 54a313f..0000000 | |
--- a/src/components/layout/navbar/NavbarSecundary.vue | |
+++ /dev/null | |
@@ -1,64 +0,0 @@ | |
-<script setup> | |
-import BaseButton from '@/components/ui/buttons/BaseButton.vue'; | |
-</script> | |
- | |
-<template> | |
- <div class="p-400 mx-auto lg:max-w-7xl hidden md:flex items-center justify-between"> | |
- <ul class="flex flex-grow justify-between items-center me-400 h-1600"> | |
- <li> | |
- <BaseButton as="button" variant="underline" size="lg"> | |
- Programaçao | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="button" variant="underline" size="lg"> | |
- Edição 2024 | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="button" variant="underline" size="lg"> | |
- Sobre nós | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="a" href="#" variant="underline" size="lg"> | |
- Notícias | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="button" variant="underline" size="lg"> | |
- Mídias | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="button" variant="underline" size="lg"> | |
- Informações | |
- </BaseButton> | |
- </li> | |
- </ul> | |
- <ul class="hidden md:flex items-center space-x-400"> | |
- <li> | |
- <BaseButton as="button" variant="gray" size="xs"> | |
- Imprensa | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="a" href="https://www.google.com" variant="gray" size="xs"> | |
- Arquivo | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="button" variant="gray" size="xs"> | |
- Inscrições | |
- </BaseButton> | |
- </li> | |
- <li> | |
- <BaseButton as="button" variant="gray" size="xs"> | |
- Contato | |
- </BaseButton> | |
- </li> | |
- </ul> | |
- </div> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/accordion/AccordionGroup.vue b/src/components/ui/accordion/AccordionGroup.vue | |
deleted file mode 100644 | |
index d696369..0000000 | |
--- a/src/components/ui/accordion/AccordionGroup.vue | |
+++ /dev/null | |
@@ -1,27 +0,0 @@ | |
-<script setup> | |
-import IconCarretUp from '@/components/ui/icons/navigation/IconCarretUp.vue'; | |
- | |
-// import AccordionHeader from './AccordionHeader.vue'; | |
-const { text } = defineProps({ | |
- text: { | |
- type: String, | |
- required: true | |
- }, | |
- isOpen: { | |
- type: Boolean, | |
- default: false | |
- } | |
-}) | |
-</script> | |
- | |
-<template> | |
- <details :open="isOpen"> | |
- <summary class="font-body font-semibold text-neutrals-900 leadgin-[19.6px] uppercase flex justify-between items-center pb-300 border-b hover:cursor-pointer"> | |
- {{ text }} | |
- <IconCarretUp className="text-neutrals-900 accordion-icon" :width="16"/> | |
- </summary> | |
- <slot name="content"></slot> | |
- </details> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/buttons/BaseButton.vue b/src/components/ui/buttons/BaseButton.vue | |
deleted file mode 100644 | |
index 753936d..0000000 | |
--- a/src/components/ui/buttons/BaseButton.vue | |
+++ /dev/null | |
@@ -1,45 +0,0 @@ | |
-<script setup> | |
-const { as, type, variant, size, disabled } = defineProps({ | |
- as: { type: String, default: 'button' }, // Could be 'a', 'router-link', etc. | |
- type: { type: String, default: 'button' }, // Only relevant if as=button | |
- variant: { type: String, default: 'gray' }, // primary, secondary, ghost... | |
- size: { type: String, default: 'md' }, // sm, md, lg | |
- disabled: { type: Boolean, default: false } | |
-}) | |
- | |
-const baseStyles = 'font-body inline-flex items-center justify-center rounded-lg transition-colors duration-200 focus:outline-2 focus:outline-offset-2' | |
- | |
-const variants = { | |
- gray: "text-neutrals-900 bg-neutrals-200 hover:bg-neutrals-300 active:bg-neutrals-300 focus:outline-neutrals-300", | |
- cta: "text-white-transp-1000 bg-gradient-to-r from-magenta-600 to-laranja-600 hover:bg-gradient-to-l", | |
- rioMarket: 'text-white bg-vermelho-600', | |
- underline: "text-neutrals-700 border-neutrals-900 hover:border-b-[1.5px] active:text-neutrals-900 active:border-none focus:outline-none focus:border-b-[1.5px] rounded-none" | |
-} | |
- | |
-const sizes = { | |
- xs: "px-200 py-150 text-sm font-regular leading-[21px]", | |
- sm: "px-300 py-200 text-sm font-semibold leading-[19.6px]", | |
- md: "px-400 py-400 text-md font-semibold leading-[22.4px]", | |
- lg: "px-200 py-400 text-sm font-semibold leading-[19.6px]" | |
-} | |
- // px-200 py-400 | |
- // text-sm font-body font-semibold leading-[19.6px] | |
- // text-neutrals-700 | |
- // border-neutrals-900 | |
- // hover:border-b-[1.5px] | |
- // active:text-neutrals-900 active:border-none | |
- // focus:outline-none focus:border-b-[1.5px] | |
-</script> | |
- | |
-<template> | |
- <component | |
- :is="as" | |
- :type="as === 'button' ? type : undefined" | |
- :disabled="disabled" | |
- :class="[baseStyles, sizes[size], variants[variant]]" | |
- > | |
- <slot /> | |
- </component> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/buttons/ButtonText.vue b/src/components/ui/buttons/ButtonText.vue | |
deleted file mode 100644 | |
index e1a7132..0000000 | |
--- a/src/components/ui/buttons/ButtonText.vue | |
+++ /dev/null | |
@@ -1,48 +0,0 @@ | |
-<script setup> | |
-import { computed } from 'vue'; | |
- | |
-const variants = { | |
- dark: "text-neutrals-900 hover:text-neutrals-700 active:text-neutrals-800", | |
- light: "text-white-transp-1000", | |
- color: "bg-clip-text text-transparent bg-gradient-to-r from-magenta-600 to-laranja-600 hover:bg-gradient-to-l active:bg-gradient-to-r" | |
-} | |
- | |
-const sizes = { | |
- sm: "text-sm leading-[19.6px]", | |
- md: "text-md leading-[22.4px]" | |
-} | |
- | |
-const { variant, size, text, tag, href } = defineProps({ | |
- variant: { | |
- type: String, | |
- validator: (value) => ['dark', 'light', 'color'].includes(value), | |
- default: "dark" | |
- }, | |
- size: { | |
- type: String, | |
- validator: (value) => ['sm', 'md'].includes(value), | |
- default: "md" | |
- }, | |
- text: { type: String, default: '' }, | |
- tag: { type: String, default: "a" }, | |
- href: { type: String, default: "#" } | |
-}) | |
- | |
- | |
-const variantClass = computed(() => variants[variant]) | |
-const sizeClass = computed(() => sizes[size]) | |
-</script> | |
- | |
-<template> | |
- <component :is="tag" | |
- :href="href" | |
- class="p-100 inline-flex items-center justify-center max-w-fit font-body font-semibold" | |
- :class="[sizeClass, variantClass]"> | |
- <slot name="icon"/> | |
- {{ text }} | |
- </component> | |
-</template> | |
- | |
-<style scoped> | |
- | |
-</style> | |
diff --git a/src/components/ui/buttons/NavButtonContext.vue b/src/components/ui/buttons/NavButtonContext.vue | |
deleted file mode 100644 | |
index f0c8f9c..0000000 | |
--- a/src/components/ui/buttons/NavButtonContext.vue | |
+++ /dev/null | |
@@ -1,30 +0,0 @@ | |
-<script setup> | |
-import { computed, ref } from 'vue' | |
-import BodyStrongXs from '../typography/BodyStrongXs.vue'; | |
-const props = defineProps({ | |
- content: { type: String, required: true }, | |
- route: { type: String, default: '#' } | |
-}) | |
-const isHovered = ref(false) | |
-const isFocused = ref(false) | |
- | |
-const isActive = computed(() => isHovered.value || isFocused.value) | |
- | |
-</script> | |
- | |
-<template> | |
- <a | |
- :href="props.route" | |
- class="" | |
- :aria-label="`Navegar para ${props.content}`" | |
- @mouseenter="isHovered = true" | |
- @mouseleave="isHovered = false" | |
- @focus="isFocused = true" | |
- @blur="isFocused = false" | |
- > | |
- <div class="flex flex-col items-center gap-200"> | |
- <slot name="icon" :hovered="isActive" /> | |
- <BodyStrongXs class="text-center uppercase">{{ props.content }}</BodyStrongXs> | |
- </div> | |
- </a> | |
-</template> | |
diff --git a/src/components/ui/cards/ArticleCard.vue b/src/components/ui/cards/ArticleCard.vue | |
deleted file mode 100644 | |
index d08c62f..0000000 | |
--- a/src/components/ui/cards/ArticleCard.vue | |
+++ /dev/null | |
@@ -1,62 +0,0 @@ | |
-<script setup> | |
-import { computed } from "vue"; | |
- | |
-import OverLine from "@/components/ui/typography/OverLine.vue"; | |
-import HeaderSmall from "@/components/ui/typography/HeaderSmall.vue"; | |
-import BodyRegular from "@/components/ui/typography/BodyRegular.vue"; | |
- | |
-const props = defineProps({ | |
- variant: { | |
- type: String, | |
- default: 'primary', | |
- validator: (value) => ['primary','secondary','simple'].includes(value) | |
- }, | |
- backgroundImage: { | |
- type: String, | |
- required: true | |
- }, | |
- heightClass: { | |
- type: String, | |
- default: "" | |
- }, | |
- title: { type: String, required: true }, | |
- content: { type: String, required: true }, | |
- date: { type: String, required: true }, | |
- category: { type: String, required: true } | |
-}) | |
- | |
-const backgroundImageStyle = computed(() => ({ | |
- backgroundImage: `url(${props.backgroundImage})`, | |
- ...(props.variant === 'secondary' && { | |
- minHeight: '182px', | |
- maxHeight: '182px' | |
- }) | |
-})); | |
- | |
- | |
-</script> | |
- | |
-<template> | |
- <div class="flex flex-col gap-y-200" :class="heightClass"> | |
- <div class="flex-grow self-stretch bg-no-repeat bg-cover bg-center rounded-200" | |
- :style="backgroundImageStyle"> | |
- </div> | |
- <div v-if="date && category" class="flex gap-x-200 items-center"> | |
- <OverLine> | |
- {{ date }} | |
- </OverLine> | |
- <img src="@assets/divisor.svg" alt="divisor" style="height: 16px;"> | |
- <OverLine> | |
- {{ category }} | |
- </OverLine> | |
- </div> | |
- <HeaderSmall> | |
- {{ props.title }} | |
- </HeaderSmall> | |
- <BodyRegular v-if="props.variant === 'primary'"> | |
- {{ props.content }} | |
- </BodyRegular> | |
- </div> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/cards/MovieCard.vue b/src/components/ui/cards/MovieCard.vue | |
deleted file mode 100644 | |
index c5e0754..0000000 | |
--- a/src/components/ui/cards/MovieCard.vue | |
+++ /dev/null | |
@@ -1,73 +0,0 @@ | |
-<script setup> | |
-import { ref } from 'vue'; | |
- | |
-import TagMostra from '../tags/TagMostra.vue'; | |
-import TagScreening from '../tags/TagScreening.vue'; | |
-import BodyRegular from '../typography/BodyRegular.vue'; | |
-import HeaderSmall from '../typography/HeaderSmall.vue'; | |
-import OverLine from '../typography/OverLine.vue'; | |
-import { IconPin } from '@/components/ui/icons'; | |
- | |
-// Hover state | |
-const isHovered = ref(false); | |
-</script> | |
- | |
-<template> | |
- <!-- w-[380px] --> | |
- <div | |
- class="flex flex-col items-start gap-200" | |
- @mouseenter="isHovered = true" | |
- @mouseleave="isHovered = false" | |
- > | |
- <!-- image --> | |
- <div class="relative"> | |
- <img | |
- src="https://leiturafilmica.com.br/wp-content/uploads/2018/09/saneamento-basico-o-filme-1024x575.png" | |
- alt="movie-name poster" | |
- width="100%" | |
- class="rounded-200" | |
- > | |
- <!-- Overlay --> | |
- <div | |
- class="absolute inset-0 rounded-200 pointer-events-none" | |
- style="background: linear-gradient(180deg, rgba(0, 0, 0, 0.30) 36.54%, rgba(0, 0, 0, 0.45) 100%);" | |
- ></div> | |
- <!-- tag --> | |
- <TagMostra class="absolute top-0 left-0 rounded-tl-200" variant="gala-abertura" text="Gala de Abertura" /> | |
- | |
- <div class="content absolute bottom-250 left-250 flex flex-col gap-[5px]"> | |
- <!-- movie title --> | |
- <HeaderSmall color="text-white-transp-1000"> | |
- Saneamento Básico | |
- </HeaderSmall> | |
- <div class="flex items-center gap-200"> | |
- <OverLine color="text-white-transp-1000">ESPANHA</OverLine> | |
- <img src="@assets/divisor.svg" alt="divisor" height="16px" width="1px"> | |
- <OverLine color="text-white-transp-1000">FIC</OverLine> | |
- <img src="@assets/divisor.svg" alt="divisor" height="16px" width="1px"> | |
- <OverLine color="text-white-transp-1000">114'</OverLine> | |
- </div> | |
- <!-- Animated underline --> | |
- <span | |
- class="w-full bg-white-transp-900 transition-height duration-100" | |
- :style="{ height: isHovered ? '1px' : '0px' }" | |
- ></span> | |
- </div> | |
- </div> | |
- <div class="px-200 space-y-250 w-full"> | |
- <div class="flex items-center gap-[6px]"> | |
- <IconPin width="16" height="16" /> | |
- <BodyRegular> | |
- Cine Odeon - CCLSR - Centro | |
- </BodyRegular> | |
- </div> | |
- <div class="flex items-center space-x-200"> | |
- <TagScreening time="21h30" state="disabled" /> | |
- <TagScreening time="23h45" /> | |
- </div> | |
- </div> | |
- </div> | |
-</template> | |
- | |
-<style scoped> | |
-</style> | |
diff --git a/src/components/ui/cards/QuickLinkCard.vue b/src/components/ui/cards/QuickLinkCard.vue | |
deleted file mode 100644 | |
index 2d774d7..0000000 | |
--- a/src/components/ui/cards/QuickLinkCard.vue | |
+++ /dev/null | |
@@ -1,27 +0,0 @@ | |
-<script setup> | |
-import ButtonText from '../buttons/ButtonText.vue'; | |
- | |
-const props = defineProps({ | |
- title: { | |
- type: String, | |
- required: true, | |
- }, | |
- description: { | |
- type: String, | |
- required: true, | |
- }, | |
- href: { | |
- type: String, | |
- default: "#" | |
- } | |
-}) | |
-</script> | |
- | |
-<template> | |
- <div class="flex flex-col gap-y-5 border-l border-neutrals-300 px-[1.25rem] py-0"> | |
- <ButtonText tag="a" :href="href" variant="dark" size="md" :text="props.title"/> | |
- <p class="text-neutrals-900 font-body text-md font-light leading-[24px] tracking-wid">{{ props.description }}</p> | |
- </div> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/icons/index.js b/src/components/ui/icons/index.js | |
deleted file mode 100644 | |
index d1a6521..0000000 | |
--- a/src/components/ui/icons/index.js | |
+++ /dev/null | |
@@ -1,26 +0,0 @@ | |
-export { default as BaseIcon } from "./BaseIcon.vue" | |
- | |
-// Actions | |
-export { default as IconClose } from "./actions/IconClose.vue" | |
-export { default as IconFilter } from "./actions/IconFilter.vue" | |
-export { default as IconPlus } from "./actions/IconPlus.vue" | |
-export { default as IconSearch } from "./actions/IconSearch.vue" | |
- | |
-// Navigation | |
-export { default as IconCarretUp } from "./navigation/IconCarretUp.vue" | |
-export { default as IconChevronLeft } from "./navigation/IconChevronLeft.vue" | |
-export { default as IconChevronRight } from "./navigation/IconChevronRight.vue" | |
-export { default as IconMenu } from "./navigation/IconMenu.vue" | |
- | |
-// Status | |
-export { default as IconCheck } from "./status/IconCheck.vue" | |
- | |
-// Misc | |
-export { default as IconChange } from "./misc/IconChange.vue" | |
-export { default as IconClock } from "./misc/IconClock.vue" | |
-export { default as IconDash } from "./misc/IconDash.vue" | |
-export { default as IconInfo } from "./misc/IconInfo.vue" | |
-export { default as IconLink } from "./misc/IconLink.vue" | |
-export { default as IconNewUser } from "./misc/IconNewUser.vue" | |
-export { default as IconPin } from "./misc/IconPin.vue" | |
-export { default as IconProgram } from "./misc/IconProgram.vue" | |
diff --git a/src/components/ui/icons/misc/IconChange.vue b/src/components/ui/icons/misc/IconChange.vue | |
deleted file mode 100644 | |
index 29ad4df..0000000 | |
--- a/src/components/ui/icons/misc/IconChange.vue | |
+++ /dev/null | |
@@ -1,33 +0,0 @@ | |
-<script setup> | |
-import BaseIcon from "@/components/ui/icons/BaseIcon.vue"; | |
-const props = defineProps({ | |
- color: { type: String, default: undefined }, | |
- active: { type: Boolean, default: false } | |
-}) | |
-</script> | |
- | |
-<template> | |
- <BaseIcon viewBox="0 0 21 20" :active="active" :className="props.color"> | |
- <template #default="{ fill }"> | |
- | |
- <g clip-path="url(#clip0_28_1820)"> | |
- <path | |
- d="M10.1693 0.666748C5.01727 0.666748 0.835938 4.84808 0.835938 10.0001C0.835938 15.1521 5.01727 19.3334 10.1693 19.3334C15.3213 19.3334 19.5026 15.1521 19.5026 10.0001C19.5026 4.84808 15.3213 0.666748 10.1693 0.666748ZM11.0186 15.7401C10.9534 15.804 10.8708 15.8474 10.7812 15.8649C10.6915 15.8824 10.5987 15.8732 10.5142 15.8384C10.4297 15.8036 10.3573 15.7448 10.306 15.6692C10.2546 15.5937 10.2266 15.5048 10.2253 15.4134V14.6667H10.1693C8.9746 14.6667 7.77994 14.2094 6.86527 13.3041C6.23156 12.6688 5.79422 11.8645 5.60553 10.9873C5.41685 10.1101 5.4848 9.19704 5.80127 8.35741C5.9786 7.88141 6.60394 7.76008 6.9586 8.12408C7.16394 8.32941 7.2106 8.62808 7.11727 8.88941C6.68794 10.0467 6.9306 11.3907 7.86394 12.3241C8.51727 12.9774 9.37594 13.2854 10.2346 13.2667V12.3894C10.2346 11.9694 10.7386 11.7641 11.0279 12.0627L12.5399 13.5747C12.7266 13.7614 12.7266 14.0507 12.5399 14.2374L11.0186 15.7401ZM13.3799 11.8854C13.2835 11.7861 13.2173 11.6614 13.1892 11.5258C13.1611 11.3903 13.1722 11.2495 13.2213 11.1201C13.6506 9.96275 13.4079 8.61875 12.4746 7.68541C11.8213 7.03208 10.9626 6.71475 10.1133 6.73341V7.61075C10.1133 8.03075 9.60927 8.23608 9.31994 7.93741L7.7986 6.43475C7.61194 6.24808 7.61194 5.95875 7.7986 5.77208L9.3106 4.26008C9.37583 4.19613 9.45839 4.15273 9.54805 4.13525C9.63771 4.11778 9.73053 4.12701 9.815 4.16179C9.89947 4.19657 9.97187 4.25537 10.0232 4.33091C10.0746 4.40646 10.1027 4.49541 10.1039 4.58675V5.34275C11.3173 5.32408 12.5399 5.76275 13.4639 6.69608C14.0976 7.33133 14.535 8.13566 14.7237 9.01288C14.9124 9.89011 14.8444 10.8031 14.5279 11.6427C14.3506 12.1281 13.7346 12.2494 13.3799 11.8854Z" | |
- :fill="fill" | |
- /> | |
- </g> | |
- <defs> | |
- <clipPath id="clip0_28_1820"> | |
- <rect | |
- width="20" | |
- height="20" | |
- fill="white" | |
- transform="translate(0.167969)" | |
- /> | |
- </clipPath> | |
- </defs> | |
- </template> | |
- </BaseIcon> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/tags/TagFilter.vue b/src/components/ui/tags/TagFilter.vue | |
deleted file mode 100644 | |
index ff0a1d8..0000000 | |
--- a/src/components/ui/tags/TagFilter.vue | |
+++ /dev/null | |
@@ -1,17 +0,0 @@ | |
-<script setup> | |
-import { IconClose } from "@/components/ui/icons" | |
-const { text } = defineProps({ | |
- text: { | |
- type: String, | |
- required: true, | |
- } | |
-}) | |
-</script> | |
- | |
-<template> | |
- <span | |
- class=" max-w-fit inline-flex items-center gap-100 px-200 py-100 border rounded-full border-neutrals-300 font-body text-xs text-neutrals-700 font-regular leading-[18px]" | |
- >{{ text }} <IconClose /></span> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/tags/TagMostra.vue b/src/components/ui/tags/TagMostra.vue | |
deleted file mode 100644 | |
index 653e5ab..0000000 | |
--- a/src/components/ui/tags/TagMostra.vue | |
+++ /dev/null | |
@@ -1,88 +0,0 @@ | |
-<script> | |
- const VARIANT_CLASSES = { | |
- "gala-abertura": { | |
- filled: "bg-magenta-600 text-white-transp-1000", | |
- outline: "border-l-4 border-magenta-600 text-neutrals-900" | |
- }, | |
- "gala-encerramento": { | |
- filled: "bg-magenta-800 text-white", | |
- outline: "border-l-4 border-magenta-800 text-neutrals-900" | |
- }, | |
- "resistencias": { | |
- filled: "bg-white-transp-1000 text-neutrals-900", | |
- outline: "border-l-4 border-white-transp-1000 text-neutrals-900" | |
- }, | |
- "cine-memoria": { | |
- filled: "bg-violeta-200 text-neutrals-900", | |
- outline: "border-l-4 border-violeta-200 text-neutrals-900" | |
- }, | |
- "midnight-movies": { | |
- filled: "bg-violeta-600 text-white-transp-1000", | |
- outline: "border-l-4 border-violeta-600 text-neutrals-900" | |
- }, | |
- "cinema-capacete": { | |
- filled: "bg-neutrals-400 text-neutrals-900", | |
- outline: "border-l-4 border-neutrals-400 text-neutrals-900" | |
- }, | |
- "classicos-cults": { | |
- filled: "bg-neutrals-900 text-white-transp-1000", | |
- outline: "border-l-4 border-neutrals-900 text-neutrals-900" | |
- }, | |
- "expectativa": { | |
- filled: "bg-azul-600 text-white-transp-1000", | |
- outline: "border-l-4 border-azul-600 text-neutrals-900" | |
- }, | |
- "itinerarios": { | |
- filled: "bg-verde-600 text-white-transp-1000", | |
- outline: "border-l-4 border-verde-600 text-neutrals-900" | |
- }, | |
- "panorama": { | |
- filled: "bg-vermelho-600 text-white-transp-1000", | |
- outline: "border-l-4 border-vermelho-600 text-neutrals-900" | |
- }, | |
- "premiere-latina": { | |
- filled: "bg-amarelo-800 text-neutrals-900", | |
- outline: "border-l-4 border-amarelo-800 text-neutrals-900" | |
- }, | |
- "premiere-brasil": { | |
- filled: "bg-laranja-600 text-neutrals-900", | |
- outline: "border-l-4 border-laranja-600 text-neutrals-900" | |
- } | |
- } | |
- | |
-</script> | |
-<script setup> | |
-import { computed } from 'vue' | |
-const props = defineProps({ | |
- variant: { | |
- type: String, | |
- required: true, | |
- validator: (value) => Object.keys(VARIANT_CLASSES).includes(value), | |
- default: "cinema-capacete" | |
- }, | |
- text: { type: String, required: true }, | |
- mode: { | |
- type: String, | |
- required: true, | |
- validator: (value) => ['filled', 'outline'].includes(value), | |
- default: 'filled' | |
- } | |
-}) | |
- | |
-const finalClass = computed(() => { | |
- const variantClass = VARIANT_CLASSES[props.variant]?.[props.mode] | |
- | |
- if (!variantClass) { | |
- console.warn(`Invalid combination: variant="${props.variant}" mode="${props.mode}"`) | |
- return '' | |
- } | |
- | |
- return variantClass | |
-}) | |
-</script> | |
- | |
-<template> | |
- <span | |
- class="py-100 px-250 uppercase font-body text-2xs font-medium leading-[16px] tracking-widest rounded-br-100 max-w-fit" | |
- :class="finalClass" role="label" :aria-label="`${variant} tag`">{{ text }}</span> | |
-</template> | |
diff --git a/src/components/ui/tags/TagScreening.vue b/src/components/ui/tags/TagScreening.vue | |
deleted file mode 100644 | |
index 5b5b7db..0000000 | |
--- a/src/components/ui/tags/TagScreening.vue | |
+++ /dev/null | |
@@ -1,31 +0,0 @@ | |
-<script setup> | |
-import { computed } from 'vue' | |
- | |
-const props = defineProps({ | |
- state: { | |
- type: String, | |
- default: 'default', | |
- validator: (value) => ['default', 'active', 'disabled'].includes(value) | |
- }, | |
- time: { | |
- type: String, | |
- default: '21h30' | |
- } | |
-}) | |
- | |
-const baseClasses = `inline-flex items-center px-200 py-100 border rounded-sm max-w-fit | |
- text-2xs leading-[16px] tracking-widest | |
- font-body font-medium uppercase` | |
- | |
-const stateClasses = computed(() => ({ | |
- 'bg-neutrals-100 border-neutrals-300 text-neutrals-900': props.state === 'default', | |
- 'bg-neutrals-100 border-neutrals-900 text-neutrals-900': props.state === 'active', | |
- 'bg-neutrals-300 text-neutrals-600 cursor-not-allowed': props.state === 'disabled' | |
-})) | |
- | |
-const isDisabled = computed(() => props.state === 'disabled') | |
-</script> | |
- | |
-<template> | |
- <p :class="[baseClasses, stateClasses]" :aria-disabled="isDisabled">{{ time }}</p> | |
-</template> | |
diff --git a/src/components/ui/typography/BaseHeader.vue b/src/components/ui/typography/BaseHeader.vue | |
deleted file mode 100644 | |
index c0b725f..0000000 | |
--- a/src/components/ui/typography/BaseHeader.vue | |
+++ /dev/null | |
@@ -1,21 +0,0 @@ | |
-<script setup> | |
-const { fontSize, textColor } = defineProps({ | |
- fontSize: { | |
- type: String, | |
- default: 'text-xl' | |
- }, | |
- textColor: { | |
- type: String, | |
- default: "text-neutrals-1000" | |
- } | |
-}) | |
- | |
-const baseClasses = "font-heading font-semibold leading-[120%]" | |
- | |
-</script> | |
- | |
-<template> | |
- <h1 | |
- :class="[baseClasses, fontSize, textColor]" | |
- ><slot /></h1> | |
-</template> | |
diff --git a/src/components/ui/typography/BodyRegular.vue b/src/components/ui/typography/BodyRegular.vue | |
deleted file mode 100644 | |
index 41ba3af..0000000 | |
--- a/src/components/ui/typography/BodyRegular.vue | |
+++ /dev/null | |
@@ -1,11 +0,0 @@ | |
-<script setup></script> | |
- | |
-<template> | |
- <p class="font-body text-sm font-regular leading-[150%] text-neutrals-900"> | |
- <slot /> | |
- </p> | |
-</template> | |
- | |
-<style scoped> | |
- | |
-</style> | |
diff --git a/src/components/ui/typography/BodyStrongXs.vue b/src/components/ui/typography/BodyStrongXs.vue | |
deleted file mode 100644 | |
index b09d969..0000000 | |
--- a/src/components/ui/typography/BodyStrongXs.vue | |
+++ /dev/null | |
@@ -1,5 +0,0 @@ | |
-<template> | |
- <p class="text-neutrals-900 font-body text-xs font-semibold leading-[140%]"> | |
- <slot /> | |
- </p> | |
-</template> | |
diff --git a/src/components/ui/typography/HeaderSmall.vue b/src/components/ui/typography/HeaderSmall.vue | |
deleted file mode 100644 | |
index 39eca09..0000000 | |
--- a/src/components/ui/typography/HeaderSmall.vue | |
+++ /dev/null | |
@@ -1,12 +0,0 @@ | |
-<script setup> | |
-import BaseHeader from './BaseHeader.vue'; | |
-const props = defineProps({ | |
- color: { type: String, default: 'text-neutrals-900' } | |
-}) | |
-</script> | |
- | |
-<template> | |
- <BaseHeader :text-color="props.color" font-size="text-lg"><slot /></BaseHeader> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/typography/OverLine.vue b/src/components/ui/typography/OverLine.vue | |
deleted file mode 100644 | |
index 1d763a3..0000000 | |
--- a/src/components/ui/typography/OverLine.vue | |
+++ /dev/null | |
@@ -1,12 +0,0 @@ | |
-<script setup> | |
-const props = defineProps({ | |
- color: { type: String, default: 'text-neutrals-900' } | |
-}) | |
-</script> | |
-<template> | |
- <p | |
- :class="props.color" | |
- class="font-body font-medium text-2xs leading-[160%] tracking-widest uppercase"> | |
- <slot /> | |
- </p> | |
-</template> | |
diff --git a/src/components/ui/typography/SectionHeader.vue b/src/components/ui/typography/SectionHeader.vue | |
deleted file mode 100644 | |
index b3ca8a5..0000000 | |
--- a/src/components/ui/typography/SectionHeader.vue | |
+++ /dev/null | |
@@ -1,18 +0,0 @@ | |
-<script setup> | |
-const props = defineProps({ | |
- title: { type: String, required: true }, | |
-}) | |
-</script> | |
- | |
-<template> | |
- <h2 class=" | |
- font-heading | |
- text-lg | |
- text-neutrals-900 | |
- font-semibold | |
- leading-[28.8px]"> | |
- {{ props.title }} | |
- </h2> | |
-</template> | |
- | |
-<style scoped></style> | |
diff --git a/src/components/ui/typography/SubHeading.vue b/src/components/ui/typography/SubHeading.vue | |
deleted file mode 100644 | |
index f5e045f..0000000 | |
--- a/src/components/ui/typography/SubHeading.vue | |
+++ /dev/null | |
@@ -1,5 +0,0 @@ | |
-<template> | |
- <p | |
- class="text-neutrals-900 font-body text-xl font-regular leading-[30px]" | |
- ><slot /></p> | |
-</template> | |
diff --git a/src/composables/useI18n.js b/src/composables/useI18n.js | |
new file mode 100644 | |
index 0000000..510a78f | |
--- /dev/null | |
+++ b/src/composables/useI18n.js | |
@@ -0,0 +1,17 @@ | |
+import { useI18n as useVueI18n } from "vue-i18n"; | |
+ | |
+export function useI18n() { | |
+ const { t, locale } = useVueI18n(); | |
+ | |
+ // Helper for accessible labels | |
+ const ta = (key, fallback = "") => { | |
+ const translation = t(key); | |
+ return translation !== key ? translation : fallback; | |
+ }; | |
+ | |
+ return { | |
+ t, | |
+ ta, // t with accessibility fallback | |
+ locale, | |
+ }; | |
+} | |
diff --git a/src/i18n/index.js b/src/i18n/index.js | |
new file mode 100644 | |
index 0000000..a6afcb6 | |
--- /dev/null | |
+++ b/src/i18n/index.js | |
@@ -0,0 +1,17 @@ | |
+import { createI18n } from "vue-i18n"; | |
+import en from "./locales/en.json"; | |
+import pt from "./locales/pt.json"; | |
+ | |
+const i18n = createI18n( | |
+ // something vue-i18n options here ... | |
+ { | |
+ legacy: false, | |
+ locale: "pt", | |
+ fallbackLocale: "en", | |
+ messages: { | |
+ en, | |
+ pt, | |
+ }, | |
+ }, | |
+); | |
+export default i18n; | |
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json | |
new file mode 100644 | |
index 0000000..6ea54a2 | |
--- /dev/null | |
+++ b/src/i18n/locales/en.json | |
@@ -0,0 +1,65 @@ | |
+{ | |
+ "home": { | |
+ "banner_title": "The 26th edition of Festival do Rio is coming!", | |
+ "banner_subtitle": "From October 2 to 12, cinema will be under the light of Rio" | |
+ }, | |
+ "quickLinks": { | |
+ "programming": { | |
+ "title": "PROGRAMMING", | |
+ "description": "View the complete schedule or filter according to what you want." | |
+ }, | |
+ "tickets": { | |
+ "title": "TICKETS", | |
+ "description": "Find out how to secure your entry to cinemas and events." | |
+ }, | |
+ "schedule": { | |
+ "title": "SCHEDULE CHANGES", | |
+ "description": "Plan ahead by checking schedule changes." | |
+ } | |
+ }, | |
+ "navigation": { | |
+ "programming": "Programming", | |
+ "edition2024": "2024 Edition", | |
+ "aboutUs": "About Us", | |
+ "news": "News", | |
+ "media": "Media", | |
+ "information": "Information", | |
+ "press": "Press", | |
+ "archive": "Archive", | |
+ "registrations": "Registration", | |
+ "contact": "Contact", | |
+ "sessoes_com_convidados": "Sessions with Guests", | |
+ "mudancas_na_programacao": "Program Updates", | |
+ "sessoes_ao_ar_livre": "Open-Air Screenings" | |
+ }, | |
+ "filter": { | |
+ "title": "Filters", | |
+ "date": "Date", | |
+ "time": "Time", | |
+ "submostra": "Showcase", | |
+ "cinema": "Cinema", | |
+ "genero": "Genre", | |
+ "pais": "Country", | |
+ "direcao": "Director", | |
+ "elenco": "Cast", | |
+ "selo": "Label", | |
+ "festivais": "Festivals", | |
+ "premios": "Awards", | |
+ "palavras_chaves": "Keywords" | |
+ }, | |
+ "filter_by": { | |
+ "date": "by date", | |
+ "time": "by time", | |
+ "genre": "by genre" | |
+ }, | |
+ "loading": { | |
+ "title": "Loading", | |
+ "movies": "Loading movies" | |
+ }, | |
+ "datepicker": { | |
+ "pick_date": "Pick a date" | |
+ }, | |
+ "placeholder": { | |
+ "select": "Pick one" | |
+ } | |
+} | |
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json | |
new file mode 100644 | |
index 0000000..1b6b358 | |
--- /dev/null | |
+++ b/src/i18n/locales/pt.json | |
@@ -0,0 +1,66 @@ | |
+{ | |
+ "home": { | |
+ "banner_title": "A 26ª edição do Festival do Rio vem aí!", | |
+ "banner_subtitle": "De 2 a 12 de outubro o cinema estará sob a luz do Rio" | |
+ }, | |
+ "quickLinks": { | |
+ "programming": { | |
+ "title": "PROGRAMAÇÃO", | |
+ "description": "Veja a programação completa ou filtre de acordo com o que deseja." | |
+ }, | |
+ "tickets": { | |
+ "title": "INGRESSOS", | |
+ "description": "Descubra como garantir sua entrada nos cinemas e eventos." | |
+ }, | |
+ "schedule": { | |
+ "title": "MUDANÇAS NA PROGRAMAÇÃO", | |
+ "description": "Planeje-se verificando as mudanças na programação." | |
+ } | |
+ }, | |
+ "navigation": { | |
+ "programming": "Programação", | |
+ "edition2024": "Edição 2024", | |
+ "aboutUs": "Sobre nós", | |
+ "news": "Notícias", | |
+ "media": "Mídias", | |
+ "information": "Informações", | |
+ "press": "Imprensa", | |
+ "archive": "Arquivo", | |
+ "registrations": "Inscrições", | |
+ "contact": "Contato", | |
+ "sessoes_com_convidados": "Sessões com convidados", | |
+ "mudancas_na_programacao": "Mudanças na programação", | |
+ "sessoes_ao_ar_livre": "Sessões ao ar livre" | |
+ }, | |
+ "filter": { | |
+ "title": "Filtros", | |
+ "date": "Data", | |
+ "time": "Horário", | |
+ "submostra": "Mostra", | |
+ "cinema": "Cinema", | |
+ "genero": "Genero", | |
+ "pais": "País", | |
+ "direcao": "Direção", | |
+ "elenco": "Elenco", | |
+ "selo": "Selo", | |
+ "festivais": "Festivais", | |
+ "premios": "Prêmios", | |
+ "palavras_chaves": "Palavras chaves" | |
+ }, | |
+ "filtro": "Filtro | Filtros", | |
+ "filter_by": { | |
+ "date": "por data", | |
+ "time": "por horário", | |
+ "genre": "por gênero" | |
+ }, | |
+ "loading": { | |
+ "title": "Carregando", | |
+ "movies": "Carregando filmes" | |
+ }, | |
+ "datepicker": { | |
+ "pick_date": "Escolha uma data" | |
+ }, | |
+ "placeholder": { | |
+ "select": "Escolha um(a)" | |
+ } | |
+} | |
diff --git a/src/lib/fakeData.js b/src/lib/fakeData.js | |
new file mode 100644 | |
index 0000000..a3905cd | |
--- /dev/null | |
+++ b/src/lib/fakeData.js | |
@@ -0,0 +1,199 @@ | |
+export const collection = [ | |
+ { value: "Joachim Trier", label: "Joachim Trier" }, | |
+ { value: "Lynne Ramsay", label: "Lynne Ramsay" }, | |
+ { | |
+ value: "Isabel Joffily e Pedro Rossi", | |
+ label: "Isabel Joffily e Pedro Rossi", | |
+ }, | |
+ { value: "Julia Ducournau", label: "Julia Ducournau" }, | |
+ { | |
+ value: "Marcio Reolon e Filipe Matzembacher", | |
+ label: "Marcio Reolon e Filipe Matzembacher", | |
+ }, | |
+ { value: "Lloyd Lee Choi", label: "Lloyd Lee Choi" }, | |
+ { | |
+ value: "Bruno Jorge, Mariana Oliva e Renato Terra", | |
+ label: "Bruno Jorge, Mariana Oliva e Renato Terra", | |
+ }, | |
+ { value: "Françoise Ferraton", label: "Françoise Ferraton" }, | |
+ { value: "Natesh Hegde", label: "Natesh Hegde" }, | |
+ { | |
+ value: "Delphine Coulin e Muriel Coulin", | |
+ label: "Delphine Coulin e Muriel Coulin", | |
+ }, | |
+ { value: "Maya Da-Rin", label: "Maya Da-Rin" }, | |
+ { value: "Luciano Zito", label: "Luciano Zito" }, | |
+ { value: "Anna Cornudella", label: "Anna Cornudella" }, | |
+ { value: "Daniel Bandeira", label: "Daniel Bandeira" }, | |
+ { value: "Mehrnoush Alia", label: "Mehrnoush Alia" }, | |
+ { value: "Adriana L. Dutra", label: "Adriana L. Dutra" }, | |
+ { value: "João Rosas", label: "João Rosas" }, | |
+ { | |
+ value: "Vincent Carelli, Tatiana Almeida e Ernesto de Carvalho", | |
+ label: "Vincent Carelli, Tatiana Almeida e Ernesto de Carvalho", | |
+ }, | |
+ { value: "Vibeke Løkkeberg", label: "Vibeke Løkkeberg" }, | |
+ { value: "Vivian Qu", label: "Vivian Qu" }, | |
+ { value: "Tereza Nvotová", label: "Tereza Nvotová" }, | |
+ { | |
+ value: "Luiz Bolognesi, Jean Cullen de Moura e Marcelo Fernandes de Moura", | |
+ label: "Luiz Bolognesi, Jean Cullen de Moura e Marcelo Fernandes de Moura", | |
+ }, | |
+ { value: "Luca Guadagnino", label: "Luca Guadagnino" }, | |
+ { | |
+ value: "Aurélien Vernhes-Lermusiaux", | |
+ label: "Aurélien Vernhes-Lermusiaux", | |
+ }, | |
+ { | |
+ value: "Eleanor Coppola, Fax Bahr e George Hickenlooper", | |
+ label: "Eleanor Coppola, Fax Bahr e George Hickenlooper", | |
+ }, | |
+ { value: "Ido Fluk", label: "Ido Fluk" }, | |
+ { | |
+ value: "Carlos Fausto, Takumã Kuikuro e Leonardo Sette", | |
+ label: "Carlos Fausto, Takumã Kuikuro e Leonardo Sette", | |
+ }, | |
+ { value: "Emilie Blichfeldt", label: "Emilie Blichfeldt" }, | |
+ { value: "Ondřej Provazník", label: "Ondřej Provazník" }, | |
+ { value: "Estêvão Ciavatta", label: "Estêvão Ciavatta" }, | |
+ { value: "Ebs Burnough", label: "Ebs Burnough" }, | |
+ { value: " Sang-il Lee", label: " Sang-il Lee" }, | |
+ { value: "Pedro de Filippis", label: "Pedro de Filippis" }, | |
+ { value: "Hong Sang-soo", label: "Hong Sang-soo" }, | |
+ { | |
+ value: "Philip Delaquis e Barbara Miller ", | |
+ label: "Philip Delaquis e Barbara Miller ", | |
+ }, | |
+ { value: "Alexe Poukine", label: "Alexe Poukine" }, | |
+ { | |
+ value: "Tati Franklin e Suellen Vasconcelos ", | |
+ label: "Tati Franklin e Suellen Vasconcelos ", | |
+ }, | |
+ { value: "Radu Jude", label: "Radu Jude" }, | |
+ { value: "Silvina Schnicer", label: "Silvina Schnicer" }, | |
+ { value: "Pauline Loquès", label: "Pauline Loquès" }, | |
+ { value: "Bruno Murtinho", label: "Bruno Murtinho" }, | |
+ { value: "Mercedes Bryce Morgan", label: "Mercedes Bryce Morgan" }, | |
+ { value: "Jean-François Ravagnan ", label: "Jean-François Ravagnan " }, | |
+ { | |
+ value: "Marco Altberg e Tainá De Luccas", | |
+ label: "Marco Altberg e Tainá De Luccas", | |
+ }, | |
+ { | |
+ value: | |
+ "Toby Schmutzler, Kevin Schmutzler, Apuu Mourine Munyes e Vallentine Chelluget", | |
+ label: | |
+ "Toby Schmutzler, Kevin Schmutzler, Apuu Mourine Munyes e Vallentine Chelluget", | |
+ }, | |
+ { value: "Denis Villeneuve", label: "Denis Villeneuve" }, | |
+ { value: "Eduardo Ades", label: "Eduardo Ades" }, | |
+ { value: "Toby Perl Freilich", label: "Toby Perl Freilich" }, | |
+ { value: "Fanny Ovesen", label: "Fanny Ovesen" }, | |
+ { value: "Mary Bronstein", label: "Mary Bronstein" }, | |
+ { value: "Andrea Tonacci", label: "Andrea Tonacci" }, | |
+ { value: "Dolores Fonzi", label: "Dolores Fonzi" }, | |
+ { value: "Mehmet Akif Büyükatalay", label: "Mehmet Akif Büyükatalay" }, | |
+ { value: "Ethan Coen", label: "Ethan Coen" }, | |
+ { value: "Sammy Baloji", label: "Sammy Baloji" }, | |
+ { | |
+ value: "Renée Nader Messora e João Salaviza", | |
+ label: "Renée Nader Messora e João Salaviza", | |
+ }, | |
+ { value: "Mohamed Rashad", label: "Mohamed Rashad" }, | |
+ { value: "Camila Freitas", label: "Camila Freitas" }, | |
+ { value: "Billy Shebar", label: "Billy Shebar" }, | |
+ { value: "Gemma Blasco", label: "Gemma Blasco" }, | |
+ { value: "Rebecca Zlotowski", label: "Rebecca Zlotowski" }, | |
+ { value: "Jorge Furtado", label: "Jorge Furtado" }, | |
+ { value: "Laura Casabe", label: "Laura Casabe" }, | |
+ { value: "Michael Tyburski", label: "Michael Tyburski" }, | |
+ { value: "Teresa Villaverde", label: "Teresa Villaverde" }, | |
+ { | |
+ value: "Lucy Walker, Karen Harley e João Jardim", | |
+ label: "Lucy Walker, Karen Harley e João Jardim", | |
+ }, | |
+ { | |
+ value: "Chelsea Greene, Rob Grobman e Edivan Guajajara", | |
+ label: "Chelsea Greene, Rob Grobman e Edivan Guajajara", | |
+ }, | |
+ { | |
+ value: "Alessio Rigo de Righi e Matteo Zoppis", | |
+ label: "Alessio Rigo de Righi e Matteo Zoppis", | |
+ }, | |
+ { value: "Pia Marais", label: "Pia Marais" }, | |
+ { | |
+ value: "Vivian Ostrovsky e Ruti Gadish", | |
+ label: "Vivian Ostrovsky e Ruti Gadish", | |
+ }, | |
+ { | |
+ value: "Charlotte Devillers e Arnaud Dufeys", | |
+ label: "Charlotte Devillers e Arnaud Dufeys", | |
+ }, | |
+ { value: "Agnieszka Holland", label: "Agnieszka Holland" }, | |
+ { value: "Abderrahmane Sissako", label: "Abderrahmane Sissako" }, | |
+ { value: "Iván Fund", label: "Iván Fund" }, | |
+ { value: "Čejen Černić Čanak", label: "Čejen Černić Čanak" }, | |
+ { value: "Paolo Sorrentino", label: "Paolo Sorrentino" }, | |
+ { value: "Matteo Garrone", label: "Matteo Garrone" }, | |
+ { value: "Chase Joynt", label: "Chase Joynt" }, | |
+ { value: "Alireza Khatami", label: "Alireza Khatami" }, | |
+ { value: "Anna Cazenave Cambet", label: "Anna Cazenave Cambet" }, | |
+ { | |
+ value: "Mariano Cohn e Gastón Duprat", | |
+ label: "Mariano Cohn e Gastón Duprat", | |
+ }, | |
+ { value: "Morad Mostafa", label: "Morad Mostafa" }, | |
+ { value: "Diego Céspedes", label: "Diego Céspedes" }, | |
+ { value: "Sergei Loznitsa", label: "Sergei Loznitsa" }, | |
+ { value: "Peter Kerekes", label: "Peter Kerekes" }, | |
+ { value: "Claire Simon", label: "Claire Simon" }, | |
+ { value: "Avelina Prat", label: "Avelina Prat" }, | |
+ { value: "Kristen Stewart", label: "Kristen Stewart" }, | |
+ { value: "Ernesto Martínez Bucio", label: "Ernesto Martínez Bucio" }, | |
+ { value: "Jaume Claret Muxart", label: "Jaume Claret Muxart" }, | |
+ { value: "Nadav Lapid", label: "Nadav Lapid" }, | |
+ { value: "Maria Eriksson-Hecht", label: "Maria Eriksson-Hecht" }, | |
+ { value: "Allan Ribeiro", label: "Allan Ribeiro" }, | |
+ { value: "Yolanda Centeno", label: "Yolanda Centeno" }, | |
+ { value: "Kelly Reichardt", label: "Kelly Reichardt" }, | |
+ { value: "Davi Pretto", label: "Davi Pretto" }, | |
+ { value: "Ratchapoom Boonbunchachoke", label: "Ratchapoom Boonbunchachoke" }, | |
+ { value: "Tina Romero", label: "Tina Romero" }, | |
+ { value: "Daniel Raim", label: "Daniel Raim" }, | |
+ { value: "Huo Meng", label: "Huo Meng" }, | |
+ { value: "Sylvain Chomet", label: "Sylvain Chomet" }, | |
+ { value: "Cristiano Burlan", label: "Cristiano Burlan" }, | |
+ { value: "Thierry Klifa", label: "Thierry Klifa" }, | |
+ { value: "Urška Djukić", label: "Urška Djukić" }, | |
+ { value: "Francis Ford Coppola", label: "Francis Ford Coppola" }, | |
+ { value: "Fernando Eimbcke", label: "Fernando Eimbcke" }, | |
+ { value: "Max Walker-Silverman", label: "Max Walker-Silverman" }, | |
+ { value: "Tolga Karaçelik", label: "Tolga Karaçelik" }, | |
+ { value: "Dean Francis", label: "Dean Francis" }, | |
+ { value: "Elena Manrique", label: "Elena Manrique" }, | |
+ { value: "Pedro Cabeleira", label: "Pedro Cabeleira" }, | |
+ { | |
+ value: "Kevin Macdonald e Sam Rice-Edwards", | |
+ label: "Kevin Macdonald e Sam Rice-Edwards", | |
+ }, | |
+ { | |
+ value: "Leela Varghese e Emma Hough Hobb", | |
+ label: "Leela Varghese e Emma Hough Hobb", | |
+ }, | |
+ { value: "Kateryna Gornostai", label: "Kateryna Gornostai" }, | |
+ { value: "Oliver Laxe", label: "Oliver Laxe" }, | |
+ { value: "Sven Bresser", label: "Sven Bresser" }, | |
+]; | |
+ | |
+export const showcases = [ | |
+ { | |
+ label: "Première Brasil", | |
+ value: "premiere-brasil", | |
+ iconColor: "bg-laranja-600", | |
+ }, | |
+ { | |
+ label: "Première Latina", | |
+ value: "premiere-latina", | |
+ iconColor: "bg-amarelo-800", | |
+ }, | |
+]; | |
diff --git a/src/lib/utils.js b/src/lib/utils.js | |
new file mode 100644 | |
index 0000000..5924575 | |
--- /dev/null | |
+++ b/src/lib/utils.js | |
@@ -0,0 +1,13 @@ | |
+import { clsx } from "clsx"; | |
+import { twMerge } from "tailwind-merge"; | |
+ | |
+export function cn(...inputs) { | |
+ return twMerge(clsx(inputs)); | |
+} | |
+ | |
+export function valueUpdater(updaterOrValue, ref) { | |
+ ref.value = | |
+ typeof updaterOrValue === "function" | |
+ ? updaterOrValue(ref.value) | |
+ : updaterOrValue; | |
+} | |
diff --git a/src/main.js b/src/main.js | |
index 3c9bfeb..671ed34 100644 | |
--- a/src/main.js | |
+++ b/src/main.js | |
@@ -1,5 +1,18 @@ | |
import { createApp } from "vue"; | |
+import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; | |
+ | |
+import riffRouter from "./router/index.js"; | |
+import i18n from "./i18n/index.js"; | |
import "./style.css"; | |
+import "./assets/css/typography.css"; | |
+import "./assets/css/animations.css"; | |
import App from "./App.vue"; | |
-createApp(App).mount("#app"); | |
+const app = createApp(App); | |
+ | |
+const queryClient = new QueryClient(); | |
+ | |
+app.use(riffRouter); | |
+app.use(i18n); | |
+app.use(VueQueryPlugin, { queryClient }); | |
+app.mount("#app"); | |
diff --git a/src/router/index.js b/src/router/index.js | |
new file mode 100644 | |
index 0000000..daf20e9 | |
--- /dev/null | |
+++ b/src/router/index.js | |
@@ -0,0 +1,30 @@ | |
+import { createWebHistory, createRouter } from "vue-router"; | |
+const routes = [ | |
+ { | |
+ path: "/", | |
+ name: "home", | |
+ component: () => | |
+ import("@/components/features/home/components/HomeView.vue"), | |
+ }, | |
+ { | |
+ path: "/programming", | |
+ name: "programming", | |
+ component: () => | |
+ import( | |
+ "@/components/features/programming/components/ProgrammingView.vue" | |
+ ), | |
+ }, | |
+ { | |
+ path: "/components", | |
+ name: "components", | |
+ component: () => import("@/views/ComponentsView.vue"), | |
+ }, | |
+]; | |
+ | |
+const riffRouter = createRouter({ | |
+ // history: createMemoryHistory(), | |
+ history: createWebHistory(), | |
+ routes, | |
+}); | |
+ | |
+export default riffRouter; | |
diff --git a/src/services/api/api_client.js b/src/services/api/api_client.js | |
deleted file mode 100644 | |
index 4abaf34..0000000 | |
--- a/src/services/api/api_client.js | |
+++ /dev/null | |
@@ -1,25 +0,0 @@ | |
-import axios from "axios"; | |
- | |
-const apiClient = axios.create({ | |
- baseURL: import.meta.env.VITE_API_BASE_URL, | |
- headers: { Accept: 'application/json'} | |
-}) | |
- | |
-apiClient.interceptors.request.use((config) => { | |
- const token = import.meta.env.VITE_TMDB_TOKEN | |
- if(token) { | |
- config.headers.Authorization = `Bearer ${token}` | |
- } | |
- return config | |
-}) | |
- | |
-apiClient.interceptors.response.use( | |
- (response) => response, | |
- (error) => { | |
- console.error('API Error:', error); | |
- // Optional: show toast notification | |
- return Promise.reject(error); | |
- } | |
-); | |
- | |
-export default apiClient | |
diff --git a/src/services/api/client/apiClient.js b/src/services/api/client/apiClient.js | |
new file mode 100644 | |
index 0000000..b784553 | |
--- /dev/null | |
+++ b/src/services/api/client/apiClient.js | |
@@ -0,0 +1,36 @@ | |
+import axios from "axios"; | |
+import { handleApiError } from "./errorHandler"; | |
+ | |
+if (!import.meta.env.VITE_API_BASE_URL) { | |
+ throw new Error("VITE_API_BASE_URL environment variable is required"); | |
+} | |
+ | |
+const apiClient = axios.create({ | |
+ baseURL: import.meta.env.VITE_API_BASE_URL, | |
+ headers: { Accept: "application/json, text/xml, */*" }, | |
+}); | |
+ | |
+// Request interceptor | |
+apiClient.interceptors.request.use( | |
+ (config) => { | |
+ // Set default timeout | |
+ if (!config.timeout) { | |
+ config.timeout = config.url?.includes("/xml/") ? 60000 : 30000; | |
+ } | |
+ | |
+ // Add auth token if available (commented out as in original) | |
+ // const token = import.meta.env.VITE_TMDB_TOKEN | |
+ // if(token) { | |
+ // config.headers.Authorization = `Bearer ${token}` | |
+ // } | |
+ | |
+ return config; | |
+ }, | |
+ (error) => { | |
+ return Promise.reject(error); | |
+ }, | |
+); | |
+ | |
+apiClient.interceptors.response.use((response) => response, handleApiError); | |
+ | |
+export default apiClient; | |
diff --git a/src/services/api/client/errorHandler.js b/src/services/api/client/errorHandler.js | |
new file mode 100644 | |
index 0000000..deab6eb | |
--- /dev/null | |
+++ b/src/services/api/client/errorHandler.js | |
@@ -0,0 +1,130 @@ | |
+class ApiErrorHandler { | |
+ constructor() { | |
+ this.errorMap = { | |
+ 400: "Bad Request - Invalid data sent", | |
+ 401: "Unauthorized - Please log in again", | |
+ 403: "Forbidden - You don't have permission", | |
+ 404: "Not Found - The requested resource doesn't exist", | |
+ 408: "Request Timeout - Please try again", | |
+ 429: "Too Many Requests - Please slow down", | |
+ 500: "Server Error - Something went wrong on our end", | |
+ 502: "Bad Gateway - Service temporarily unavailable", | |
+ 503: "Service Unavailable - Please try again later", | |
+ 504: "Gateway Timeout - Request took too long", | |
+ }; | |
+ | |
+ this.errorCount = new Map(); | |
+ } | |
+ | |
+ handleError(error) { | |
+ const errorContext = this.buildErrorContext(error); | |
+ | |
+ const errorKey = `${errorContext.status}-${errorContext.url}`; | |
+ this.errorCount.set(errorKey, (this.errorCount.get(errorKey) || 0) + 1); | |
+ | |
+ console.group("API Error"); | |
+ console.error("Error Details:", errorContext); | |
+ console.error( | |
+ "Error Count for this endpoint:", | |
+ this.errorCount.get(errorKey), | |
+ ); | |
+ console.groupEnd(); | |
+ | |
+ // Network error (no response) | |
+ if (!error.response) { | |
+ if (error.code === "ECONNABORTED") { | |
+ return this.createSafeError("Request timeout - please try again", 408); | |
+ } | |
+ return this.createSafeError( | |
+ "Network error - please check your connection", | |
+ 0, | |
+ ); | |
+ } | |
+ | |
+ const status = error.response.status; | |
+ const message = this.errorMap[status] || "An unexpected error occurred"; | |
+ | |
+ // Don't log sensitive data in production | |
+ if (import.meta.env.DEV) { | |
+ console.group("Full Error Details (DEV)"); | |
+ console.error("Full axios error:", error); | |
+ console.error("Request config:", error.config); | |
+ console.error("Response data:", error.response?.data); | |
+ console.groupEnd(); | |
+ } | |
+ | |
+ return this.createSafeError(message, status); | |
+ } | |
+ | |
+ buildErrorContext(error) { | |
+ return { | |
+ status: error.response?.status || 0, | |
+ statusText: error.response?.statusText || "Network Error", | |
+ url: error.config?.url || "Unknown", | |
+ method: error.config?.method?.toUpperCase() || "Unknown", | |
+ | |
+ timestamp: new Date().toISOString(), | |
+ | |
+ // userAgent: navigator.userAgent, | |
+ // language: navigator.language, | |
+ // online: navigator.onLine, | |
+ | |
+ // viewport: `${window.innerWidth}x${window.innerHeight}`, | |
+ | |
+ timeout: error.config?.timeout, | |
+ responseTime: | |
+ error.config?.metadata?.endTime - error.config?.metadata?.startTime, | |
+ | |
+ isNetworkError: !error.response, | |
+ isTimeoutError: error.code === "ECONNABORTED", | |
+ isServerError: error.response?.status >= 500, | |
+ isClientError: | |
+ error.response?.status >= 400 && error.response?.status < 500, | |
+ }; | |
+ } | |
+ | |
+ createSafeError(message, status) { | |
+ const safeError = new Error(message); | |
+ safeError.status = status; | |
+ safeError.isSafe = true; // Mark as safe to display to user | |
+ return safeError; | |
+ } | |
+ | |
+ // Optional: Method to show user-friendly notifications | |
+ showErrorNotification(error) { | |
+ // You can integrate with your notification system here | |
+ // For example: toast.error(error.message) | |
+ console.warn("User Error:", error.message); | |
+ } | |
+ | |
+ // Easy migration path for future monitoring | |
+ getErrorStats() { | |
+ return { | |
+ totalErrors: Array.from(this.errorCount.values()).reduce( | |
+ (a, b) => a + b, | |
+ 0, | |
+ ), | |
+ uniqueErrors: this.errorCount.size, | |
+ errorBreakdown: Object.fromEntries(this.errorCount), | |
+ }; | |
+ } | |
+} | |
+ | |
+// Create singleton instance | |
+const errorHandler = new ApiErrorHandler(); | |
+ | |
+// Export the error handling function | |
+export const handleApiError = (error) => { | |
+ const safeError = errorHandler.handleError(error); | |
+ | |
+ // Optionally show notification to user | |
+ errorHandler.showErrorNotification(safeError); | |
+ | |
+ return Promise.reject(safeError); | |
+}; | |
+ | |
+// Export for future monitoring | |
+export const getErrorStats = () => errorHandler.getErrorStats(); | |
+ | |
+// Export the class for testing or advanced usage | |
+export default errorHandler; | |
diff --git a/src/services/api/endpoints/movies.js b/src/services/api/endpoints/movies.js | |
new file mode 100644 | |
index 0000000..cbd7979 | |
--- /dev/null | |
+++ b/src/services/api/endpoints/movies.js | |
@@ -0,0 +1,60 @@ | |
+import apiClient from "@/services/api/client/apiClient"; | |
+import { XMLParser } from "fast-xml-parser"; | |
+import { | |
+ validateXMLStructure, | |
+ validateXMLContent, | |
+ validateXMLSize, | |
+} from "@/services/validation/xmlValidation"; | |
+ | |
+const parser = new XMLParser({ | |
+ ignoreAttributes: false, | |
+ | |
+ processEntities: false, // Don't process entities | |
+ allowBooleanAttributes: false, // Strict attribute parsing | |
+ parseAttributeValue: false, // Don't parse attribute values | |
+ | |
+ stopNodes: ["*.entity"], // Stop processing at entity nodes | |
+ | |
+ trimValues: true, // Trim whitespace | |
+ parseTrueNumberOnly: true, // Only parse actual numbers | |
+ | |
+ ignoreNameSpace: true, // Ignore namespaces | |
+ parseTagValue: true, // Parse tag values strictly | |
+ parseNodeValue: true, // Parse node values strictly | |
+}); | |
+ | |
+export const fetchMovies = async () => { | |
+ try { | |
+ let xmlData; | |
+ // Use API | |
+ const endpoint = "/schedules/xml/programacao"; | |
+ const response = await apiClient.get(endpoint, { responseType: "text" }); | |
+ xmlData = response.data; | |
+ | |
+ validateXMLStructure(xmlData); | |
+ validateXMLSize(xmlData); | |
+ validateXMLContent(xmlData); | |
+ const parsed = parser.parse(xmlData); | |
+ | |
+ return parsed; | |
+ } catch (error) { | |
+ console.error("XML parsing error:", error.message); | |
+ | |
+ if (error.message.includes("malicious")) { | |
+ throw new Error("Invalid XML format received"); | |
+ } | |
+ | |
+ if (error.message.includes("too large")) { | |
+ throw new Error("XML too large"); | |
+ } | |
+ | |
+ if ( | |
+ error.message.includes("data type") || | |
+ error.message.includes("format") | |
+ ) { | |
+ throw new Error("Invalid XML data received"); | |
+ } | |
+ | |
+ throw new Error("Failed to fetch movie data"); | |
+ } | |
+}; | |
diff --git a/src/services/api/movies.js b/src/services/api/movies.js | |
deleted file mode 100644 | |
index 22376ab..0000000 | |
--- a/src/services/api/movies.js | |
+++ /dev/null | |
@@ -1,13 +0,0 @@ | |
-import apiClient from "./api_client"; | |
- | |
-export const fetchMovies = () => { | |
- const path = "/discover/movie"; | |
- return apiClient.get(path, {}) | |
- .then(res => res.data); | |
- }; | |
- | |
- export const fetchTrending = async () => { | |
- const path = "/trending/movie/week"; | |
- return apiClient.get(path, {}) | |
- .then(res => res.data); | |
-}; | |
diff --git a/src/services/validation/xmlValidation.js b/src/services/validation/xmlValidation.js | |
new file mode 100644 | |
index 0000000..b78071d | |
--- /dev/null | |
+++ b/src/services/validation/xmlValidation.js | |
@@ -0,0 +1,39 @@ | |
+export const validateXMLContent = (xmlData) => { | |
+ // Basic XML validation | |
+ if (xmlData === "") { | |
+ return; | |
+ } | |
+ | |
+ if (!xmlData || typeof xmlData !== "string") { | |
+ throw new Error("Invalid XML data type"); | |
+ } | |
+ | |
+ if (!xmlData.trim().startsWith("<?xml") && !xmlData.trim().startsWith("<")) { | |
+ throw new Error("Invalid XML format"); | |
+ } | |
+}; | |
+ | |
+export const validateXMLSize = (xmlData) => { | |
+ const MAX_SIZE = 1024 * 1024; // 1MB limit | |
+ if (xmlData.length > MAX_SIZE) { | |
+ throw new Error("XML too large"); | |
+ } | |
+}; | |
+ | |
+export const validateXMLStructure = (xmlData) => { | |
+ // Check for suspicious patterns | |
+ const suspiciousPatterns = [ | |
+ /<!DOCTYPE.*\[/i, // DOCTYPE with entities | |
+ /<!ENTITY/i, // Entity definitions | |
+ /SYSTEM\s+["']/i, // External system entities | |
+ /file:\/\//i, // File protocol | |
+ /http:\/\/localhost/i, // Localhost requests | |
+ /&\w+;/g, // Entity references | |
+ ]; | |
+ | |
+ for (const pattern of suspiciousPatterns) { | |
+ if (pattern.test(xmlData)) { | |
+ throw new Error("Potentially malicious XML detected"); | |
+ } | |
+ } | |
+}; | |
diff --git a/src/style.css b/src/style.css | |
index f0fe356..894a952 100644 | |
--- a/src/style.css | |
+++ b/src/style.css | |
@@ -1,8 +1,103 @@ | |
-@import url('https://fonts.googleapis.com/css2?family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'); | |
+@import url("https://fonts.googleapis.com/css2?family=Fira+Sans:wght@100;200;300;400;500;600;700;800;900&family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"); | |
+ | |
@tailwind base; | |
@tailwind components; | |
@tailwind utilities; | |
@theme { | |
- --header-font: "Fira Sans", monospace; | |
+} | |
+ | |
+@layer base { | |
+ :root { | |
+ --header-font: "Fira Sans", monospace; | |
+ --body-font: "Inter", monospace; | |
+ | |
+ /* 🌈 Background & Foreground */ | |
+ --background: 0 0% 100%; /* White background */ | |
+ --foreground: 0 0% 3.9%; /* Almost black text */ | |
+ | |
+ /* 🃏 Card Components */ | |
+ --card: 0 0% 100%; /* White card background */ | |
+ --card-foreground: 0 0% 3.9%; /* Card text color */ | |
+ | |
+ /* 📦 Popover / Dropdown */ | |
+ --popover: 0 0% 100%; | |
+ --popover-foreground: 0 0% 3.9%; | |
+ | |
+ /* 🎨 Primary Color (use your laranja 600) */ | |
+ --primary: 40 5% 22%; /* #FF7F00 → laranja.600 */ | |
+ --primary-foreground: 0 0% 100%; /* White text on primary */ | |
+ | |
+ /* 🎨 Secondary (neutral background) */ | |
+ --secondary: 0 0% 96.1%; /* #F5F4F4 → neutrals.200 */ | |
+ --secondary-foreground: 0 0% 10%; /* Dark text on light bg */ | |
+ | |
+ /* 🎨 Muted (gray tone for less emphasis) */ | |
+ --muted: 0 0% 96.1%; | |
+ --muted-foreground: 0 0% 45.1%; /* #736F68 approx neutrals.700 */ | |
+ | |
+ /* 🎨 Accent (highlight elements) */ | |
+ --accent: 0 0% 96.1%; | |
+ --accent-foreground: 0 0% 10%; | |
+ | |
+ /* 🚨 Destructive (error/red) */ | |
+ --destructive: 0 100% 50%; /* #FF0000 → vermelho.600 */ | |
+ --destructive-foreground: 0 0% 98%; /* White text on red */ | |
+ | |
+ /* 🔲 Borders & Inputs */ | |
+ --border: 0 0% 89.8%; /* Light gray for borders */ | |
+ --input: 0 0% 89.8%; | |
+ --ring: 0 0% 3.9%; /* Focus ring color */ | |
+ | |
+ /* 📊 Chart Colors (Optional) */ | |
+ --chart-1: 28 100% 50%; /* laranja */ | |
+ --chart-2: 160 80% 50%; /* verde */ | |
+ --chart-3: 220 80% 50%; /* azul */ | |
+ --chart-4: 340 80% 50%; /* magenta */ | |
+ --chart-5: 50 80% 50%; /* amarelo */ | |
+ | |
+ --radius: 0.5rem; /* Default border radius */ | |
+ } | |
+ | |
+ /* 🌑 Dark Mode */ | |
+ .dark { | |
+ --background: 0 0% 3.9%; /* Dark gray background */ | |
+ --foreground: 0 0% 98%; /* White text */ | |
+ | |
+ --card: 0 0% 3.9%; | |
+ --card-foreground: 0 0% 98%; | |
+ | |
+ --popover: 0 0% 3.9%; | |
+ --popover-foreground: 0 0% 98%; | |
+ | |
+ --primary: 28 100% 50%; /* Keep same orange */ | |
+ --primary-foreground: 0 0% 100%; | |
+ | |
+ --secondary: 0 0% 14.9%; | |
+ --secondary-foreground: 0 0% 98%; | |
+ | |
+ --muted: 0 0% 14.9%; | |
+ --muted-foreground: 0 0% 63.9%; | |
+ | |
+ --accent: 0 0% 14.9%; | |
+ --accent-foreground: 0 0% 98%; | |
+ | |
+ --destructive: 0 100% 50%; | |
+ --destructive-foreground: 0 0% 98%; | |
+ | |
+ --border: 0 0% 14.9%; | |
+ --input: 0 0% 14.9%; | |
+ --ring: 0 0% 83.1%; | |
+ } | |
+} | |
+ | |
+/* Apply your font */ | |
+@layer base { | |
+ * { | |
+ @apply border-border; | |
+ } | |
+ body { | |
+ font-family: var(--body-font, "Inter", sans-serif); | |
+ @apply bg-background text-foreground; | |
+ } | |
} | |
diff --git a/src/utils/helpers/formatDateHelper.js b/src/utils/helpers/formatDateHelper.js | |
new file mode 100644 | |
index 0000000..8a96fd5 | |
--- /dev/null | |
+++ b/src/utils/helpers/formatDateHelper.js | |
@@ -0,0 +1,4 @@ | |
+export function formatDate(dateCalendarObj) { | |
+ // Use your desired format (e.g., YYYY-MM-DD) | |
+ return `${dateCalendarObj.year}-${String(dateCalendarObj.month).padStart(2, "0")}-${String(dateCalendarObj.day).padStart(2, "0")}`; | |
+} | |
diff --git a/src/utils/helpers/objectHelpers.js b/src/utils/helpers/objectHelpers.js | |
new file mode 100644 | |
index 0000000..38a5696 | |
--- /dev/null | |
+++ b/src/utils/helpers/objectHelpers.js | |
@@ -0,0 +1,12 @@ | |
+export function cleanObject(obj) { | |
+ return Object.fromEntries( | |
+ Object.entries(obj).filter( | |
+ ([, v]) => v !== "" && v !== null && v !== undefined, | |
+ ), | |
+ ); | |
+} | |
+ | |
+export function toHHMM(timeStr) { | |
+ const [hh = "00", mm = "00"] = timeStr.split(":"); | |
+ return `${hh}:${mm}`; | |
+} | |
diff --git a/src/views/ComponentsView.vue b/src/views/ComponentsView.vue | |
new file mode 100644 | |
index 0000000..8089b94 | |
--- /dev/null | |
+++ b/src/views/ComponentsView.vue | |
@@ -0,0 +1,168 @@ | |
+<script setup> | |
+import { IconCheck } from "@/components/common/icons"; | |
+import { IconSearch } from "@/components/common/icons"; | |
+import { IconDash } from "@/components/common/icons"; | |
+import { IconCarretUp } from "@/components/common/icons"; | |
+import { IconInfo } from "@/components/common/icons"; | |
+import { IconPin } from "@/components/common/icons"; | |
+import { IconChange } from "@/components/common/icons"; | |
+import { IconNewUser } from "@/components/common/icons"; | |
+import { IconClose } from "@/components/common/icons"; | |
+import { IconClock } from "@/components/common/icons"; | |
+import { IconChevronLeft } from "@/components/common/icons"; | |
+import { IconChevronRight } from "@/components/common/icons"; | |
+import { IconLink } from "@/components/common/icons"; | |
+import { IconFilter } from "@/components/common/icons"; | |
+import { IconPlus } from "@/components/common/icons"; | |
+import { IconProgram } from "@/components/common/icons"; | |
+import { IconMenu } from "@/components/common/icons"; | |
+import TextInput from "@/components/common/forms/inputs/TextInput.vue"; | |
+import TagMostra from "@/components/common/tags/TagMostra.vue"; | |
+import TagScreening from "@/components/common/tags/TagScreening.vue"; | |
+import TagFilter from "@/components/common/tags/TagFilter.vue"; | |
+import TwContainer from "@/components/layout/TwContainer.vue"; | |
+import { ref } from "vue"; | |
+import CheckboxInput from "@/components/common/forms/inputs/CheckboxInput.vue"; | |
+const inputValue = ref("Flamengo"); | |
+const inputCheckboxValue = ref(false); | |
+</script> | |
+ | |
+<template> | |
+ <TwContainer> | |
+ <p>{{ inputValue }}</p> | |
+ <p>Checkbox: {{ inputCheckboxValue }}</p> | |
+ <TextInput | |
+ id="flamengo" | |
+ placeholder="Your email" | |
+ v-model:value="inputValue" | |
+ /> | |
+ <TextInput id="flamengo" placeholder="Your email" /> | |
+ <TextInput disabled="true" id="flamengo" placeholder="Your email" /> | |
+ <div class="flex space-x-200"> | |
+ <CheckboxInput | |
+ v-model="inputCheckboxValue" | |
+ label="Legenda" | |
+ id="form-12" | |
+ /> | |
+ <CheckboxInput label="Legenda" id="form-13" :disabled="true" /> | |
+ </div> | |
+ </TwContainer> | |
+ <TwContainer> | |
+ <div class="flex justify-center space-x-200 mb-600"> | |
+ <IconClock /> | |
+ <IconClock active="true" /> | |
+ <IconProgram /> | |
+ <IconProgram active="true" /> | |
+ <IconNewUser /> | |
+ <IconNewUser active="true" /> | |
+ <IconChange /> | |
+ <IconChange active="true" /> | |
+ <IconLink /> | |
+ <IconLink active="true" /> | |
+ <IconChevronRight /> | |
+ <IconChevronLeft /> | |
+ <IconMenu /> | |
+ <IconClose /> | |
+ <IconPin /> | |
+ <IconSearch /> | |
+ <IconPlus /> | |
+ <IconFilter /> | |
+ <IconInfo /> | |
+ <IconCheck /> | |
+ <IconDash /> | |
+ <IconCarretUp /> | |
+ </div> | |
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-4"> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="gala-abertura" | |
+ text="Gala de Abertura" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="gala-abertura" | |
+ text="Gala de Abertura" | |
+ /> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="gala-encerramento" | |
+ text="Gala de Encerramento" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="gala-encerramento" | |
+ text="Gala de Encerramento" | |
+ /> | |
+ <TagMostra mode="filled" variant="cine-memoria" text="CinE MemóRIA" /> | |
+ <TagMostra mode="outline" variant="cine-memoria" text="CinE MemóRIA" /> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="midnight-movies" | |
+ text="Midnight movies" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="midnight-movies" | |
+ text="Midnight movies" | |
+ /> | |
+ <TagMostra mode="filled" variant="expectativa" text="Expectativa" /> | |
+ <TagMostra mode="outline" variant="expectativa" text="Expectativa" /> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="premiere-brasil" | |
+ text="Premiere-brasil" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="premiere-brasil" | |
+ text="Premiere-brasil" | |
+ /> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="premiere-latina" | |
+ text="Premiere-latina" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="premiere-latina" | |
+ text="Premiere-latina" | |
+ /> | |
+ <TagMostra mode="filled" variant="resistencias" text="Resistencias" /> | |
+ <TagMostra mode="outline" variant="resistencias" text="Resistencias" /> | |
+ <TagMostra mode="filled" variant="itinerarios" text="Itinerarios" /> | |
+ <TagMostra mode="outline" variant="itinerarios" text="Itinerarios" /> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="classicos-cults" | |
+ text="Classicos-cults" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="classicos-cults" | |
+ text="Classicos-cults" | |
+ /> | |
+ <TagMostra mode="filled" variant="panorama" text="Panorama" /> | |
+ <TagMostra mode="outline" variant="panorama" text="Panorama" /> | |
+ <TagMostra | |
+ mode="filled" | |
+ variant="cinema-capacete" | |
+ text="Cinema-capacete" | |
+ /> | |
+ <TagMostra | |
+ mode="outline" | |
+ variant="cinema-capacete" | |
+ text="Cinema-capacete" | |
+ /> | |
+ <TagScreening time="19h30" state="default" /> | |
+ <TagScreening time="19h30" state="active" /> | |
+ <TagScreening time="19h30" state="disabled" /> | |
+ <TagFilter text="Legenda" /> | |
+ </div> | |
+ </TwContainer> | |
+ | |
+ <div class="grid grid-cols-3 gap-300"> | |
+ <!-- <ListCard v-for="movie in movies" :key="movie.id" :movie /> --> | |
+ </div> | |
+</template> | |
+ | |
+<style scoped></style> | |
diff --git a/tailwind.config.js b/tailwind.config.js | |
index 8bccfc3..c1c866f 100644 | |
--- a/tailwind.config.js | |
+++ b/tailwind.config.js | |
@@ -3,71 +3,75 @@ | |
import * as colorPrimitives from "./design-tokens/color-primitives.json"; | |
export default { | |
+ darkMode: ["class"], | |
content: ["./src/**/*.{vue,js,ts}"], | |
theme: { | |
extend: { | |
colors: colorPrimitives, | |
spacing: { | |
- "50": "0.125rem", // "2px" | |
- "100": "0.25rem", // "4px" | |
- "150": "0.375rem", // "6px" | |
- "200": "0.5rem", // "8px" | |
- "250": "0.625rem", // "10px" | |
- "300": "0.75rem", // "12px" | |
- "400": "1rem", // "16px" | |
- "600": "1.5rem", // "24px" | |
- "800": "2rem", // "32px" | |
- "1200": "3rem", // "48px" | |
- "1600": "4rem", // "64px" | |
- "2400": "6rem", // "96px" | |
- "4000": "9.375rem", // "150px" | |
+ 50: "0.125rem", | |
+ 100: "0.25rem", | |
+ 150: "0.375rem", | |
+ 200: "0.5rem", | |
+ 250: "0.625rem", | |
+ 300: "0.75rem", | |
+ 400: "1rem", | |
+ 600: "1.5rem", | |
+ 800: "2rem", | |
+ 1200: "3rem", | |
+ 1600: "4rem", | |
+ 2400: "6rem", | |
+ 4000: "9.375rem", | |
}, | |
borderRadius: { | |
- "100": "0.25rem", | |
- "200": "0.5rem", | |
- "400": "1rem", | |
+ 100: "0.25rem", | |
+ 200: "0.5rem", | |
+ 400: "1rem", | |
+ lg: "var(--radius)", | |
+ md: "calc(var(--radius) - 2px)", | |
+ sm: "calc(var(--radius) - 4px)", | |
}, | |
fontFamily: { | |
- heading: ['Fira Sans', 'sans-serif'], | |
- body: ['Inter', 'sans-serif'], | |
+ heading: ["Fira Sans", "sans-serif"], | |
+ body: ["Inter", "sans-serif"], | |
}, | |
fontWeight: { | |
- regular: '400', | |
+ regular: "400", | |
}, | |
fontSize: { | |
- '2xs': "0.625rem", | |
+ "2xs": "0.625rem", | |
xs: "0.75rem", | |
- sm: '0.875rem', | |
+ sm: "0.875rem", | |
md: "1rem", | |
lg: "1.25rem", | |
- xl: '1.5rem', | |
- '2xl': '2rem', | |
- '3xl': '2.5rem', | |
- '4xl': '3rem', | |
- '5xl': '3.5rem', | |
- '6xl': '4rem', | |
- "7xl": "4.5rem" | |
+ xl: "1.5rem", | |
+ "2xl": "2rem", | |
+ "3xl": "2.5rem", | |
+ "4xl": "3rem", | |
+ "5xl": "3.5rem", | |
+ "6xl": "4rem", | |
+ "7xl": "4.5rem", | |
}, | |
letterSpacing: { | |
- tightest: '-.075em', | |
- tighter: '-.05em', | |
- tight: '-.025em', | |
- normal: '0', | |
- wide: '.025em', | |
- wid: '0.03125em', | |
- wider: '.05em', | |
- widest: '1.5px' | |
+ tightest: "-.075em", | |
+ tighter: "-.05em", | |
+ tight: "-.025em", | |
+ normal: "0", | |
+ wide: ".025em", | |
+ wid: "0.03125em", | |
+ wider: ".05em", | |
+ widest: "1.5px", | |
}, | |
screens: { | |
- xs: '375px', // Mobile (iPhone-ish) | |
- sm: '640px', // Small tablets / large phones | |
- md: '768px', // Tablets portrait | |
- lg: '1024px', // Tablets landscape / small laptop | |
- xl: '1280px', // Desktop | |
- '2xl': '1440px', // Large desktop | |
- '3xl': '1920px' // Full HD monitors | |
- } | |
- } | |
+ xs: "375px", | |
+ sm: "640px", | |
+ md: "768px", | |
+ lg: "1024px", | |
+ xl: "1280px", | |
+ "2xl": "1440px", | |
+ "3xl": "1920px", | |
+ }, | |
+ }, | |
}, | |
- plugins: [], | |
-} | |
+ plugins: [require("tailwindcss-animate")], | |
+}; | |
diff --git a/tests/components/useLanguageSwitcher.spec.js b/tests/components/useLanguageSwitcher.spec.js | |
new file mode 100644 | |
index 0000000..0114fdb | |
--- /dev/null | |
+++ b/tests/components/useLanguageSwitcher.spec.js | |
@@ -0,0 +1,68 @@ | |
+// tests/components/useLanguageSwitcher.spec.js | |
+import { describe, expect, test, vi, beforeEach } from "vitest"; | |
+ | |
+// Mock the useI18n composable | |
+vi.mock("@/composables/useI18n", () => ({ | |
+ useI18n: () => ({ | |
+ locale: { value: "pt" }, | |
+ t: (key) => key, | |
+ ta: (key, fallback) => fallback || key, | |
+ }), | |
+})); | |
+ | |
+// Mock localStorage | |
+globalThis.localStorage = { | |
+ getItem: vi.fn(), | |
+ setItem: vi.fn(), | |
+ // Add storage for realistic behavior | |
+ _storage: {}, | |
+}; | |
+ | |
+// Make it work like real localStorage | |
+globalThis.localStorage.getItem.mockImplementation((key) => { | |
+ return globalThis.localStorage._storage[key] || null; | |
+}); | |
+ | |
+globalThis.localStorage.setItem.mockImplementation((key, value) => { | |
+ globalThis.localStorage._storage[key] = value; | |
+}); | |
+ | |
+describe("useLanguageSwitcher", () => { | |
+ beforeEach(() => { | |
+ vi.clearAllMocks(); | |
+ vi.resetModules(); | |
+ globalThis.localStorage._storage = {}; | |
+ }); | |
+ | |
+ test("should switch language", async () => { | |
+ const { useLanguageSwitcher } = await import( | |
+ "@/components/app/useLanguageSwitcher" | |
+ ); | |
+ const { switchLanguage, currentLanguage } = useLanguageSwitcher(); | |
+ | |
+ await switchLanguage("en"); | |
+ | |
+ expect(currentLanguage.value).toBe("en"); | |
+ }); | |
+ | |
+ test("Should initialize with correct default language - PT", async () => { | |
+ const { useLanguageSwitcher } = await import( | |
+ "@/components/app/useLanguageSwitcher" | |
+ ); | |
+ const { currentLanguage } = useLanguageSwitcher(); | |
+ expect(currentLanguage.value).toBe("pt"); | |
+ }); | |
+ | |
+ test("Should initialize with language from localStorage if available", async () => { | |
+ localStorage.setItem("preferredLanguage", "en"); | |
+ | |
+ const { useLanguageSwitcher } = await import( | |
+ "@/components/app/useLanguageSwitcher" | |
+ ); | |
+ const { currentLanguage } = useLanguageSwitcher(); | |
+ expect(currentLanguage.value).toBe("en"); | |
+ }); | |
+}); | |
+ | |
+// Should update localStorage when switching language | |
+// Should update i18n locale when switching language | |
diff --git a/tests/services/api/errorHandler.spec.js b/tests/services/api/errorHandler.spec.js | |
new file mode 100644 | |
index 0000000..d33d283 | |
--- /dev/null | |
+++ b/tests/services/api/errorHandler.spec.js | |
@@ -0,0 +1,52 @@ | |
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; | |
+import { handleApiError } from "@/services/api/client/errorHandler"; | |
+ | |
+// Mock console methods to avoid noise in tests and verify logging | |
+const mockConsoleError = vi | |
+ .spyOn(console, "error") | |
+ .mockImplementation(() => {}); | |
+const mockConsoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
+ | |
+describe("errorHandler", () => { | |
+ beforeEach(() => { | |
+ vi.clearAllMocks(); | |
+ // Reset environment | |
+ vi.stubEnv("NODE_ENV", "test"); | |
+ }); | |
+ | |
+ afterEach(() => { | |
+ vi.unstubAllEnvs(); | |
+ mockConsoleError.mockClear(); | |
+ mockConsoleWarn.mockClear(); | |
+ }); | |
+ | |
+ test("should_transform_400_error_to_user_friendly_message", async () => { | |
+ const mockAxiosError = { | |
+ response: { | |
+ status: 400, | |
+ data: { message: "Validation failed" }, | |
+ }, | |
+ config: { | |
+ url: "/api/movies", | |
+ method: "GET", | |
+ }, | |
+ }; | |
+ | |
+ await expect(handleApiError(mockAxiosError)).rejects.toThrow( | |
+ "Bad Request - Invalid data sent", | |
+ ); | |
+ | |
+ try { | |
+ await handleApiError(mockAxiosError); | |
+ } catch (error) { | |
+ expect(error.status).toBe(400); | |
+ expect(error.isSafe).toBe(true); | |
+ expect(error.message).toBe("Bad Request - Invalid data sent"); | |
+ } | |
+ | |
+ expect(mockConsoleWarn).toHaveBeenCalledWith( | |
+ "User Error:", | |
+ "Bad Request - Invalid data sent", | |
+ ); | |
+ }); | |
+}); | |
diff --git a/tests/services/api/fetchMovies.spec.js b/tests/services/api/fetchMovies.spec.js | |
new file mode 100644 | |
index 0000000..c912a2b | |
--- /dev/null | |
+++ b/tests/services/api/fetchMovies.spec.js | |
@@ -0,0 +1,138 @@ | |
+import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; | |
+import { fetchMovies } from "@/services/api/endpoints/movies"; | |
+import apiClient from "@/services/api/client/apiClient"; | |
+ | |
+vi.mock("fast-xml-parser", () => { | |
+ const mockParseFn = vi.fn(); | |
+ return { | |
+ XMLParser: vi.fn().mockImplementation(() => ({ | |
+ parse: mockParseFn, | |
+ })), | |
+ __mockParseFn: mockParseFn, | |
+ }; | |
+}); | |
+ | |
+vi.mock("@/services/api/client/apiClient", () => ({ | |
+ default: { | |
+ get: vi.fn(), | |
+ }, | |
+})); | |
+ | |
+globalThis.fetch = vi.fn(); | |
+ | |
+describe("fetchMovies", () => { | |
+ const mockXmlData = `<?xml version="1.0" encoding="UTF-8"?> | |
+ <programacao> | |
+ <filme> | |
+ <titulo>Test Movie</titulo> | |
+ <horario>20:00</horario> | |
+ </filme> | |
+ </programacao>`; | |
+ | |
+ const mockParsedData = { | |
+ programacao: { | |
+ filme: { | |
+ titulo: "Test Movie", | |
+ horario: "20:00", | |
+ }, | |
+ }, | |
+ }; | |
+ | |
+ let mockParseFn; | |
+ beforeEach(async () => { | |
+ vi.clearAllMocks(); | |
+ vi.stubEnv("DEV", false); | |
+ | |
+ const { __mockParseFn } = await import("fast-xml-parser"); | |
+ mockParseFn = __mockParseFn; | |
+ }); | |
+ | |
+ afterEach(() => { | |
+ vi.unstubAllEnvs(); | |
+ }); | |
+ | |
+ test("fetchMovies_should_parse_xml_response_successfully", async () => { | |
+ apiClient.get.mockResolvedValue({ | |
+ data: mockXmlData, | |
+ }); | |
+ | |
+ mockParseFn.mockReturnValue(mockParsedData); | |
+ | |
+ const result = await fetchMovies(); | |
+ | |
+ expect(apiClient.get).toHaveBeenCalledWith("/schedules/xml/programacao", { | |
+ responseType: "text", | |
+ }); | |
+ | |
+ expect(mockParseFn).toHaveBeenCalledWith(mockXmlData); | |
+ expect(result).toEqual(mockParsedData); | |
+ }); | |
+ | |
+ test("fetchMovies_should_use_local_file_when_dev_mode_enabled", async () => { | |
+ vi.stubEnv("DEV", true); | |
+ | |
+ globalThis.fetch.mockResolvedValue({ | |
+ text: () => Promise.resolve(mockXmlData), | |
+ }); | |
+ | |
+ mockParseFn.mockReturnValue(mockParsedData); | |
+ | |
+ globalThis.fetch.mockResolvedValue({ | |
+ text: () => Promise.resolve(mockXmlData), | |
+ }); | |
+ | |
+ const result = await fetchMovies(); | |
+ | |
+ expect(result).toEqual(mockParsedData); | |
+ }); | |
+ | |
+ test("fetchMovies_should_handle_api_request_failures", async () => { | |
+ const apiError = new Error("Failed to fetch movie data"); | |
+ apiClient.get.mockRejectedValue(apiError); | |
+ | |
+ await expect(fetchMovies()).rejects.toThrow("Failed to fetch movie data"); | |
+ expect(apiClient.get).toHaveBeenCalledWith("/schedules/xml/programacao", { | |
+ responseType: "text", | |
+ }); | |
+ }); | |
+ | |
+ test("fetchMovies_should_handle_xml_parsing_errors", async () => { | |
+ apiClient.get.mockResolvedValue({ | |
+ data: "invalid-xml-data", | |
+ }); | |
+ | |
+ const parseError = new Error("Invalid XML data received"); | |
+ mockParseFn.mockImplementation(() => { | |
+ throw parseError; | |
+ }); | |
+ | |
+ await expect(fetchMovies()).rejects.toThrow("Invalid XML data received"); | |
+ }); | |
+ | |
+ test("fetchMovies_should_handle_empty_xml_response", async () => { | |
+ apiClient.get.mockResolvedValue({ | |
+ data: "", | |
+ }); | |
+ | |
+ mockParseFn.mockReturnValue({}); | |
+ | |
+ const result = await fetchMovies(); | |
+ | |
+ expect(mockParseFn).toHaveBeenCalledWith(""); | |
+ expect(result).toEqual({}); | |
+ }); | |
+ | |
+ test("fetchMovies_should_use_correct_response_type_for_api_calls", async () => { | |
+ apiClient.get.mockResolvedValue({ | |
+ data: mockXmlData, | |
+ }); | |
+ | |
+ mockParseFn.mockReturnValue(mockParsedData); | |
+ | |
+ await fetchMovies(); | |
+ | |
+ expect(apiClient.get).toHaveBeenCalledWith("/schedules/xml/programacao", { | |
+ responseType: "text", | |
+ }); | |
+ }); | |
+}); | |
diff --git a/tests/services/api_client.spec.js b/tests/services/api_client.spec.js | |
index 88f6a66..7bf8800 100644 | |
--- a/tests/services/api_client.spec.js | |
+++ b/tests/services/api_client.spec.js | |
@@ -1,10 +1,10 @@ | |
import { afterEach, beforeEach, describe, expect, test } from "vitest"; | |
import { vi } from "vitest"; | |
-import axios from 'axios'; | |
-import apiClient from "../../src/services/api/api_client"; | |
+import axios from "axios"; | |
+import apiClient from "@/services/api/client/apiClient"; | |
// Mock axios completely | |
-vi.mock('axios', () => { | |
+vi.mock("axios", () => { | |
return { | |
default: { | |
create: () => axios, | |
@@ -25,22 +25,22 @@ vi.mock('axios', () => { | |
describe("apiClient", () => { | |
beforeEach(() => { | |
axios.get.mockReset(); | |
- }) | |
+ }); | |
- afterEach(() => {}) | |
+ afterEach(() => {}); | |
test("should set base url", () => { | |
- expect(apiClient.defaults.baseURL).toBe(import.meta.env.VITE_API_BASE_URL) | |
- }) | |
+ expect(apiClient.defaults.baseURL).toBe(import.meta.env.VITE_API_BASE_URL); | |
+ }); | |
- test('should attach Authorization header if token exists', async () => { | |
- vi.stubEnv('VITE_TMDB_TOKEN', 'test-token'); | |
+ // test("should attach Authorization header if token exists", async () => { | |
+ // vi.stubEnv("VITE_TMDB_TOKEN", "test-token"); | |
- const interceptorFn = axios.interceptors.request.use.mock.calls[0][0]; | |
+ // const interceptorFn = axios.interceptors.request.use.mock.calls[0][0]; | |
- const config = { headers: {} }; | |
- const updatedConfig = interceptorFn(config); | |
+ // const config = { headers: {} }; | |
+ // const updatedConfig = interceptorFn(config); | |
- expect(updatedConfig.headers.Authorization).toBe('Bearer test-token'); | |
- }); | |
-}) | |
+ // expect(updatedConfig.headers.Authorization).toBe("Bearer test-token"); | |
+ // }); | |
+}); | |
diff --git a/tests/services/xmlParser.spec.js b/tests/services/xmlParser.spec.js | |
new file mode 100644 | |
index 0000000..5378558 | |
--- /dev/null | |
+++ b/tests/services/xmlParser.spec.js | |
@@ -0,0 +1,39 @@ | |
+import { describe, test, expect } from "vitest"; | |
+import { | |
+ validateXMLStructure, | |
+ validateXMLSize, | |
+} from "@/services/validation/xmlValidation"; | |
+ | |
+describe("XML Validation", () => { | |
+ // Test XXE prevention | |
+ test("should reject XML with external entities", () => { | |
+ const maliciousXML = `<?xml version="1.0"?> | |
+ <!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///etc/passwd">]> | |
+ <root>&xxe;</root>`; | |
+ | |
+ expect(() => | |
+ validateXMLStructure(maliciousXML).toThrow( | |
+ "Potentially malicious XML detected", | |
+ ), | |
+ ); | |
+ }); | |
+ | |
+ // Test XML bomb prevention | |
+ test("should reject XML bombs", () => { | |
+ const xmlBomb = `<?xml version="1.0"?> | |
+ <!DOCTYPE lolz [<!ENTITY lol "&lol;&lol;&lol;&lol;&lol;">]> | |
+ <root>&lol;</root>`; | |
+ | |
+ expect(() => | |
+ validateXMLStructure(xmlBomb).toThrow( | |
+ "Potentially malicious XML detected", | |
+ ), | |
+ ); | |
+ }); | |
+ | |
+ // Test size limits | |
+ test("should reject oversized XML", () => { | |
+ const largeXML = "<root>" + "x".repeat(2 * 1024 * 1024) + "</root>"; | |
+ expect(() => validateXMLSize(largeXML).toThrow("XML too large")); | |
+ }); | |
+}); | |
diff --git a/vite.config.js b/vite.config.js | |
index fd99a6f..667e2da 100644 | |
--- a/vite.config.js | |
+++ b/vite.config.js | |
@@ -1,16 +1,16 @@ | |
import { defineConfig } from "vite"; | |
import vue from "@vitejs/plugin-vue"; | |
-import path from 'path' | |
+import path from "node:path"; | |
export default defineConfig({ | |
plugins: [vue()], | |
resolve: { | |
alias: { | |
- '@assets': '/src/assets', | |
- '@': path.resolve(path.__dirname, '/src'), | |
- } | |
+ "@": path.resolve(__dirname, "src"), | |
+ "@assets": path.resolve(__dirname, "src/assets"), | |
+ }, | |
}, | |
server: { | |
- allowedHosts: ['f640407c50fc.ngrok-free.app'] | |
+ allowedHosts: ["f640407c50fc.ngrok-free.app"], | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment