Initial commit: Skwik image deskew tool
Vue 3 + Vite + TypeScript (strict) app with shadcn-vue, Konva.js canvas, and Pinia. 4-step wizard: upload JPG/HEIC, view EXIF, place datum measurements (rectangles/lines with presets), run deskew (placeholder). Dark mode, mobile-responsive with bottom sheet for datum panel. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
2d56c5dada
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
155
.serena/project.yml
Normal file
155
.serena/project.yml
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
project_name: "skwik"
|
||||||
|
|
||||||
|
|
||||||
|
# list of languages for which language servers are started; choose from:
|
||||||
|
# al bash clojure cpp csharp
|
||||||
|
# csharp_omnisharp dart elixir elm erlang
|
||||||
|
# fortran fsharp go groovy haskell
|
||||||
|
# haxe java julia kotlin lua
|
||||||
|
# markdown
|
||||||
|
# matlab nix pascal perl php
|
||||||
|
# php_phpactor powershell python python_jedi r
|
||||||
|
# rego ruby ruby_solargraph rust scala
|
||||||
|
# swift terraform toml typescript typescript_vts
|
||||||
|
# vue yaml zig
|
||||||
|
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||||
|
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||||
|
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||||
|
# Note:
|
||||||
|
# - For C, use cpp
|
||||||
|
# - For JavaScript, use typescript
|
||||||
|
# - For Free Pascal/Lazarus, use pascal
|
||||||
|
# Special requirements:
|
||||||
|
# Some languages require additional setup/installations.
|
||||||
|
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||||
|
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||||
|
# The first language is the default language and the respective language server will be used as a fallback.
|
||||||
|
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||||
|
languages:
|
||||||
|
- typescript
|
||||||
|
- vue
|
||||||
|
|
||||||
|
# the encoding used by text files in the project
|
||||||
|
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||||
|
encoding: "utf-8"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
|
||||||
|
# advanced configuration option allowing to configure language server-specific options.
|
||||||
|
# Maps the language key to the options.
|
||||||
|
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||||
|
# No documentation on options means no options are available.
|
||||||
|
ls_specific_settings: {}
|
||||||
|
|
||||||
|
# list of additional paths to ignore in this project.
|
||||||
|
# Same syntax as gitignore, so you can use * and **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
#
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project based on the project name or path.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
|
||||||
|
# for example by saying that the information retrieved from a memory file is no longer correct
|
||||||
|
# or no longer relevant for the project.
|
||||||
|
# * `edit_memory`: Replaces content matching a regular expression in a memory.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_file`: Finds files in the given relative paths
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
|
||||||
|
# * `find_symbol`: Performs a global (or local) search using the language server backend.
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
|
||||||
|
# for clients that do not read the initial instructions when the MCP server is connected.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
|
||||||
|
# is relevant to the current task. You can infer whether the information
|
||||||
|
# is relevant from the memory file name.
|
||||||
|
# You should not read the same memory file multiple times in the same conversation.
|
||||||
|
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
|
||||||
|
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
|
||||||
|
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
|
||||||
|
# For JB, we use a separate tool.
|
||||||
|
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
|
||||||
|
# * `safe_delete_symbol`:
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
|
||||||
|
# The memory name should be meaningful.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names to that are always to be included in the set of active modes
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this setting overrides the global configuration.
|
||||||
|
# Set this to [] to disable base modes for this project.
|
||||||
|
# Set this to a list of mode names to always include the respective modes for this project.
|
||||||
|
base_modes:
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default.
|
||||||
|
# The full set of modes to be activated is base_modes + default_modes.
|
||||||
|
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
25
components.json
Normal file
25
components.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "reka-nova",
|
||||||
|
"font": "geist-sans",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/assets/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"composables": "@/composables"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
11
env.d.ts
vendored
Normal file
11
env.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
62
eslint.config.ts
Normal file
62
eslint.config.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import pluginVue from "eslint-plugin-vue";
|
||||||
|
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import vueParser from "vue-eslint-parser";
|
||||||
|
import prettierConfig from "eslint-config-prettier";
|
||||||
|
import type { Linter } from "eslint";
|
||||||
|
|
||||||
|
const config: Linter.Config[] = [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "node_modules/**"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tsPlugin as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs?.["recommended"]?.rules,
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ argsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.vue"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: vueParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
extraFileExtensions: [".vue"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
vue: pluginVue as unknown as Record<string, unknown>,
|
||||||
|
"@typescript-eslint": tsPlugin as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...pluginVue.configs?.["flat/recommended"]?.reduce(
|
||||||
|
(acc: Record<string, unknown>, cfg: Linter.Config) => ({
|
||||||
|
...acc,
|
||||||
|
...cfg.rules,
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettierConfig as Linter.Config,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default config;
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Skwik - Image Deskew</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
package.json
Normal file
48
package.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "skwik",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint . --ext .ts,.vue",
|
||||||
|
"lint:fix": "eslint . --ext .ts,.vue --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,vue,css}\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
||||||
|
"@typescript-eslint/parser": "^8.58.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-vue": "^10.8.0",
|
||||||
|
"prettier": "^3.8.2",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@vueuse/core": "^14.2.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
|
"konva": "^10.2.5",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
|
"nanoid": "^5.1.7",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"reka-ui": "^2.9.6",
|
||||||
|
"shadcn-vue": "^2.6.2",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-konva": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4717
pnpm-lock.yaml
generated
Normal file
4717
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
34
src/App.vue
Normal file
34
src/App.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import StepIndicator from "@/components/StepIndicator.vue";
|
||||||
|
import ImageUpload from "@/components/ImageUpload.vue";
|
||||||
|
import ExifViewer from "@/components/ExifViewer.vue";
|
||||||
|
import DatumEditor from "@/components/DatumEditor.vue";
|
||||||
|
import ResultViewer from "@/components/ResultViewer.vue";
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle.vue";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-background text-foreground">
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||||
|
<h1 class="text-lg font-semibold tracking-tight">Skwik</h1>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<StepIndicator />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||||
|
<ImageUpload v-if="store.currentStep === 1" />
|
||||||
|
<ExifViewer v-else-if="store.currentStep === 2" />
|
||||||
|
<DatumEditor v-else-if="store.currentStep === 3" />
|
||||||
|
<ResultViewer v-else-if="store.currentStep === 4" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
129
src/assets/index.css
Normal file
129
src/assets/index.css
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@import "shadcn-vue/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: 'Geist Variable', sans-serif;
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
321
src/components/DatumCanvas.vue
Normal file
321
src/components/DatumCanvas.vue
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { getDatumColor } from "@/lib/datums";
|
||||||
|
import type { Datum, Point } from "@/types";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const stageWidth = ref(800);
|
||||||
|
const stageHeight = ref(600);
|
||||||
|
|
||||||
|
const scale = ref(1);
|
||||||
|
const offsetX = ref(0);
|
||||||
|
const offsetY = ref(0);
|
||||||
|
|
||||||
|
// Touch state for pinch-to-zoom
|
||||||
|
let lastPinchDist = 0;
|
||||||
|
let isPanning = false;
|
||||||
|
let panStart = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const imageConfig = computed(() => {
|
||||||
|
const img = store.loadedImage;
|
||||||
|
if (!img) return null;
|
||||||
|
return {
|
||||||
|
image: img,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: img.naturalWidth,
|
||||||
|
height: img.naturalHeight,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const stageConfig = computed(() => ({
|
||||||
|
width: stageWidth.value,
|
||||||
|
height: stageHeight.value,
|
||||||
|
scaleX: scale.value,
|
||||||
|
scaleY: scale.value,
|
||||||
|
x: offsetX.value,
|
||||||
|
y: offsetY.value,
|
||||||
|
draggable: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function datumIndex(datum: Datum): number {
|
||||||
|
return store.datums.findIndex((d) => d.id === datum.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPointConfigs(datum: Datum, dIdx: number) {
|
||||||
|
const color = getDatumColor(dIdx);
|
||||||
|
const isSelected = store.selectedDatumId === datum.id;
|
||||||
|
const points = datum.type === "rectangle" ? datum.corners : datum.endpoints;
|
||||||
|
const radius = isSelected ? 10 : 7;
|
||||||
|
|
||||||
|
return points.map((pt, pIdx) => ({
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
radius: radius / scale.value,
|
||||||
|
fill: color,
|
||||||
|
stroke: isSelected ? "#fff" : color,
|
||||||
|
strokeWidth: 2 / scale.value,
|
||||||
|
draggable: true,
|
||||||
|
_datumId: datum.id,
|
||||||
|
_pointIndex: pIdx,
|
||||||
|
hitStrokeWidth: 20 / scale.value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineConfigs(datum: Datum, dIdx: number) {
|
||||||
|
const color = getDatumColor(dIdx);
|
||||||
|
const isSelected = store.selectedDatumId === datum.id;
|
||||||
|
|
||||||
|
if (datum.type === "line") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
points: [
|
||||||
|
datum.endpoints[0].x,
|
||||||
|
datum.endpoints[0].y,
|
||||||
|
datum.endpoints[1].x,
|
||||||
|
datum.endpoints[1].y,
|
||||||
|
],
|
||||||
|
stroke: color,
|
||||||
|
strokeWidth: (isSelected ? 3 : 2) / scale.value,
|
||||||
|
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rectangle: draw 4 edges
|
||||||
|
const c = datum.corners;
|
||||||
|
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y]);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
points: pts,
|
||||||
|
stroke: color,
|
||||||
|
strokeWidth: (isSelected ? 3 : 2) / scale.value,
|
||||||
|
closed: true,
|
||||||
|
dash: isSelected ? [] : [8 / scale.value, 4 / scale.value],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabelConfig(datum: Datum, dIdx: number) {
|
||||||
|
const color = getDatumColor(dIdx);
|
||||||
|
let pos: Point;
|
||||||
|
|
||||||
|
if (datum.type === "rectangle") {
|
||||||
|
pos = {
|
||||||
|
x: (datum.corners[0].x + datum.corners[2].x) / 2,
|
||||||
|
y: (datum.corners[0].y + datum.corners[2].y) / 2,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pos = {
|
||||||
|
x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2,
|
||||||
|
y: (datum.endpoints[0].y + datum.endpoints[1].y) / 2 - 20 / scale.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
text: datum.label,
|
||||||
|
fontSize: 14 / scale.value,
|
||||||
|
fill: color,
|
||||||
|
fontStyle: "bold",
|
||||||
|
align: "center" as const,
|
||||||
|
offsetX: (datum.label.length * 7) / 2 / scale.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointDragMove(e: { target: { x: () => number; y: () => number; attrs: { _datumId: string; _pointIndex: number } } }) {
|
||||||
|
const { _datumId, _pointIndex } = e.target.attrs;
|
||||||
|
const datum = store.datums.find((d) => d.id === _datumId);
|
||||||
|
if (!datum) return;
|
||||||
|
|
||||||
|
const newPos: Point = { x: e.target.x(), y: e.target.y() };
|
||||||
|
|
||||||
|
if (datum.type === "rectangle") {
|
||||||
|
const newCorners = [...datum.corners] as [Point, Point, Point, Point];
|
||||||
|
newCorners[_pointIndex] = newPos;
|
||||||
|
store.updateDatum(_datumId, { corners: newCorners });
|
||||||
|
} else {
|
||||||
|
const newEndpoints = [...datum.endpoints] as [Point, Point];
|
||||||
|
newEndpoints[_pointIndex] = newPos;
|
||||||
|
store.updateDatum(_datumId, { endpoints: newEndpoints });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointClick(datumId: string) {
|
||||||
|
store.selectedDatumId = datumId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom with mouse wheel
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const scaleBy = 1.08;
|
||||||
|
const oldScale = scale.value;
|
||||||
|
const newScale =
|
||||||
|
e.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
|
||||||
|
|
||||||
|
const clampedScale = Math.max(0.05, Math.min(10, newScale));
|
||||||
|
|
||||||
|
const rect = containerRef.value?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const pointerX = e.clientX - rect.left;
|
||||||
|
const pointerY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
const mousePointTo = {
|
||||||
|
x: (pointerX - offsetX.value) / oldScale,
|
||||||
|
y: (pointerY - offsetY.value) / oldScale,
|
||||||
|
};
|
||||||
|
|
||||||
|
scale.value = clampedScale;
|
||||||
|
offsetX.value = pointerX - mousePointTo.x * clampedScale;
|
||||||
|
offsetY.value = pointerY - mousePointTo.y * clampedScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch handlers for pinch-to-zoom and pan
|
||||||
|
function getTouchDistance(t1: Touch, t2: Touch): number {
|
||||||
|
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: (t1.clientX + t2.clientX) / 2,
|
||||||
|
y: (t1.clientY + t2.clientY) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
lastPinchDist = getTouchDistance(e.touches[0]!, e.touches[1]!);
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
// Single-finger pan (only if not on a point)
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest(".konvajs-content")) return;
|
||||||
|
isPanning = true;
|
||||||
|
panStart = { x: e.touches[0]!.clientX - offsetX.value, y: e.touches[0]!.clientY - offsetY.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dist = getTouchDistance(e.touches[0]!, e.touches[1]!);
|
||||||
|
const center = getTouchCenter(e.touches[0]!, e.touches[1]!);
|
||||||
|
|
||||||
|
const rect = containerRef.value?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const scaleFactor = dist / lastPinchDist;
|
||||||
|
const oldScale = scale.value;
|
||||||
|
const newScale = Math.max(0.05, Math.min(10, oldScale * scaleFactor));
|
||||||
|
|
||||||
|
const cx = center.x - rect.left;
|
||||||
|
const cy = center.y - rect.top;
|
||||||
|
|
||||||
|
const mousePointTo = {
|
||||||
|
x: (cx - offsetX.value) / oldScale,
|
||||||
|
y: (cy - offsetY.value) / oldScale,
|
||||||
|
};
|
||||||
|
|
||||||
|
scale.value = newScale;
|
||||||
|
offsetX.value = cx - mousePointTo.x * newScale;
|
||||||
|
offsetY.value = cy - mousePointTo.y * newScale;
|
||||||
|
|
||||||
|
lastPinchDist = dist;
|
||||||
|
} else if (e.touches.length === 1 && isPanning) {
|
||||||
|
offsetX.value = e.touches[0]!.clientX - panStart.x;
|
||||||
|
offsetY.value = e.touches[0]!.clientY - panStart.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
lastPinchDist = 0;
|
||||||
|
isPanning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit image to canvas on mount
|
||||||
|
function fitToCanvas() {
|
||||||
|
const img = store.loadedImage;
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!img || !container) return;
|
||||||
|
|
||||||
|
const cw = container.clientWidth;
|
||||||
|
const ch = container.clientHeight;
|
||||||
|
stageWidth.value = cw;
|
||||||
|
stageHeight.value = ch;
|
||||||
|
|
||||||
|
const fitScale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight) * 0.9;
|
||||||
|
scale.value = fitScale;
|
||||||
|
offsetX.value = (cw - img.naturalWidth * fitScale) / 2;
|
||||||
|
offsetY.value = (ch - img.naturalHeight * fitScale) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fitToCanvas();
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (!containerRef.value) return;
|
||||||
|
const cw = containerRef.value.clientWidth;
|
||||||
|
const ch = containerRef.value.clientHeight;
|
||||||
|
// Skip if the container is hidden (0-sized)
|
||||||
|
if (cw === 0 || ch === 0) return;
|
||||||
|
// Re-fit whenever the container size actually changes
|
||||||
|
fitToCanvas();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => store.loadedImage, fitToCanvas);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="h-full w-full touch-none overflow-hidden rounded-lg border border-border bg-muted"
|
||||||
|
@wheel.prevent="onWheel"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<v-stage :config="stageConfig">
|
||||||
|
<v-layer>
|
||||||
|
<!-- Background image -->
|
||||||
|
<v-image v-if="imageConfig" :config="imageConfig" />
|
||||||
|
|
||||||
|
<!-- Datum shapes -->
|
||||||
|
<template v-for="datum in store.datums" :key="datum.id">
|
||||||
|
<!-- Lines/edges -->
|
||||||
|
<v-line
|
||||||
|
v-for="(lineCfg, li) in getLineConfigs(datum, datumIndex(datum))"
|
||||||
|
:key="`${datum.id}-line-${li}`"
|
||||||
|
:config="lineCfg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center label -->
|
||||||
|
<v-text :config="getLabelConfig(datum, datumIndex(datum))" />
|
||||||
|
|
||||||
|
<!-- Draggable points -->
|
||||||
|
<v-circle
|
||||||
|
v-for="ptCfg in getPointConfigs(datum, datumIndex(datum))"
|
||||||
|
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
|
||||||
|
:config="ptCfg"
|
||||||
|
@dragmove="onPointDragMove"
|
||||||
|
@click="onPointClick(datum.id)"
|
||||||
|
@tap="onPointClick(datum.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-layer>
|
||||||
|
</v-stage>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
87
src/components/DatumEditor.vue
Normal file
87
src/components/DatumEditor.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useMediaQuery } from "@vueuse/core";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import DatumCanvas from "@/components/DatumCanvas.vue";
|
||||||
|
import DatumPanel from "@/components/DatumPanel.vue";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
const sheetOpen = ref(false);
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
|
|
||||||
|
const canvasHeight = computed(() =>
|
||||||
|
isMobile.value ? "h-[calc(100vh-14rem)]" : "h-[calc(100vh-12rem)]",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-xl font-semibold">Place Datums</h2>
|
||||||
|
<p class="hidden text-sm text-muted-foreground sm:block">
|
||||||
|
Add reference shapes on the image and enter their real-world dimensions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<Button variant="outline" size="sm" @click="store.goToStep(2)">Back</Button>
|
||||||
|
<Button size="sm" :disabled="!store.canProceedToStep4" @click="store.goToStep(4)">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single layout: canvas always present, sidebar conditionally placed -->
|
||||||
|
<div
|
||||||
|
class="grid gap-4"
|
||||||
|
:class="isMobile ? 'grid-cols-1' : 'grid-cols-[1fr_360px]'"
|
||||||
|
:style="{ height: isMobile ? undefined : 'calc(100vh - 12rem)' }"
|
||||||
|
>
|
||||||
|
<div :class="canvasHeight">
|
||||||
|
<DatumCanvas />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: inline sidebar -->
|
||||||
|
<DatumPanel v-if="!isMobile" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: bottom sheet for datums -->
|
||||||
|
<Sheet v-if="isMobile" v-model:open="sheetOpen">
|
||||||
|
<SheetTrigger as-child>
|
||||||
|
<Button variant="outline" class="w-full">
|
||||||
|
Datums ({{ store.datums.length }})
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="bottom" class="h-[75vh] overflow-hidden rounded-t-xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Datums</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div class="h-[calc(75vh-4rem)] overflow-y-auto">
|
||||||
|
<DatumPanel />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
227
src/components/DatumPanel.vue
Normal file
227
src/components/DatumPanel.vue
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { RECT_PRESETS, createRectDatum, createLineDatum, getDatumColor } from "@/lib/datums";
|
||||||
|
import type { ConfidenceScore, Datum, RectDatum } from "@/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
|
||||||
|
function imageCenter() {
|
||||||
|
const img = store.loadedImage;
|
||||||
|
if (!img) return { x: 400, y: 300 };
|
||||||
|
return { x: img.naturalWidth / 2, y: img.naturalHeight / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRect(presetLabel?: string) {
|
||||||
|
const preset = presetLabel
|
||||||
|
? RECT_PRESETS.find((p) => p.label === presetLabel)
|
||||||
|
: undefined;
|
||||||
|
store.addDatum(createRectDatum(imageCenter(), preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
store.addDatum(createLineDatum(imageCenter()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(datum: Datum, field: string, value: string | number) {
|
||||||
|
store.updateDatum(datum.id, { [field]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfidence(datum: Datum, val: number[] | undefined) {
|
||||||
|
if (!val) return;
|
||||||
|
const v = val[0];
|
||||||
|
if (v !== undefined && v >= 1 && v <= 5) {
|
||||||
|
store.updateDatum(datum.id, { confidence: v as ConfidenceScore });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDimensions(datum: Datum): string {
|
||||||
|
if (datum.type === "rectangle") {
|
||||||
|
return `${datum.widthMm} \u00D7 ${datum.heightMm} mm`;
|
||||||
|
}
|
||||||
|
return `${datum.lengthMm} mm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPresetSelect(value: unknown) {
|
||||||
|
const v = String(value);
|
||||||
|
addRect(v === "custom" ? undefined : v);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col gap-4 overflow-y-auto p-4">
|
||||||
|
<!-- Add datum controls -->
|
||||||
|
<Card class="shrink-0">
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<CardTitle class="text-sm">Add Datum</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3 overflow-visible">
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 text-xs text-muted-foreground">Rectangle (preset)</Label>
|
||||||
|
<Select @update:model-value="onPresetSelect">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose a preset..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="preset in RECT_PRESETS" :key="preset.label" :value="preset.label">
|
||||||
|
{{ preset.label }} ({{ preset.widthMm }}×{{ preset.heightMm }} mm)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom rectangle</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" class="w-full" size="sm" @click="addLine">
|
||||||
|
+ Add Line
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Datum list -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Datums ({{ store.datums.length }})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="store.datums.length === 0" class="text-sm text-muted-foreground">
|
||||||
|
No datums added yet. Use the controls above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-for="(datum, idx) in store.datums"
|
||||||
|
:key="datum.id"
|
||||||
|
class="cursor-pointer transition-colors"
|
||||||
|
:class="
|
||||||
|
store.selectedDatumId === datum.id
|
||||||
|
? 'ring-2 ring-primary'
|
||||||
|
: 'hover:bg-accent/50'
|
||||||
|
"
|
||||||
|
@click="store.selectedDatumId = datum.id"
|
||||||
|
>
|
||||||
|
<CardContent class="space-y-3 pt-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="h-3 w-3 rounded-full"
|
||||||
|
:style="{ backgroundColor: getDatumColor(idx) }"
|
||||||
|
/>
|
||||||
|
<Badge variant="outline" class="text-xs">
|
||||||
|
{{ datum.type === "rectangle" ? "Rect" : "Line" }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground">{{ formatDimensions(datum) }}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6 text-destructive"
|
||||||
|
@click.stop="store.removeDatum(datum.id)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div>
|
||||||
|
<Label class="text-xs">Label</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="datum.label"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="(v: string | number) => updateField(datum, 'label', String(v))"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dimensions -->
|
||||||
|
<div v-if="datum.type === 'rectangle'" class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label class="text-xs">Width (mm)</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="String((datum as RectDatum).widthMm)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="(v: string | number) => updateField(datum, 'widthMm', Number(v))"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="text-xs">Height (mm)</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="String((datum as RectDatum).heightMm)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="(v: string | number) => updateField(datum, 'heightMm', Number(v))"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<Label class="text-xs">Length (mm)</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="String(datum.lengthMm)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="mt-1 h-8 text-sm"
|
||||||
|
@update:model-value="(v: string | number) => updateField(datum, 'lengthMm', Number(v))"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confidence -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label class="text-xs">Confidence</Label>
|
||||||
|
<span class="text-xs font-medium text-muted-foreground">
|
||||||
|
{{ datum.confidence }} / 5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
:model-value="[datum.confidence]"
|
||||||
|
:min="1"
|
||||||
|
:max="5"
|
||||||
|
:step="1"
|
||||||
|
class="mt-2"
|
||||||
|
@update:model-value="(v: number[] | undefined) => updateConfidence(datum, v)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
184
src/components/ExifViewer.vue
Normal file
184
src/components/ExifViewer.vue
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { orientationLabel } from "@/lib/exif";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
|
||||||
|
interface ExifRow {
|
||||||
|
label: string;
|
||||||
|
value: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExifRows(): ExifRow[] {
|
||||||
|
const e = store.exifData;
|
||||||
|
return [
|
||||||
|
{ label: "Camera Make", value: e.make },
|
||||||
|
{ label: "Camera Model", value: e.model },
|
||||||
|
{ label: "Lens", value: e.lensModel },
|
||||||
|
{
|
||||||
|
label: "Focal Length",
|
||||||
|
value: e.focalLength ? `${e.focalLength}mm` : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Focal Length (35mm eq.)",
|
||||||
|
value: e.focalLengthIn35mm ? `${e.focalLengthIn35mm}mm` : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Orientation",
|
||||||
|
value: e.orientation ? orientationLabel(e.orientation) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Image Size",
|
||||||
|
value:
|
||||||
|
e.imageWidth && e.imageHeight
|
||||||
|
? `${e.imageWidth} \u00D7 ${e.imageHeight}`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Aperture",
|
||||||
|
value: e.fNumber ? `f/${e.fNumber}` : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "ISO",
|
||||||
|
value: e.iso ? String(e.iso) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Exposure",
|
||||||
|
value: e.exposureTime
|
||||||
|
? e.exposureTime < 1
|
||||||
|
? `1/${Math.round(1 / e.exposureTime)}s`
|
||||||
|
: `${e.exposureTime}s`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{ label: "Date Taken", value: e.dateTimeOriginal },
|
||||||
|
{
|
||||||
|
label: "GPS",
|
||||||
|
value:
|
||||||
|
e.gpsLatitude != null && e.gpsLongitude != null
|
||||||
|
? `${e.gpsLatitude.toFixed(5)}, ${e.gpsLongitude.toFixed(5)}`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
].filter((r) => r.value != null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-3xl space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">Image & EXIF Data</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Review camera and lens information extracted from the image.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" @click="store.goToStep(1)">Back</Button>
|
||||||
|
<Button @click="store.goToStep(3)">Next: Add Datums</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
|
<!-- Image preview -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Preview</CardTitle>
|
||||||
|
<CardDescription>{{ store.originalFile?.name }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex items-center justify-center overflow-hidden rounded-md bg-muted">
|
||||||
|
<img
|
||||||
|
v-if="store.loadedImage"
|
||||||
|
:src="store.loadedImage.src"
|
||||||
|
alt="Uploaded image preview"
|
||||||
|
class="max-h-[400px] w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- EXIF table -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">EXIF Metadata</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<template v-if="getExifRows().length > 0">
|
||||||
|
Extracted from the image file
|
||||||
|
</template>
|
||||||
|
<template v-else> No EXIF data found in this image. </template>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table v-if="getExifRows().length > 0">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Property</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-for="row in getExifRows()" :key="row.label">
|
||||||
|
<TableCell class="font-medium">{{ row.label }}</TableCell>
|
||||||
|
<TableCell>{{ row.value }}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info card about lens correction -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mt-0.5 shrink-0 text-primary"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<p class="font-medium text-foreground">Lens Correction Info</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
<template v-if="store.exifData.focalLength">
|
||||||
|
This image was shot at <strong>{{ store.exifData.focalLength }}mm</strong>
|
||||||
|
<template v-if="store.exifData.lensModel">
|
||||||
|
with a <strong>{{ store.exifData.lensModel }}</strong> </template
|
||||||
|
>. The deskew algorithm can use this to correct barrel/pincushion distortion.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
No focal length data found. The algorithm will rely solely on datum
|
||||||
|
measurements for perspective correction.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
127
src/components/ImageUpload.vue
Normal file
127
src/components/ImageUpload.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { loadImage } from "@/lib/image-loader";
|
||||||
|
import { extractExif } from "@/lib/exif";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const ACCEPTED = ".jpg,.jpeg,.heic,.heif";
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
error.value = "";
|
||||||
|
store.isProcessing = true;
|
||||||
|
store.processingStatus = "Reading file...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { image, convertedFile } = await loadImage(file, (status) => {
|
||||||
|
store.processingStatus = status;
|
||||||
|
});
|
||||||
|
|
||||||
|
store.processingStatus = "Extracting EXIF data...";
|
||||||
|
const exif = await extractExif(file);
|
||||||
|
|
||||||
|
store.setImage(convertedFile, image);
|
||||||
|
store.setExif(exif);
|
||||||
|
store.goToStep(2);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Failed to load image";
|
||||||
|
} finally {
|
||||||
|
store.isProcessing = false;
|
||||||
|
store.processingStatus = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
isDragging.value = false;
|
||||||
|
const file = e.dataTransfer?.files[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelect(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Card class="w-full max-w-lg">
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<CardTitle class="text-2xl">Upload an Image</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Drop a JPG or HEIC image, or click to browse. HEIC files will be converted
|
||||||
|
automatically.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
class="relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors"
|
||||||
|
:class="
|
||||||
|
isDragging
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||||
|
"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<template v-if="store.isProcessing">
|
||||||
|
<div class="flex flex-col items-center gap-3 p-6">
|
||||||
|
<Progress :model-value="50" class="w-48" />
|
||||||
|
<p class="text-sm text-muted-foreground">{{ store.processingStatus }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mb-3 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" x2="12" y1="3" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Drag & drop or
|
||||||
|
<span class="font-medium text-primary underline">browse</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground/70">JPG, JPEG, HEIC, HEIF</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
:accept="ACCEPTED"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-3 text-center text-sm text-destructive">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
188
src/components/ResultViewer.vue
Normal file
188
src/components/ResultViewer.vue
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { deskewImage } from "@/lib/deskew";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
const resultUrl = ref<string | null>(null);
|
||||||
|
const error = ref("");
|
||||||
|
const hasRun = ref(false);
|
||||||
|
|
||||||
|
async function runDeskew() {
|
||||||
|
if (!store.loadedImage) return;
|
||||||
|
|
||||||
|
error.value = "";
|
||||||
|
store.isProcessing = true;
|
||||||
|
store.processingStatus = "Running deskew algorithm...";
|
||||||
|
hasRun.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Draw the loaded image onto a canvas to pass to the algorithm
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = store.loadedImage.naturalWidth;
|
||||||
|
canvas.height = store.loadedImage.naturalHeight;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Cannot get 2D context");
|
||||||
|
ctx.drawImage(store.loadedImage, 0, 0);
|
||||||
|
|
||||||
|
const result = await deskewImage({
|
||||||
|
imageData: canvas,
|
||||||
|
datums: store.datums,
|
||||||
|
exif: store.exifData,
|
||||||
|
});
|
||||||
|
|
||||||
|
store.setResult(result);
|
||||||
|
resultUrl.value = URL.createObjectURL(result.correctedImageBlob);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Deskew failed";
|
||||||
|
} finally {
|
||||||
|
store.isProcessing = false;
|
||||||
|
store.processingStatus = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!resultUrl.value) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = resultUrl.value;
|
||||||
|
a.download = `skwik-${store.originalFile?.name ?? "output"}.jpg`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Don't auto-run: let user set scale first
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">Process & Download</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Set the scale, run the deskew algorithm, and download the corrected image.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" @click="store.goToStep(3)">Back</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scale setting (between step 3 and running the algo) -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Image Scale</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
How many pixels represent 1 cm in the original image. This helps the algorithm
|
||||||
|
interpret your datum measurements.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Label>Scale</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="String(store.scalePxPerCm)"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="w-28"
|
||||||
|
@update:model-value="(v: string | number) => (store.scalePxPerCm = Number(v) || 50)"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">px / cm</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Summary of datums -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Datum Summary</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{{ store.datums.length }} datum(s) will be used for calibration.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Badge v-for="datum in store.datums" :key="datum.id" variant="outline">
|
||||||
|
{{ datum.label }}
|
||||||
|
({{ datum.type === "rectangle" ? `${datum.widthMm}\u00D7${datum.heightMm}mm` : `${datum.lengthMm}mm` }})
|
||||||
|
— confidence {{ datum.confidence }}/5
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Run button -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button size="lg" :disabled="store.isProcessing" @click="runDeskew">
|
||||||
|
<template v-if="store.isProcessing">
|
||||||
|
Processing...
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ hasRun ? "Re-run Deskew" : "Run Deskew Algorithm" }}
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-center text-sm text-destructive">{{ error }}</p>
|
||||||
|
|
||||||
|
<!-- Result -->
|
||||||
|
<template v-if="store.deskewResult">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Corrected Image</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
<span
|
||||||
|
v-for="(correction, i) in store.deskewResult.appliedCorrections"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
{{ correction }}<template v-if="i < store.deskewResult.appliedCorrections.length - 1">
|
||||||
|
 • 
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex items-center justify-center overflow-hidden rounded-md bg-muted">
|
||||||
|
<img
|
||||||
|
v-if="resultUrl"
|
||||||
|
:src="resultUrl"
|
||||||
|
alt="Corrected image"
|
||||||
|
class="max-h-[500px] w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button size="lg" @click="download">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" x2="12" y1="15" y2="3" />
|
||||||
|
</svg>
|
||||||
|
Download Image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
30
src/components/StepIndicator.vue
Normal file
30
src/components/StepIndicator.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
const store = useAppStore();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ num: 1 as const, label: "Upload" },
|
||||||
|
{ num: 2 as const, label: "EXIF" },
|
||||||
|
{ num: 3 as const, label: "Datums" },
|
||||||
|
{ num: 4 as const, label: "Result" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="flex items-center gap-1" aria-label="Steps">
|
||||||
|
<template v-for="(step, i) in steps" :key="step.num">
|
||||||
|
<Badge
|
||||||
|
:variant="store.currentStep === step.num ? 'default' : 'outline'"
|
||||||
|
class="cursor-default select-none text-xs"
|
||||||
|
:class="{
|
||||||
|
'opacity-40': store.currentStep < step.num,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ step.num }}. {{ step.label }}
|
||||||
|
</Badge>
|
||||||
|
<span v-if="i < steps.length - 1" class="text-muted-foreground">·</span>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
64
src/components/ThemeToggle.vue
Normal file
64
src/components/ThemeToggle.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const isDark = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isDark.value =
|
||||||
|
document.documentElement.classList.contains("dark") ||
|
||||||
|
(!document.documentElement.classList.contains("light") &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
applyTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isDark.value = !isDark.value;
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
document.documentElement.classList.toggle("dark", isDark.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button variant="ghost" size="icon" @click="toggle" aria-label="Toggle theme">
|
||||||
|
<svg
|
||||||
|
v-if="isDark"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2" />
|
||||||
|
<path d="M12 20v2" />
|
||||||
|
<path d="m4.93 4.93 1.41 1.41" />
|
||||||
|
<path d="m17.66 17.66 1.41 1.41" />
|
||||||
|
<path d="M2 12h2" />
|
||||||
|
<path d="M20 12h2" />
|
||||||
|
<path d="m6.34 17.66-1.41 1.41" />
|
||||||
|
<path d="m19.07 4.93-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
27
src/components/ui/badge/Badge.vue
Normal file
27
src/components/ui/badge/Badge.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { BadgeVariants } from '.'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { badgeVariants } from '.'
|
||||||
|
|
||||||
|
const props = defineProps<PrimitiveProps & {
|
||||||
|
variant?: BadgeVariants['variant']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:data-variant="variant"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
24
src/components/ui/badge/index.ts
Normal file
24
src/components/ui/badge/index.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Badge } from './Badge.vue'
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
|
||||||
|
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
|
||||||
|
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
|
||||||
|
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
31
src/components/ui/button/Button.vue
Normal file
31
src/components/ui/button/Button.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimitiveProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { ButtonVariants } from '.'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '.'
|
||||||
|
|
||||||
|
interface Props extends PrimitiveProps {
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
as: 'button',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
|
:data-variant="variant"
|
||||||
|
:data-size="size"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
35
src/components/ui/button/index.ts
Normal file
35
src/components/ui/button/index.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Button } from './Button.vue'
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||||
|
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
|
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||||
|
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||||
|
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
'icon': 'size-8',
|
||||||
|
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
21
src/components/ui/card/Card.vue
Normal file
21
src/components/ui/card/Card.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
size?: 'default' | 'sm'
|
||||||
|
}>(), {
|
||||||
|
size: 'default',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
:data-size="size"
|
||||||
|
:class="cn('ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/card/CardAction.vue
Normal file
17
src/components/ui/card/CardAction.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/card/CardContent.vue
Normal file
17
src/components/ui/card/CardContent.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
:class="cn('px-4 group-data-[size=sm]/card:px-3', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/card/CardDescription.vue
Normal file
17
src/components/ui/card/CardDescription.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/card/CardFooter.vue
Normal file
17
src/components/ui/card/CardFooter.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
:class="cn('bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/card/CardHeader.vue
Normal file
17
src/components/ui/card/CardHeader.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
:class="cn('gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/card/CardTitle.vue
Normal file
17
src/components/ui/card/CardTitle.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
:class="cn('text-base leading-snug font-medium group-data-[size=sm]/card:text-sm cn-font-heading', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
7
src/components/ui/card/index.ts
Normal file
7
src/components/ui/card/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { default as Card } from './Card.vue'
|
||||||
|
export { default as CardAction } from './CardAction.vue'
|
||||||
|
export { default as CardContent } from './CardContent.vue'
|
||||||
|
export { default as CardDescription } from './CardDescription.vue'
|
||||||
|
export { default as CardFooter } from './CardFooter.vue'
|
||||||
|
export { default as CardHeader } from './CardHeader.vue'
|
||||||
|
export { default as CardTitle } from './CardTitle.vue'
|
||||||
31
src/components/ui/input/Input.vue
Normal file
31
src/components/ui/input/Input.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number
|
||||||
|
modelValue?: string | number
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', payload: string | number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
v-model="modelValue"
|
||||||
|
data-slot="input"
|
||||||
|
:class="cn(
|
||||||
|
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
</template>
|
||||||
1
src/components/ui/input/index.ts
Normal file
1
src/components/ui/input/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from './Input.vue'
|
||||||
26
src/components/ui/label/Label.vue
Normal file
26
src/components/ui/label/Label.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { LabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Label } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
1
src/components/ui/label/index.ts
Normal file
1
src/components/ui/label/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './Label.vue'
|
||||||
38
src/components/ui/progress/Progress.vue
Normal file
38
src/components/ui/progress/Progress.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProgressRootProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressRoot,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(),
|
||||||
|
{
|
||||||
|
modelValue: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProgressRoot
|
||||||
|
data-slot="progress"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ProgressIndicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
class="bg-primary size-full flex-1 transition-all"
|
||||||
|
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||||
|
/>
|
||||||
|
</ProgressRoot>
|
||||||
|
</template>
|
||||||
1
src/components/ui/progress/index.ts
Normal file
1
src/components/ui/progress/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Progress } from './Progress.vue'
|
||||||
19
src/components/ui/select/Select.vue
Normal file
19
src/components/ui/select/Select.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
|
||||||
|
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectRootProps>()
|
||||||
|
const emits = defineEmits<SelectRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="select"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
58
src/components/ui/select/SelectContent.vue
Normal file
58
src/components/ui/select/SelectContent.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
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 = withDefaults(
|
||||||
|
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||||
|
{
|
||||||
|
position: 'item-aligned',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emits = defineEmits<SelectContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
data-slot="select-content"
|
||||||
|
:data-align-trigger="position === 'item-aligned'"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
:class="cn(
|
||||||
|
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
|
||||||
|
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
|
||||||
|
:data-position="position"
|
||||||
|
:class="cn(
|
||||||
|
'data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full data-[position=popper]:min-w-[var(--reka-select-trigger-width)]',
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectViewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</template>
|
||||||
21
src/components/ui/select/SelectGroup.vue
Normal file
21
src/components/ui/select/SelectGroup.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectGroupProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectGroup } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectGroup
|
||||||
|
data-slot="select-group"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('scroll-my-1 p-1', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectGroup>
|
||||||
|
</template>
|
||||||
45
src/components/ui/select/SelectItem.vue
Normal file
45
src/components/ui/select/SelectItem.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { CheckIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
useForwardProps,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItem
|
||||||
|
data-slot="select-item"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=size-])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectItemIndicator>
|
||||||
|
<slot name="indicator-icon">
|
||||||
|
<CheckIcon class="pointer-events-none" />
|
||||||
|
</slot>
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectItemText>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</template>
|
||||||
15
src/components/ui/select/SelectItemText.vue
Normal file
15
src/components/ui/select/SelectItemText.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectItemTextProps } from 'reka-ui'
|
||||||
|
import { SelectItemText } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectItemTextProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectItemText
|
||||||
|
data-slot="select-item-text"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectItemText>
|
||||||
|
</template>
|
||||||
17
src/components/ui/select/SelectLabel.vue
Normal file
17
src/components/ui/select/SelectLabel.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectLabelProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { SelectLabel } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
:class="cn('text-muted-foreground px-1.5 py-1 text-xs', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectLabel>
|
||||||
|
</template>
|
||||||
27
src/components/ui/select/SelectScrollDownButton.vue
Normal file
27
src/components/ui/select/SelectScrollDownButton.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronDownIcon } from 'lucide-vue-next'
|
||||||
|
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollDownButton>
|
||||||
|
</template>
|
||||||
27
src/components/ui/select/SelectScrollUpButton.vue
Normal file
27
src/components/ui/select/SelectScrollUpButton.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronUpIcon } from 'lucide-vue-next'
|
||||||
|
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn('bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=size-])]:size-4', props.class)"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</slot>
|
||||||
|
</SelectScrollUpButton>
|
||||||
|
</template>
|
||||||
19
src/components/ui/select/SelectSeparator.vue
Normal file
19
src/components/ui/select/SelectSeparator.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectSeparatorProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SelectSeparator } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectSeparator
|
||||||
|
data-slot="select-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="cn('bg-border -mx-1 my-1 h-px pointer-events-none', props.class)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
34
src/components/ui/select/SelectTrigger.vue
Normal file
34
src/components/ui/select/SelectTrigger.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectTriggerProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { ChevronDownIcon } from 'lucide-vue-next'
|
||||||
|
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
|
||||||
|
{ size: 'default' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||||
|
const forwardedProps = useForwardProps(delegatedProps)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectTrigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
:data-size="size"
|
||||||
|
v-bind="forwardedProps"
|
||||||
|
:class="cn(
|
||||||
|
'border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=size-])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
|
</template>
|
||||||
15
src/components/ui/select/SelectValue.vue
Normal file
15
src/components/ui/select/SelectValue.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SelectValueProps } from 'reka-ui'
|
||||||
|
import { SelectValue } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<SelectValueProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectValue
|
||||||
|
data-slot="select-value"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectValue>
|
||||||
|
</template>
|
||||||
11
src/components/ui/select/index.ts
Normal file
11
src/components/ui/select/index.ts
Normal file
@ -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'
|
||||||
29
src/components/ui/separator/Separator.vue
Normal file
29
src/components/ui/separator/Separator.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SeparatorProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { Separator } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<
|
||||||
|
SeparatorProps & { class?: HTMLAttributes['class'] }
|
||||||
|
>(), {
|
||||||
|
orientation: 'horizontal',
|
||||||
|
decorative: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
1
src/components/ui/separator/index.ts
Normal file
1
src/components/ui/separator/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Separator } from './Separator.vue'
|
||||||
19
src/components/ui/sheet/Sheet.vue
Normal file
19
src/components/ui/sheet/Sheet.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||||
|
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogRootProps>()
|
||||||
|
const emits = defineEmits<DialogRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="sheet"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
15
src/components/ui/sheet/SheetClose.vue
Normal file
15
src/components/ui/sheet/SheetClose.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogCloseProps } from 'reka-ui'
|
||||||
|
import { DialogClose } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogCloseProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogClose
|
||||||
|
data-slot="sheet-close"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogClose>
|
||||||
|
</template>
|
||||||
61
src/components/ui/sheet/SheetContent.vue
Normal file
61
src/components/ui/sheet/SheetContent.vue
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { XIcon } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogPortal,
|
||||||
|
useForwardPropsEmits,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import SheetOverlay from './SheetOverlay.vue'
|
||||||
|
|
||||||
|
interface SheetContentProps extends DialogContentProps {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||||
|
side: 'right',
|
||||||
|
showCloseButton: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<DialogContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class', 'side', 'showCloseButton')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogContent
|
||||||
|
data-slot="sheet-content"
|
||||||
|
:data-side="side"
|
||||||
|
:class="cn('bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10', props.class)"
|
||||||
|
v-bind="{ ...$attrs, ...forwarded }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
v-if="showCloseButton"
|
||||||
|
data-slot="sheet-close"
|
||||||
|
as-child
|
||||||
|
>
|
||||||
|
<Button variant="ghost" class="absolute top-3 right-3" size="icon-sm">
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</template>
|
||||||
21
src/components/ui/sheet/SheetDescription.vue
Normal file
21
src/components/ui/sheet/SheetDescription.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogDescriptionProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogDescription } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogDescription
|
||||||
|
data-slot="sheet-description"
|
||||||
|
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogDescription>
|
||||||
|
</template>
|
||||||
15
src/components/ui/sheet/SheetFooter.vue
Normal file
15
src/components/ui/sheet/SheetFooter.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
:class="cn('gap-2 p-4 mt-auto flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
src/components/ui/sheet/SheetHeader.vue
Normal file
15
src/components/ui/sheet/SheetHeader.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
:class="cn('gap-0.5 p-4 flex flex-col', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
21
src/components/ui/sheet/SheetOverlay.vue
Normal file
21
src/components/ui/sheet/SheetOverlay.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogOverlayProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogOverlay } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogOverlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
:class="cn('bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogOverlay>
|
||||||
|
</template>
|
||||||
21
src/components/ui/sheet/SheetTitle.vue
Normal file
21
src/components/ui/sheet/SheetTitle.vue
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTitleProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { DialogTitle } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTitle
|
||||||
|
data-slot="sheet-title"
|
||||||
|
:class="cn('text-foreground text-base font-medium cn-font-heading', props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTitle>
|
||||||
|
</template>
|
||||||
15
src/components/ui/sheet/SheetTrigger.vue
Normal file
15
src/components/ui/sheet/SheetTrigger.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DialogTriggerProps } from 'reka-ui'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<DialogTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogTrigger
|
||||||
|
data-slot="sheet-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</DialogTrigger>
|
||||||
|
</template>
|
||||||
8
src/components/ui/sheet/index.ts
Normal file
8
src/components/ui/sheet/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export { default as Sheet } from './Sheet.vue'
|
||||||
|
export { default as SheetClose } from './SheetClose.vue'
|
||||||
|
export { default as SheetContent } from './SheetContent.vue'
|
||||||
|
export { default as SheetDescription } from './SheetDescription.vue'
|
||||||
|
export { default as SheetFooter } from './SheetFooter.vue'
|
||||||
|
export { default as SheetHeader } from './SheetHeader.vue'
|
||||||
|
export { default as SheetTitle } from './SheetTitle.vue'
|
||||||
|
export { default as SheetTrigger } from './SheetTrigger.vue'
|
||||||
49
src/components/ui/slider/Slider.vue
Normal file
49
src/components/ui/slider/Slider.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { SliderRange, SliderRoot, SliderThumb, SliderTrack, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<SliderRootProps & { class?: HTMLAttributes['class'] }>()
|
||||||
|
const emits = defineEmits<SliderRootEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SliderRoot
|
||||||
|
v-slot="{ modelValue }"
|
||||||
|
data-slot="slider"
|
||||||
|
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
||||||
|
:class="cn(
|
||||||
|
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
||||||
|
props.class,
|
||||||
|
)"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<SliderTrack
|
||||||
|
data-slot="slider-track"
|
||||||
|
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
|
||||||
|
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
||||||
|
class="bg-muted rounded-full data-horizontal:h-1 data-vertical:w-1 relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
|
||||||
|
>
|
||||||
|
<SliderRange
|
||||||
|
data-slot="slider-range"
|
||||||
|
:data-horizontal="props.orientation !== 'vertical' ? '' : undefined"
|
||||||
|
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
||||||
|
class="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
|
||||||
|
/>
|
||||||
|
</SliderTrack>
|
||||||
|
|
||||||
|
<SliderThumb
|
||||||
|
v-for="(_, key) in modelValue"
|
||||||
|
:key="key"
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
:data-vertical="props.orientation === 'vertical' ? '' : undefined"
|
||||||
|
class="border-ring ring-ring/50 relative size-3 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</SliderRoot>
|
||||||
|
</template>
|
||||||
1
src/components/ui/slider/index.ts
Normal file
1
src/components/ui/slider/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Slider } from './Slider.vue'
|
||||||
16
src/components/ui/table/Table.vue
Normal file
16
src/components/ui/table/Table.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||||
|
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableBody.vue
Normal file
17
src/components/ui/table/TableBody.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableCaption.vue
Normal file
17
src/components/ui/table/TableCaption.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</caption>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableCell.vue
Normal file
17
src/components/ui/table/TableCell.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
:class="cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
34
src/components/ui/table/TableEmpty.vue
Normal file
34
src/components/ui/table/TableEmpty.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import TableCell from './TableCell.vue'
|
||||||
|
import TableRow from './TableRow.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
colspan?: number
|
||||||
|
}>(), {
|
||||||
|
colspan: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center py-10">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableFooter.vue
Normal file
17
src/components/ui/table/TableFooter.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tfoot>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableHead.vue
Normal file
17
src/components/ui/table/TableHead.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</th>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableHeader.vue
Normal file
17
src/components/ui/table/TableHeader.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
:class="cn('[&_tr]:border-b', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</thead>
|
||||||
|
</template>
|
||||||
17
src/components/ui/table/TableRow.vue
Normal file
17
src/components/ui/table/TableRow.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
9
src/components/ui/table/index.ts
Normal file
9
src/components/ui/table/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export { default as Table } from './Table.vue'
|
||||||
|
export { default as TableBody } from './TableBody.vue'
|
||||||
|
export { default as TableCaption } from './TableCaption.vue'
|
||||||
|
export { default as TableCell } from './TableCell.vue'
|
||||||
|
export { default as TableEmpty } from './TableEmpty.vue'
|
||||||
|
export { default as TableFooter } from './TableFooter.vue'
|
||||||
|
export { default as TableHead } from './TableHead.vue'
|
||||||
|
export { default as TableHeader } from './TableHeader.vue'
|
||||||
|
export { default as TableRow } from './TableRow.vue'
|
||||||
10
src/components/ui/table/utils.ts
Normal file
10
src/components/ui/table/utils.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import type { Updater } from '@tanstack/vue-table'
|
||||||
|
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { isFunction } from '@tanstack/vue-table'
|
||||||
|
|
||||||
|
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||||
|
ref.value = isFunction(updaterOrValue)
|
||||||
|
? updaterOrValue(ref.value)
|
||||||
|
: updaterOrValue
|
||||||
|
}
|
||||||
19
src/components/ui/tooltip/Tooltip.vue
Normal file
19
src/components/ui/tooltip/Tooltip.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
|
||||||
|
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<TooltipRootProps>()
|
||||||
|
const emits = defineEmits<TooltipRootEmits>()
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(props, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="tooltip"
|
||||||
|
v-bind="forwarded"
|
||||||
|
>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
</TooltipRoot>
|
||||||
|
</template>
|
||||||
34
src/components/ui/tooltip/TooltipContent.vue
Normal file
34
src/components/ui/tooltip/TooltipContent.vue
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { reactiveOmit } from '@vueuse/core'
|
||||||
|
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
|
||||||
|
sideOffset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits<TooltipContentEmits>()
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, 'class')
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
v-bind="{ ...forwarded, ...$attrs }"
|
||||||
|
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</template>
|
||||||
14
src/components/ui/tooltip/TooltipProvider.vue
Normal file
14
src/components/ui/tooltip/TooltipProvider.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipProviderProps } from 'reka-ui'
|
||||||
|
import { TooltipProvider } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TooltipProviderProps>(), {
|
||||||
|
delayDuration: 0,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipProvider v-bind="props">
|
||||||
|
<slot />
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
15
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
15
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TooltipTriggerProps } from 'reka-ui'
|
||||||
|
import { TooltipTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
const props = defineProps<TooltipTriggerProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TooltipTrigger
|
||||||
|
data-slot="tooltip-trigger"
|
||||||
|
v-bind="props"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TooltipTrigger>
|
||||||
|
</template>
|
||||||
4
src/components/ui/tooltip/index.ts
Normal file
4
src/components/ui/tooltip/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as Tooltip } from './Tooltip.vue'
|
||||||
|
export { default as TooltipContent } from './TooltipContent.vue'
|
||||||
|
export { default as TooltipProvider } from './TooltipProvider.vue'
|
||||||
|
export { default as TooltipTrigger } from './TooltipTrigger.vue'
|
||||||
61
src/lib/datums.ts
Normal file
61
src/lib/datums.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import type { LineDatum, Point, RectDatum, RectPreset } from "@/types";
|
||||||
|
|
||||||
|
export const RECT_PRESETS: RectPreset[] = [
|
||||||
|
{ label: "A3", widthMm: 297, heightMm: 420 },
|
||||||
|
{ label: "A4", widthMm: 210, heightMm: 297 },
|
||||||
|
{ label: "A5", widthMm: 148, heightMm: 210 },
|
||||||
|
{ label: "A6", widthMm: 105, heightMm: 148 },
|
||||||
|
{ label: "10\u00D715 cm", widthMm: 100, heightMm: 150 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATUM_COLORS = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#ef4444", // red
|
||||||
|
"#22c55e", // green
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#f97316", // orange
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDatumColor(index: number): string {
|
||||||
|
return DATUM_COLORS[index % DATUM_COLORS.length]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRectDatum(
|
||||||
|
center: Point,
|
||||||
|
preset?: RectPreset,
|
||||||
|
): RectDatum {
|
||||||
|
const spread = 80;
|
||||||
|
return {
|
||||||
|
id: nanoid(),
|
||||||
|
type: "rectangle",
|
||||||
|
corners: [
|
||||||
|
{ x: center.x - spread, y: center.y - spread },
|
||||||
|
{ x: center.x + spread, y: center.y - spread },
|
||||||
|
{ x: center.x + spread, y: center.y + spread },
|
||||||
|
{ x: center.x - spread, y: center.y + spread },
|
||||||
|
],
|
||||||
|
widthMm: preset?.widthMm ?? 210,
|
||||||
|
heightMm: preset?.heightMm ?? 297,
|
||||||
|
confidence: 3,
|
||||||
|
label: preset?.label ?? "Rectangle",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLineDatum(center: Point): LineDatum {
|
||||||
|
const spread = 100;
|
||||||
|
return {
|
||||||
|
id: nanoid(),
|
||||||
|
type: "line",
|
||||||
|
endpoints: [
|
||||||
|
{ x: center.x - spread, y: center.y },
|
||||||
|
{ x: center.x + spread, y: center.y },
|
||||||
|
],
|
||||||
|
lengthMm: 100,
|
||||||
|
confidence: 3,
|
||||||
|
label: "Line",
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/lib/deskew.ts
Normal file
45
src/lib/deskew.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import type { DeskewInput, DeskewResult } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder deskew algorithm.
|
||||||
|
*
|
||||||
|
* TODO: Replace with actual perspective-correction implementation.
|
||||||
|
* The algorithm should:
|
||||||
|
* 1. Use datum measurements to compute a homography matrix
|
||||||
|
* 2. Apply lens distortion correction using EXIF focal length data
|
||||||
|
* 3. Warp the image to produce a corrected output
|
||||||
|
*/
|
||||||
|
export async function deskewImage(input: DeskewInput): Promise<DeskewResult> {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = input.imageData.width;
|
||||||
|
canvas.height = input.imageData.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("Cannot get 2D context");
|
||||||
|
|
||||||
|
ctx.drawImage(input.imageData, 0, 0);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(b) => (b ? resolve(b) : reject(new Error("Canvas toBlob failed"))),
|
||||||
|
"image/jpeg",
|
||||||
|
0.95,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const corrections: string[] = [];
|
||||||
|
|
||||||
|
if (input.exif.focalLength) {
|
||||||
|
corrections.push(
|
||||||
|
`Lens: ${input.exif.lensModel ?? "unknown"} @ ${input.exif.focalLength}mm`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
corrections.push(`${input.datums.length} datum(s) used for calibration`);
|
||||||
|
corrections.push("Placeholder: no actual correction applied yet");
|
||||||
|
|
||||||
|
return {
|
||||||
|
correctedImageBlob: blob,
|
||||||
|
appliedCorrections: corrections,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
src/lib/exif.ts
Normal file
62
src/lib/exif.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import exifr from "exifr";
|
||||||
|
import type { ExifData } from "@/types";
|
||||||
|
|
||||||
|
export async function extractExif(file: File): Promise<ExifData> {
|
||||||
|
try {
|
||||||
|
const raw = await exifr.parse(file, {
|
||||||
|
tiff: true,
|
||||||
|
exif: true,
|
||||||
|
gps: true,
|
||||||
|
ifd0: { pick: ["Make", "Model", "Orientation", "ImageWidth", "ImageHeight"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!raw) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
make: raw.Make as string | undefined,
|
||||||
|
model: raw.Model as string | undefined,
|
||||||
|
lensModel: raw.LensModel as string | undefined,
|
||||||
|
focalLength: raw.FocalLength as number | undefined,
|
||||||
|
focalLengthIn35mm: raw.FocalLengthIn35mmFormat as number | undefined,
|
||||||
|
orientation: raw.Orientation as number | undefined,
|
||||||
|
imageWidth: (raw.ImageWidth ?? raw.ExifImageWidth) as number | undefined,
|
||||||
|
imageHeight: (raw.ImageHeight ?? raw.ExifImageHeight) as
|
||||||
|
| number
|
||||||
|
| undefined,
|
||||||
|
exposureTime: raw.ExposureTime as number | undefined,
|
||||||
|
fNumber: raw.FNumber as number | undefined,
|
||||||
|
iso: raw.ISO as number | undefined,
|
||||||
|
dateTimeOriginal: raw.DateTimeOriginal
|
||||||
|
? String(raw.DateTimeOriginal)
|
||||||
|
: undefined,
|
||||||
|
gpsLatitude: raw.latitude as number | undefined,
|
||||||
|
gpsLongitude: raw.longitude as number | undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.warn("EXIF extraction failed");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orientationLabel(orientation: number | undefined): string {
|
||||||
|
switch (orientation) {
|
||||||
|
case 1:
|
||||||
|
return "Normal";
|
||||||
|
case 2:
|
||||||
|
return "Mirrored horizontal";
|
||||||
|
case 3:
|
||||||
|
return "Rotated 180\u00B0";
|
||||||
|
case 4:
|
||||||
|
return "Mirrored vertical";
|
||||||
|
case 5:
|
||||||
|
return "Mirrored horizontal + rotated 270\u00B0";
|
||||||
|
case 6:
|
||||||
|
return "Rotated 90\u00B0 CW";
|
||||||
|
case 7:
|
||||||
|
return "Mirrored horizontal + rotated 90\u00B0";
|
||||||
|
case 8:
|
||||||
|
return "Rotated 270\u00B0 CW";
|
||||||
|
default:
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/lib/image-loader.ts
Normal file
53
src/lib/image-loader.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { isHeic, heicTo } from "heic-to";
|
||||||
|
|
||||||
|
function isHeicFile(file: File): boolean {
|
||||||
|
const ext = file.name.toLowerCase();
|
||||||
|
if (ext.endsWith(".heic") || ext.endsWith(".heif")) return true;
|
||||||
|
return file.type === "image/heic" || file.type === "image/heif";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadImage(
|
||||||
|
file: File,
|
||||||
|
onProgress?: (status: string) => void,
|
||||||
|
): Promise<{ image: HTMLImageElement; convertedFile: File }> {
|
||||||
|
let processedFile = file;
|
||||||
|
|
||||||
|
if (isHeicFile(file)) {
|
||||||
|
onProgress?.("Checking HEIC format...");
|
||||||
|
|
||||||
|
if (await isHeic(file)) {
|
||||||
|
onProgress?.("Converting HEIC to JPEG...");
|
||||||
|
const jpegBlob = await heicTo({
|
||||||
|
blob: file,
|
||||||
|
type: "image/jpeg",
|
||||||
|
quality: 0.92,
|
||||||
|
});
|
||||||
|
|
||||||
|
processedFile = new File(
|
||||||
|
[jpegBlob],
|
||||||
|
file.name.replace(/\.hei[cf]$/i, ".jpg"),
|
||||||
|
{ type: "image/jpeg" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.("Loading image...");
|
||||||
|
const image = await createImageElement(processedFile);
|
||||||
|
return { image, convertedFile: processedFile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createImageElement(file: File): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
// Keep the object URL alive — the img.src must remain valid for later
|
||||||
|
// rendering in <img> tags and on the Konva canvas.
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("Failed to load image"));
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
7
src/lib/utils.ts
Normal file
7
src/lib/utils.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { ClassValue } from "clsx"
|
||||||
|
import { clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import VueKonva from "vue-konva";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./assets/index.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(VueKonva);
|
||||||
|
app.mount("#app");
|
||||||
96
src/stores/app.ts
Normal file
96
src/stores/app.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types";
|
||||||
|
import { DEFAULT_SCALE_PX_PER_CM } from "@/types";
|
||||||
|
|
||||||
|
export const useAppStore = defineStore("app", () => {
|
||||||
|
const currentStep = ref<AppStep>(1);
|
||||||
|
const originalFile = ref<File | null>(null);
|
||||||
|
const loadedImage = ref<HTMLImageElement | null>(null);
|
||||||
|
const exifData = ref<ExifData>({});
|
||||||
|
const datums = ref<Datum[]>([]);
|
||||||
|
const deskewResult = ref<DeskewResult | null>(null);
|
||||||
|
const isProcessing = ref(false);
|
||||||
|
const processingStatus = ref("");
|
||||||
|
const selectedDatumId = ref<string | null>(null);
|
||||||
|
const scalePxPerCm = ref(DEFAULT_SCALE_PX_PER_CM);
|
||||||
|
|
||||||
|
const canProceedToStep2 = computed(() => loadedImage.value !== null);
|
||||||
|
const canProceedToStep3 = computed(() => canProceedToStep2.value);
|
||||||
|
const canProceedToStep4 = computed(
|
||||||
|
() => canProceedToStep3.value && datums.value.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
function setImage(file: File, image: HTMLImageElement) {
|
||||||
|
originalFile.value = file;
|
||||||
|
loadedImage.value = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExif(data: ExifData) {
|
||||||
|
exifData.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep(step: AppStep) {
|
||||||
|
currentStep.value = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDatum(datum: Datum) {
|
||||||
|
datums.value.push(datum);
|
||||||
|
selectedDatumId.value = datum.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDatum(id: string, updates: Partial<Datum>) {
|
||||||
|
const index = datums.value.findIndex((d) => d.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
datums.value[index] = { ...datums.value[index]!, ...updates } as Datum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDatum(id: string) {
|
||||||
|
datums.value = datums.value.filter((d) => d.id !== id);
|
||||||
|
if (selectedDatumId.value === id) {
|
||||||
|
selectedDatumId.value = datums.value[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResult(result: DeskewResult) {
|
||||||
|
deskewResult.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
currentStep.value = 1;
|
||||||
|
originalFile.value = null;
|
||||||
|
loadedImage.value = null;
|
||||||
|
exifData.value = {};
|
||||||
|
datums.value = [];
|
||||||
|
deskewResult.value = null;
|
||||||
|
isProcessing.value = false;
|
||||||
|
processingStatus.value = "";
|
||||||
|
selectedDatumId.value = null;
|
||||||
|
scalePxPerCm.value = DEFAULT_SCALE_PX_PER_CM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
originalFile,
|
||||||
|
loadedImage,
|
||||||
|
exifData,
|
||||||
|
datums,
|
||||||
|
deskewResult,
|
||||||
|
isProcessing,
|
||||||
|
processingStatus,
|
||||||
|
selectedDatumId,
|
||||||
|
scalePxPerCm,
|
||||||
|
canProceedToStep2,
|
||||||
|
canProceedToStep3,
|
||||||
|
canProceedToStep4,
|
||||||
|
setImage,
|
||||||
|
setExif,
|
||||||
|
goToStep,
|
||||||
|
addDatum,
|
||||||
|
updateDatum,
|
||||||
|
removeDatum,
|
||||||
|
setResult,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
});
|
||||||
66
src/types/index.ts
Normal file
66
src/types/index.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RectDatum {
|
||||||
|
id: string;
|
||||||
|
type: "rectangle";
|
||||||
|
corners: [Point, Point, Point, Point]; // TL, TR, BR, BL
|
||||||
|
widthMm: number;
|
||||||
|
heightMm: number;
|
||||||
|
confidence: 1 | 2 | 3 | 4 | 5;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineDatum {
|
||||||
|
id: string;
|
||||||
|
type: "line";
|
||||||
|
endpoints: [Point, Point];
|
||||||
|
lengthMm: number;
|
||||||
|
confidence: 1 | 2 | 3 | 4 | 5;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Datum = RectDatum | LineDatum;
|
||||||
|
|
||||||
|
export type ConfidenceScore = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
export interface ExifData {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
lensModel?: string;
|
||||||
|
focalLength?: number;
|
||||||
|
focalLengthIn35mm?: number;
|
||||||
|
orientation?: number;
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
|
exposureTime?: number;
|
||||||
|
fNumber?: number;
|
||||||
|
iso?: number;
|
||||||
|
dateTimeOriginal?: string;
|
||||||
|
gpsLatitude?: number;
|
||||||
|
gpsLongitude?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeskewInput {
|
||||||
|
imageData: HTMLCanvasElement;
|
||||||
|
datums: Datum[];
|
||||||
|
exif: ExifData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeskewResult {
|
||||||
|
correctedImageBlob: Blob;
|
||||||
|
appliedCorrections: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppStep = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
/** Pixels per centimeter in the image. Used for initial datum placement scaling. */
|
||||||
|
export const DEFAULT_SCALE_PX_PER_CM = 50;
|
||||||
|
|
||||||
|
export interface RectPreset {
|
||||||
|
label: string;
|
||||||
|
widthMm: number;
|
||||||
|
heightMm: number;
|
||||||
|
}
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
/* Strict */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "env.d.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user