@ -1,3 +1,3 @@
|
||||
engine-strict=true |
||||
shamefully-hoist=true |
||||
use-node-version=18.14.0 |
||||
use-node-version=18.19.0 |
||||
|
@ -0,0 +1,551 @@
|
||||
#!/bin/bash |
||||
# set -x |
||||
|
||||
# ****************************************************************************** |
||||
# ***************** HELPER FUNCTIONS START ********************************* |
||||
|
||||
# Function to URL encode special characters in a string |
||||
urlencode() { |
||||
local string="$1" |
||||
local strlen=${#string} |
||||
local encoded="" |
||||
local pos c o |
||||
|
||||
for (( pos=0 ; pos<strlen ; pos++ )); do |
||||
c=${string:$pos:1} |
||||
case "$c" in |
||||
[-_.~a-zA-Z0-9] ) o="$c" ;; |
||||
* ) printf -v o '%%%02X' "'$c" |
||||
esac |
||||
encoded+="$o" |
||||
done |
||||
echo "$encoded" |
||||
} |
||||
|
||||
# function to print a message in a box |
||||
print_box_message() { |
||||
message=("$@") # Store all arguments in the array "message" |
||||
edge="======================================" |
||||
padding=" " |
||||
|
||||
echo "$edge" |
||||
for element in "${message[@]}"; do |
||||
echo "${padding}${element}" |
||||
done |
||||
echo "$edge" |
||||
} |
||||
|
||||
# check command exists |
||||
command_exists() { |
||||
command -v "$1" >/dev/null 2>&1 |
||||
} |
||||
|
||||
# install package based on platform |
||||
install_package() { |
||||
if command_exists yum; then |
||||
sudo yum install -y "$1" |
||||
elif command_exists apt; then |
||||
sudo apt install -y "$1" |
||||
elif command_exists brew; then |
||||
brew install "$1" |
||||
else |
||||
echo "Package manager not found. Please install $1 manually." |
||||
fi |
||||
} |
||||
|
||||
# Function to check if sudo is required for Docker Compose command |
||||
check_for_docker_compose_sudo() { |
||||
if docker-compose ps >/dev/null 2>&1; then |
||||
echo "n" |
||||
else |
||||
echo "y" |
||||
fi |
||||
} |
||||
|
||||
# ***************** HELPER FUNCTIONS END *********************************** |
||||
# ****************************************************************************** |
||||
|
||||
|
||||
|
||||
# ****************************************************************************** |
||||
# ******************** SYSTEM REQUIREMENTS CHECK START ************************* |
||||
|
||||
# Check if the following requirements are met: |
||||
# a. docker, docker-compose, jq installed |
||||
# b. port mapping check : 80,443 are free or being used by nginx container |
||||
|
||||
REQUIRED_PORTS=(80 443) |
||||
|
||||
echo "** Performing nocodb system check and setup. This step may require sudo permissions" |
||||
|
||||
# pre install wget if not found |
||||
if ! command_exists wget; then |
||||
echo "wget is not installed. Setting up for installation..." |
||||
install_package wget |
||||
fi |
||||
|
||||
# d. Check if required tools are installed |
||||
echo " | Checking if required tools (docker, docker-compose, lsof) are installed..." |
||||
for tool in docker docker-compose lsof openssl; do |
||||
if ! command_exists "$tool"; then |
||||
echo "$tool is not installed. Setting up for installation..." |
||||
if [ "$tool" = "docker-compose" ]; then |
||||
sudo -E curl -L https://github.com/docker/compose/releases/download/1.29.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose |
||||
sudo chmod +x /usr/local/bin/docker-compose |
||||
elif [ "$tool" = "docker" ]; then |
||||
wget -qO- https://get.docker.com/ | sh |
||||
elif [ "$tool" = "lsof" ]; then |
||||
install_package lsof |
||||
fi |
||||
fi |
||||
done |
||||
|
||||
# e. Check if NocoDB is already installed and its expected version |
||||
# echo "Checking if NocoDB is already installed and its expected version..." |
||||
# Replace the following command with the actual command to check NocoDB installation and version |
||||
# Example: nocodb_version=$(command_to_get_nocodb_version) |
||||
# echo "NocoDB version: $nocodb_install_version" |
||||
|
||||
# f. Port mapping check |
||||
echo " | Checking port accessibility..." |
||||
for port in "${REQUIRED_PORTS[@]}"; do |
||||
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null; then |
||||
echo " | WARNING: Port $port is in use. Please make sure it is free." >&2 |
||||
else |
||||
echo " | Port $port is free." |
||||
fi |
||||
done |
||||
|
||||
echo "** System check completed successfully. **" |
||||
|
||||
|
||||
# Define an array to store the messages to be printed at the end |
||||
message_arr=() |
||||
|
||||
# extract public ip address |
||||
PUBLIC_IP=$(dig +short myip.opendns.com @resolver1.opendns.com) |
||||
|
||||
# Check if the public IP address is not empty, if empty then use the localhost |
||||
if [ -z "$PUBLIC_IP" ]; then |
||||
PUBLIC_IP="localhost" |
||||
fi |
||||
|
||||
# generate a folder for the docker-compose file which is not existing and do the setup within the folder |
||||
# Define the folder name |
||||
FOLDER_NAME="nocodb_$(date +"%Y%m%d_%H%M%S")" |
||||
|
||||
# prompt for custom folder name and if left empty skip |
||||
#echo "Enter a custom folder name or press Enter to use the default folder name ($FOLDER_NAME): " |
||||
#read CUSTOM_FOLDER_NAME |
||||
|
||||
message_arr+=("Setup folder: $FOLDER_NAME") |
||||
|
||||
if [ -n "$CUSTOM_FOLDER_NAME" ]; then |
||||
FOLDER_NAME="$CUSTOM_FOLDER_NAME" |
||||
fi |
||||
|
||||
|
||||
# Create the folder |
||||
mkdir -p "$FOLDER_NAME" |
||||
|
||||
# Navigate into the folder |
||||
cd "$FOLDER_NAME" || exit |
||||
|
||||
# ******************** SYSTEM REQUIREMENTS CHECK END ************************** |
||||
# ****************************************************************************** |
||||
|
||||
|
||||
|
||||
# ******************** INPUTS FROM USER START ******************************** |
||||
# ****************************************************************************** |
||||
|
||||
echo "Choose Community or Enterprise Edition [CE/EE] (default: CE): " |
||||
read EDITION |
||||
|
||||
echo "Do you want to configure SSL [Y/N] (default: N): " |
||||
read SSL_ENABLED |
||||
|
||||
|
||||
if [ -n "$SSL_ENABLED" ] && { [ "$SSL_ENABLED" = "Y" ] || [ "$SSL_ENABLED" = "y" ]; }; then |
||||
SSL_ENABLED='y' |
||||
echo "Enter the domain name for the SSL certificate: " |
||||
read DOMAIN_NAME |
||||
if [ -z "$DOMAIN_NAME" ]; then |
||||
echo "Domain name is required for SSL configuration" |
||||
exit 1 |
||||
fi |
||||
message_arr+=("Domain: $DOMAIN_NAME") |
||||
else |
||||
# prompt for ip address and if left empty use extracted public ip |
||||
echo "Enter the IP address or domain name for the NocoDB instance (default: $PUBLIC_IP): " |
||||
read DOMAIN_NAME |
||||
if [ -z "$DOMAIN_NAME" ]; then |
||||
DOMAIN_NAME="$PUBLIC_IP" |
||||
fi |
||||
fi |
||||
|
||||
if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then |
||||
echo "Enter the NocoDB license key: " |
||||
read LICENSE_KEY |
||||
if [ -z "$LICENSE_KEY" ]; then |
||||
echo "License key is required for Enterprise Edition installation" |
||||
exit 1 |
||||
fi |
||||
fi |
||||
|
||||
|
||||
echo "Do you want to enabled Redis for caching [Y/N] (default: Y): " |
||||
read REDIS_ENABLED |
||||
|
||||
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then |
||||
message_arr+=("Redis: Enabled") |
||||
else |
||||
message_arr+=("Redis: Disabled") |
||||
fi |
||||
|
||||
|
||||
echo "Do you want to enabled Watchtower for automatic updates [Y/N] (default: Y): " |
||||
read WATCHTOWER_ENABLED |
||||
|
||||
if [ -z "$WATCHTOWER_ENABLED" ] || { [ "$WATCHTOWER_ENABLED" != "N" ] && [ "$WATCHTOWER_ENABLED" != "n" ]; }; then |
||||
message_arr+=("Watchtower: Enabled") |
||||
else |
||||
message_arr+=("Watchtower: Disabled") |
||||
fi |
||||
|
||||
|
||||
|
||||
# ****************************************************************************** |
||||
# *********************** INPUTS FROM USER END ******************************** |
||||
|
||||
|
||||
# ****************************************************************************** |
||||
# *************************** SETUP START ************************************* |
||||
|
||||
# Generate a strong random password for PostgreSQL |
||||
STRONG_PASSWORD=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9!@#$%^&*()-_+=' | head -c 32) |
||||
REDIS_PASSWORD=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 24) |
||||
# Encode special characters in the password for JDBC URL usage |
||||
ENCODED_PASSWORD=$(urlencode "$STRONG_PASSWORD") |
||||
|
||||
IMAGE="nocodb/nocodb:latest"; |
||||
|
||||
# Determine the Docker image to use based on the edition |
||||
if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then |
||||
IMAGE="nocodb/nocodb-ee:latest" |
||||
DATABASE_URL="DATABASE_URL=postgres://postgres:${ENCODED_PASSWORD}@db:5432/nocodb" |
||||
else |
||||
# use NC_DB url until the issue with DATABASE_URL is resolved(encoding) |
||||
DATABASE_URL="NC_DB=pg://db:5432?d=nocodb&user=postgres&password=${ENCODED_PASSWORD}" |
||||
fi |
||||
|
||||
|
||||
message_arr+=("Docker image: $IMAGE") |
||||
|
||||
|
||||
DEPENDS_ON="" |
||||
|
||||
# Add Redis service if enabled |
||||
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then |
||||
DEPENDS_ON="- redis" |
||||
fi |
||||
|
||||
|
||||
# Write the Docker Compose file with the updated password |
||||
cat <<EOF > docker-compose.yml |
||||
version: '3' |
||||
|
||||
services: |
||||
nocodb: |
||||
image: ${IMAGE} |
||||
env_file: docker.env |
||||
depends_on: |
||||
- db |
||||
${DEPENDS_ON} |
||||
restart: unless-stopped |
||||
volumes: |
||||
- ./nocodb:/usr/app/data |
||||
labels: |
||||
- "com.centurylinklabs.watchtower.enable=true" |
||||
networks: |
||||
- nocodb-network |
||||
db: |
||||
image: postgres:16.1 |
||||
env_file: docker.env |
||||
volumes: |
||||
- ./postgres:/var/lib/postgresql/data |
||||
restart: unless-stopped |
||||
healthcheck: |
||||
interval: 10s |
||||
retries: 10 |
||||
test: "pg_isready -U \"\$\$POSTGRES_USER\" -d \"\$\$POSTGRES_DB\"" |
||||
timeout: 2s |
||||
networks: |
||||
- nocodb-network |
||||
|
||||
nginx: |
||||
image: nginx:latest |
||||
volumes: |
||||
- ./nginx:/etc/nginx/conf.d |
||||
EOF |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
cat <<EOF >> docker-compose.yml |
||||
- webroot:/var/www/certbot |
||||
- ./letsencrypt:/etc/letsencrypt |
||||
- letsencrypt-lib:/var/lib/letsencrypt |
||||
EOF |
||||
fi |
||||
cat <<EOF >> docker-compose.yml |
||||
ports: |
||||
- "80:80" |
||||
- "443:443" |
||||
depends_on: |
||||
- nocodb |
||||
restart: unless-stopped |
||||
networks: |
||||
- nocodb-network |
||||
EOF |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
cat <<EOF >> docker-compose.yml |
||||
certbot: |
||||
image: certbot/certbot |
||||
volumes: |
||||
- ./letsencrypt:/etc/letsencrypt |
||||
- letsencrypt-lib:/var/lib/letsencrypt |
||||
- webroot:/var/www/certbot |
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait \$\${!}; done;'" |
||||
depends_on: |
||||
- nginx |
||||
restart: unless-stopped |
||||
networks: |
||||
- nocodb-network |
||||
EOF |
||||
fi |
||||
|
||||
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then |
||||
cat <<EOF >> docker-compose.yml |
||||
redis: |
||||
image: redis:latest |
||||
restart: unless-stopped |
||||
env_file: docker.env |
||||
command: |
||||
- /bin/sh |
||||
- -c |
||||
- redis-server --requirepass "\$\${REDIS_PASSWORD}" |
||||
volumes: |
||||
- redis:/data |
||||
healthcheck: |
||||
test: [ "CMD", "redis-cli", "-a", "\$\${REDIS_PASSWORD}", "--raw", "incr", "ping" ] |
||||
networks: |
||||
- nocodb-network |
||||
EOF |
||||
fi |
||||
|
||||
if [ -z "$WATCHTOWER_ENABLED" ] || { [ "$WATCHTOWER_ENABLED" != "N" ] && [ "$WATCHTOWER_ENABLED" != "n" ]; }; then |
||||
cat <<EOF >> docker-compose.yml |
||||
watchtower: |
||||
image: containrrr/watchtower |
||||
volumes: |
||||
- /var/run/docker.sock:/var/run/docker.sock |
||||
command: --schedule "0 2 * * 6" --cleanup |
||||
restart: unless-stopped |
||||
networks: |
||||
- nocodb-network |
||||
EOF |
||||
fi |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
cat <<EOF >> docker-compose.yml |
||||
volumes: |
||||
letsencrypt-lib: |
||||
webroot: |
||||
EOF |
||||
fi |
||||
|
||||
# add the cache volume |
||||
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then |
||||
# check ssl enabled |
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
cat <<EOF >> docker-compose.yml |
||||
redis: |
||||
EOF |
||||
else |
||||
cat <<EOF >> docker-compose.yml |
||||
volumes: |
||||
redis: |
||||
EOF |
||||
fi |
||||
fi |
||||
|
||||
# Create the network |
||||
cat <<EOF >> docker-compose.yml |
||||
networks: |
||||
nocodb-network: |
||||
driver: bridge |
||||
EOF |
||||
|
||||
# Write the docker.env file |
||||
cat <<EOF > docker.env |
||||
POSTGRES_DB=nocodb |
||||
POSTGRES_USER=postgres |
||||
POSTGRES_PASSWORD=${STRONG_PASSWORD} |
||||
$DATABASE_URL |
||||
NC_LICENSE_KEY=${LICENSE_KEY} |
||||
EOF |
||||
|
||||
# add redis env if enabled |
||||
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then |
||||
cat <<EOF >> docker.env |
||||
REDIS_PASSWORD=${REDIS_PASSWORD} |
||||
NC_REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0 |
||||
EOF |
||||
fi |
||||
|
||||
mkdir -p ./nginx |
||||
|
||||
# Create nginx config with the provided domain name |
||||
cat > ./nginx/default.conf <<EOF |
||||
server { |
||||
listen 80; |
||||
EOF |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
cat >> ./nginx/default.conf <<EOF |
||||
server_name $DOMAIN_NAME; |
||||
EOF |
||||
fi |
||||
|
||||
cat >> ./nginx/default.conf <<EOF |
||||
location / { |
||||
proxy_pass http://nocodb:8080; |
||||
proxy_set_header Host \$host; |
||||
proxy_set_header X-Real-IP \$remote_addr; |
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; |
||||
proxy_set_header X-Forwarded-Proto \$scheme; |
||||
} |
||||
EOF |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
cat >> ./nginx/default.conf <<EOF |
||||
location /.well-known/acme-challenge/ { |
||||
root /var/www/certbot; |
||||
} |
||||
EOF |
||||
fi |
||||
cat >> ./nginx/default.conf <<EOF |
||||
} |
||||
EOF |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
|
||||
mkdir -p ./nginx-post-config |
||||
|
||||
# Create nginx config with the provided domain name |
||||
cat > ./nginx-post-config/default.conf <<EOF |
||||
server { |
||||
listen 80; |
||||
server_name $DOMAIN_NAME; |
||||
|
||||
location / { |
||||
return 301 https://\$host\$request_uri; |
||||
} |
||||
|
||||
location /.well-known/acme-challenge/ { |
||||
root /var/www/certbot; |
||||
} |
||||
} |
||||
|
||||
|
||||
server { |
||||
listen 443 ssl; |
||||
server_name $DOMAIN_NAME; |
||||
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem; |
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem; |
||||
|
||||
location / { |
||||
proxy_pass http://nocodb:8080; |
||||
proxy_set_header Host \$host; |
||||
proxy_set_header X-Real-IP \$remote_addr; |
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; |
||||
proxy_set_header X-Forwarded-Proto \$scheme; |
||||
} |
||||
} |
||||
|
||||
EOF |
||||
fi |
||||
|
||||
IS_DOCKER_COMPOSE_REQUIRE_SUDO=$(check_for_docker_compose_sudo) |
||||
|
||||
|
||||
# Generate the update.sh file for upgrading images |
||||
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then |
||||
cat > ./update.sh <<EOF |
||||
sudo docker-compose pull |
||||
sudo docker-compose up -d --force-recreate |
||||
sudo docker image prune -a -f |
||||
EOF |
||||
else |
||||
cat > ./update.sh <<EOF |
||||
docker-compose pull |
||||
docker-compose up -d --force-recreate |
||||
docker image prune -a -f |
||||
EOF |
||||
fi |
||||
|
||||
|
||||
message_arr+=("Update script: update.sh") |
||||
|
||||
# Pull latest images and start the docker-compose setup |
||||
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then |
||||
echo "Docker compose requires sudo. Running the docker-compose setup with sudo." |
||||
sudo docker-compose pull |
||||
sudo docker-compose up -d |
||||
else |
||||
docker-compose pull |
||||
docker-compose up -d |
||||
fi |
||||
|
||||
|
||||
echo 'Waiting for Nginx to start...'; |
||||
|
||||
sleep 5 |
||||
|
||||
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then |
||||
echo 'Starting Letsencrypt certificate request...'; |
||||
|
||||
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then |
||||
sudo docker-compose exec certbot certbot certonly --webroot --webroot-path=/var/www/certbot -d $DOMAIN_NAME --email contact@$DOMAIN_NAME --agree-tos --no-eff-email && echo "Certificate request successful" || echo "Certificate request failed" |
||||
else |
||||
docker-compose exec certbot certbot certonly --webroot --webroot-path=/var/www/certbot -d $DOMAIN_NAME --email contact@$DOMAIN_NAME --agree-tos --no-eff-email && echo "Certificate request successful" || echo "Certificate request failed" |
||||
fi |
||||
# Initial Let's Encrypt certificate request |
||||
|
||||
# Update the nginx config to use the new certificates |
||||
rm -rf ./nginx/default.conf |
||||
mv ./nginx-post-config/default.conf ./nginx/ |
||||
rm -r ./nginx-post-config |
||||
|
||||
echo "Restarting nginx to apply the new certificates" |
||||
# Reload nginx to apply the new certificates |
||||
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then |
||||
sudo docker-compose exec nginx nginx -s reload |
||||
else |
||||
docker-compose exec nginx nginx -s reload |
||||
fi |
||||
|
||||
|
||||
message_arr+=("NocoDB is now available at https://$DOMAIN_NAME") |
||||
|
||||
elif [ -n "$DOMAIN_NAME" ]; then |
||||
message_arr+=("NocoDB is now available at http://$DOMAIN_NAME") |
||||
else |
||||
message_arr+=("NocoDB is now available at http://localhost") |
||||
fi |
||||
|
||||
print_box_message "${mecdessage_arr[@]}" |
||||
|
||||
# *************************** SETUP END ************************************* |
||||
# ****************************************************************************** |
@ -0,0 +1,283 @@
|
||||
<h1 align="center" style="border-bottom: none"> |
||||
<div> |
||||
<a href="https://www.nocodb.com"> |
||||
<img src="/packages/nc-gui/assets/img/icons/512x512.png" width="80" /> |
||||
<br> |
||||
NocoDB |
||||
</a> |
||||
</div> |
||||
Опенсорс альтернатива Airtable <br> |
||||
</h1> |
||||
|
||||
<p align="center"> |
||||
Nocodb перетворює будь-яку базу даних MySQL, PostgreSQL, SQL Server, SQLite та MariaDB в розумну електронну таблицю. |
||||
</p> |
||||
|
||||
<div align="center"> |
||||
|
||||
[![Node version](https://img.shields.io/badge/node-%3E%3D%2018.19.0-brightgreen)](http://nodejs.org/download/) |
||||
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-green.svg)](https://conventionalcommits.org) |
||||
|
||||
</div> |
||||
|
||||
<p align="center"> |
||||
<a href="http://www.nocodb.com"><b>Сайт</b></a> • |
||||
<a href="https://discord.gg/5RgZmkW"><b>Discord</b></a> • |
||||
<a href="https://community.nocodb.com/"><b>Спільнота</b></a> • |
||||
<a href="https://twitter.com/nocodb"><b>Twitter</b></a> • |
||||
<a href="https://www.reddit.com/r/NocoDB/"><b>Reddit</b></a> • |
||||
<a href="https://docs.nocodb.com/"><b>Документація</b></a> |
||||
</p> |
||||
|
||||
![video avi](https://github.com/nocodb/nocodb/assets/86527202/e2fad786-f211-4dcb-9bd3-aaece83a6783) |
||||
|
||||
<div align="center"> |
||||
|
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263434-75fe793d-42af-49e4-b964-d70920e41655.png">](markdown/readme/languages/chinese.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263474-787d71e7-3a87-42a8-92a8-be1d1f55413d.png">](markdown/readme/languages/french.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263531-fae58600-6616-4b43-95a0-5891019dd35d.png">](markdown/readme/languages/german.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263669-f567196a-d4e8-4143-a80a-93d3be32ba90.png">](markdown/readme/languages/portuguese.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263707-ba4e04a4-268a-4626-91b8-048e572fd9f6.png">](markdown/readme/languages/italian.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263770-38e3e79d-11d4-472e-ac27-ae0f17cf65c4.png">](markdown/readme/languages/japanese.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263822-28fce9de-915a-44dc-962d-7a61d340e91d.png">](markdown/readme/languages/korean.md) |
||||
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263589-3dbeda9a-0d2e-4bbd-b1fc-691404bb74fb.png">](markdown/readme/languages/spanish.md) |
||||
|
||||
</div> |
||||
|
||||
<p align="center"><a href="markdown/readme/languages/README.md"><b>Дивіться інші мови »</b></a></p> |
||||
|
||||
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" /> |
||||
|
||||
# Приєднуйтеся до нашої команди |
||||
|
||||
<p align=""><a href="http://careers.nocodb.com" target="_blank"><img src="https://user-images.githubusercontent.com/61551451/169663818-45643495-e95b-48e2-be13-01d6a77dc2fd.png" width="250"/></a></p> |
||||
|
||||
# Приєднуйтеся до нашої спільноти |
||||
|
||||
<a href="https://discord.gg/5RgZmkW" target="_blank"> |
||||
<img src="https://discordapp.com/api/guilds/661905455894888490/widget.png?style=banner3" alt=""> |
||||
</a> |
||||
|
||||
<!-- <a href="https://community.nocodb.com/" target="_blank"> |
||||
<img src="https://i2.wp.com/www.feverbee.com/wp-content/uploads/2018/07/logo-discourse.png" alt=""> |
||||
</a> |
||||
--> |
||||
|
||||
[![Stargazers repo roster for @nocodb/nocodb](http://reporoster.com/stars/nocodb/nocodb)](https://github.com/nocodb/nocodb/stargazers) |
||||
|
||||
# Швидка спроба проекту |
||||
|
||||
## Docker |
||||
|
||||
```bash |
||||
# для SQLite |
||||
docker run -d --name nocodb \ |
||||
-v "$(pwd)"/nocodb:/usr/app/data/ \ |
||||
-p 8080:8080 \ |
||||
nocodb/nocodb:latest |
||||
|
||||
# для MySQL |
||||
docker run -d --name nocodb-mysql \ |
||||
-v "$(pwd)"/nocodb:/usr/app/data/ \ |
||||
-p 8080:8080 \ |
||||
-e NC_DB="mysql2://host.docker.internal:3306?u=root&p=password&d=d1" \ |
||||
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \ |
||||
nocodb/nocodb:latest |
||||
|
||||
# для PostgreSQL |
||||
docker run -d --name nocodb-postgres \ |
||||
-v "$(pwd)"/nocodb:/usr/app/data/ \ |
||||
-p 8080:8080 \ |
||||
-e NC_DB="pg://host.docker.internal:5432?u=root&p=password&d=d1" \ |
||||
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \ |
||||
nocodb/nocodb:latest |
||||
|
||||
# для MSSQL |
||||
docker run -d --name nocodb-mssql \ |
||||
-v "$(pwd)"/nocodb:/usr/app/data/ \ |
||||
-p 8080:8080 \ |
||||
-e NC_DB="mssql://host.docker.internal:1433?u=root&p=password&d=d1" \ |
||||
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \ |
||||
nocodb/nocodb:latest |
||||
``` |
||||
|
||||
> Щоб зберегти дані в Docker, ви можете змонтувати том в /usr/app/data/ з версії 0.10.6. В іншому випадку ваші дані будуть втрачені після перестворення контейнера. |
||||
|
||||
> Якщо ви плануєте вводити будь-які спеціальні символи, вам може знадобитися змінити набір символів та порівняння при створенні бази даних. Будь ласка, перегляньте приклади для [MySQL Docker](https://github.com/nocodb/nocodb/issues/1340#issuecomment-1049481043). |
||||
|
||||
> Різні команди лише вказують базу даних, яку NocoDB буде використовувати для зберігання метаданих, але це не впливає на можливість підключення до іншого типу бази даних. |
||||
|
||||
## Binaries |
||||
|
||||
##### MacOS (x64) |
||||
|
||||
```bash |
||||
curl http://get.nocodb.com/macos-x64 -o nocodb -L && chmod +x nocodb && ./nocodb |
||||
``` |
||||
|
||||
##### MacOS (arm64) |
||||
|
||||
```bash |
||||
curl http://get.nocodb.com/macos-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb |
||||
``` |
||||
|
||||
##### Linux (x64) |
||||
|
||||
```bash |
||||
curl http://get.nocodb.com/linux-x64 -o nocodb -L && chmod +x nocodb && ./nocodb |
||||
``` |
||||
|
||||
##### Linux (arm64) |
||||
|
||||
```bash |
||||
curl http://get.nocodb.com/linux-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb |
||||
``` |
||||
|
||||
##### Windows (x64) |
||||
|
||||
```bash |
||||
iwr http://get.nocodb.com/win-x64.exe -o Noco-win-x64.exe |
||||
.\Noco-win-x64.exe |
||||
``` |
||||
|
||||
##### Windows (arm64) |
||||
|
||||
```bash |
||||
iwr http://get.nocodb.com/win-arm64.exe -o Noco-win-arm64.exe |
||||
.\Noco-win-arm64.exe |
||||
``` |
||||
|
||||
## Docker Compose |
||||
|
||||
Ми надаємо різні приклад конфігурацій docker-compose.yml у [цьому каталозі](https://github.com/nocodb/nocodb/tree/master/docker-compose). Ось деякі приклади. |
||||
|
||||
```bash |
||||
git clone https://github.com/nocodb/nocodb |
||||
# для MySQL |
||||
cd nocodb/docker-compose/mysql |
||||
# для PostgreSQL |
||||
cd nocodb/docker-compose/pg |
||||
# для MSSQL |
||||
cd nocodb/docker-compose/mssql |
||||
docker-compose up -d |
||||
``` |
||||
|
||||
> Щоб зберегти дані в Docker, ви можете змонтувати том в /usr/app/data/ з версії 0.10.6. В іншому випадку ваші дані будуть втрачені після перестворення контейнера. |
||||
|
||||
> Якщо ви плануєте вводити будь-які спеціальні символи, вам може знадобитися змінити набір символів та порівняння при створенні бази даних. Будь ласка, перегляньте приклади для [MySQL Docker](https://github.com/nocodb/nocodb/issues/1340#issuecomment-1049481043). |
||||
|
||||
## NPX |
||||
|
||||
Ви можете запустити нижченаведену команду, якщо вам потрібна інтерактивна конфігурація. |
||||
|
||||
``` |
||||
npx create-nocodb-app |
||||
``` |
||||
|
||||
<img src="https://user-images.githubusercontent.com/35857179/163672964-00ef5d62-0434-447d-ac01-3ebb780099b9.png" width="520px"/> |
||||
|
||||
## Node Application |
||||
|
||||
Для початку ви можете використати простий Node.js застосунок. |
||||
|
||||
```bash |
||||
git clone https://github.com/nocodb/nocodb-seed |
||||
cd nocodb-seed |
||||
npm install |
||||
npm start |
||||
``` |
||||
|
||||
|
||||
# GUI |
||||
|
||||
Доступ до панелі інструментів за адресою: [http://localhost:8080/dashboard](http://localhost:8080/dashboard) |
||||
|
||||
# Screenshots |
||||
![2](https://github.com/nocodb/nocodb/assets/86527202/a127c05e-2121-4af2-a342-128e0e2d0291) |
||||
![3](https://github.com/nocodb/nocodb/assets/86527202/674da952-8a06-4848-a0e8-a7b02d5f5c88) |
||||
![4](https://github.com/nocodb/nocodb/assets/86527202/cbc5152a-9caf-4f77-a8f7-92a9d06d025b) |
||||
![5](https://github.com/nocodb/nocodb/assets/86527202/dc75dfdc-c486-4f5a-a853-2a8f9e6b569a) |
||||
|
||||
![5](https://user-images.githubusercontent.com/35857179/194844886-a17006e0-979d-493f-83c4-0e72f5a9b716.png) |
||||
![7](https://github.com/nocodb/nocodb/assets/86527202/be64e619-7295-43e2-aa95-cace4462b17f) |
||||
![8](https://github.com/nocodb/nocodb/assets/86527202/4538bf5a-371f-4ec1-a867-8197e5824286) |
||||
|
||||
![8](https://user-images.githubusercontent.com/35857179/194844893-82d5e21b-ae61-41bd-9990-31ad659bf490.png) |
||||
![9](https://user-images.githubusercontent.com/35857179/194844897-cfd79946-e413-4c97-b16d-eb4d7678bb79.png) |
||||
![10](https://user-images.githubusercontent.com/35857179/194844902-c0122570-0dd5-41cf-a26f-6f8d71fefc99.png) |
||||
![11](https://user-images.githubusercontent.com/35857179/194844903-c1e47f40-e782-4f5d-8dce-6449cc70b181.png) |
||||
![12](https://user-images.githubusercontent.com/35857179/194844907-09277d3e-cbbf-465c-9165-6afc4161e279.png) |
||||
|
||||
# Функції |
||||
|
||||
### Багатий інтерфейс таблиць |
||||
|
||||
- ⚡ Основні операції: Створення, Читання, Оновлення та Видалення Таблиць, Стовпців та Рядків |
||||
- ⚡ Операції з полями: Сортування, Фільтрація, Приховування / Розкриття Стовпців |
||||
- ⚡ Типи переглядів: Сітка (за замовчуванням), Галерея, Форма та Канбан |
||||
- ⚡ Типи дозволів для переглядів: Спільний доступ для переглядів та Заблоковані перегляди |
||||
- ⚡ Поділ баз / переглядів: Публічно або Приватно (з паролем) |
||||
- ⚡ Варіанти типів клітинок: ID, ПосиланняНаІншийЗапис, Пошук, Сума, ОдноРядковийТекст, Вкладення, Валюта, Формула, тощо |
||||
- ⚡ Контроль доступу за ролями: Деталізований контроль доступу на різних рівнях |
||||
- ⚡ і більше ... |
||||
|
||||
### Широкий вибір застосунків для автоматизації робочих процесів |
||||
|
||||
Ми надаємо різні інтеграції у трьох основних категоріях. Деталі дивіться у <a href="https://docs.nocodb.com/setup-and-usages/account-settings#app-store" target="_blank">App Store</a>. |
||||
|
||||
- ⚡ Чат: Slack, Discord, Mattermost, тощо |
||||
- ⚡ Електронна пошта: AWS SES, SMTP, MailerSend, тощо |
||||
- ⚡ Сховище: AWS S3, Google Cloud Storage, Minio, тощо |
||||
|
||||
### API доступ |
||||
|
||||
Ми надаємо різні способи, якими користувачі можуть програмно викликати дії. Ви можете використовувати токен (або JWT, або соціальний авторизаційний токен) для підписання ваших запитань для авторизації в NocoDB. |
||||
|
||||
- ⚡ REST API |
||||
- ⚡ NocoDB SDK |
||||
|
||||
### Синхронізація схеми |
||||
|
||||
Ми дозволяємо вам синхронізувати зміни схеми, якщо ви внесли зміни поза NocoDB GUI. Проте слід зауважити, що вам доведеться мати власні міграції схеми для переміщення з одного середовища в інше. Деталі дивіться у <a href="https://docs.nocodb.com/setup-and-usages/sync-schema/" target="_blank">Sync Schema</a>. |
||||
|
||||
### Аудит |
||||
|
||||
Ми зберігаємо всі журнали операцій користувача в одному місці. Деталі дивіться у <a href="https://docs.nocodb.com/setup-and-usages/audit" target="_blank">Audit</a>. |
||||
|
||||
# Налаштування продукції |
||||
|
||||
За замовчуванням використовується SQLite для зберігання метаданих. Однак ви можете вказати свою базу даних. Параметри підключення до цієї бази даних можна вказати в змінній середовища `NC_DB`. Крім того, ми також надаємо наступні змінні середовища для налаштувань. |
||||
|
||||
## Змінні середовища |
||||
|
||||
Будь ласка, звертайтеся до [Змінні середовища](https://docs.nocodb.com/getting-started/environment-variables) |
||||
|
||||
# Налаштування розробки |
||||
|
||||
Будь ласка, перегляньте всю необхідну інформацію тут [Development Setup](https://docs.nocodb.com/engineering/development-setup) |
||||
|
||||
# Вклад у проект |
||||
|
||||
Будь ласка, перегляньте всю необхідну інформацію тут [Contribution Guide](https://github.com/nocodb/nocodb/blob/master/.github/CONTRIBUTING.md). |
||||
|
||||
# Чому ми створюємо цей проект? |
||||
|
||||
Більшість інтернет-бізнесів використовують електронні таблиці, або бази даних для вирішення своїх бізнес-потреб. Електронні таблиці використовуються мільярдами людей кожен день. Однак ми далекі від роботи на подібних швидкостях у роботі з базами даних, які є набагато потужнішими інструментами, коли мова йде про обчислення. Спроби вирішити це за допомогою пропозицій SaaS приводить до жахливого контролю доступу, в'язницю від постачальників хмарних сервісів, в'язницю даних, раптові зміни цін та, що найважливіше, скляну стелю щодо того, що можливо у майбутньому. |
||||
|
||||
# Наша місія |
||||
|
||||
Наша місія - надати найпотужніший no-code інтерфейс для баз даних, код яких є відкритим для кожного інтернет-бізнесу в світі. Це не лише демократизує доступ до потужного інструмента для обчислень, але також приведе до того, що мільярди людей матимуть неймовірні можливості до експериментів та створення проектів в інтернеті. |
||||
|
||||
# Ліцензія |
||||
|
||||
<p> |
||||
Цей проект ліцензується за <a href="./LICENSE">AGPLv3</a>. |
||||
</p> |
||||
|
||||
# Співтовариші |
||||
|
||||
Дякуємо за ваші внески! Ми вдячні за всі внески від спільноти. |
||||
|
||||
<a href="https://github.com/nocodb/nocodb/graphs/contributors"> |
||||
<img src="https://contrib.rocks/image?repo=nocodb/nocodb" /> |
||||
</a> |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 77 KiB |
After Width: | Height: | Size: 743 KiB |
After Width: | Height: | Size: 64 KiB |
After Width: | Height: | Size: 122 KiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 161 KiB |
After Width: | Height: | Size: 367 B |
After Width: | Height: | Size: 379 B |
After Width: | Height: | Size: 222 B |
Before Width: | Height: | Size: 643 B After Width: | Height: | Size: 658 B |
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 634 B After Width: | Height: | Size: 634 B |
After Width: | Height: | Size: 790 B |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 707 B |
After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 637 B After Width: | Height: | Size: 646 B |
@ -0,0 +1,61 @@
|
||||
<script setup lang="ts"> |
||||
import type { CommandPaletteType } from '~/lib' |
||||
|
||||
defineProps<{ |
||||
activeCmd: CommandPaletteType |
||||
setActiveCmdView: (cmd: CommandPaletteType) => void |
||||
}>() |
||||
|
||||
const renderCmdOrCtrlKey = () => { |
||||
return isMac() ? '⌘' : 'Ctrl' |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="cmdk-footer absolute inset-x-0 bottom-0 !bg-white"> |
||||
<div class="flex justify-center w-full py-2"> |
||||
<div |
||||
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer" |
||||
:class="activeCmd === 'cmd-j' ? 'text-brand-500' : ''" |
||||
@click.stop="activeCmd !== 'cmd-j' ? setActiveCmdView('cmd-j') : () => undefined" |
||||
> |
||||
<MdiFileOutline class="h-4 w-4" /> |
||||
Document |
||||
<span |
||||
class="text-sm px-1 rounded-md border-1" |
||||
:class="activeCmd === 'cmd-j' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'" |
||||
> |
||||
{{ renderCmdOrCtrlKey() }} + J |
||||
</span> |
||||
</div> |
||||
<div |
||||
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer" |
||||
:class="activeCmd === 'cmd-k' ? 'text-brand-500' : ''" |
||||
@click.stop="activeCmd !== 'cmd-k' ? setActiveCmdView('cmd-k') : () => undefined" |
||||
> |
||||
<MdiMapMarkerOutline class="h-4 w-4" /> |
||||
Quick Navigation |
||||
<span |
||||
class="text-sm px-1 rounded-md border-1" |
||||
:class="activeCmd === 'cmd-k' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'" |
||||
> |
||||
{{ renderCmdOrCtrlKey() }} + K |
||||
</span> |
||||
</div> |
||||
<div |
||||
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer" |
||||
:class="activeCmd === 'cmd-l' ? 'text-brand-500' : ''" |
||||
@click.stop="activeCmd !== 'cmd-l' ? setActiveCmdView('cmd-l') : () => undefined" |
||||
> |
||||
<MdiClockOutline class="h-4 w-4" /> |
||||
Recent |
||||
<span |
||||
class="text-sm px-1 rounded-md border-1" |
||||
:class="activeCmd === 'cmd-l' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'" |
||||
> |
||||
{{ renderCmdOrCtrlKey() }} + L |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
@ -0,0 +1,37 @@
|
||||
<script setup lang="ts"> |
||||
import '~/assets/js/typesense-docsearch' |
||||
declare const docsearch: any |
||||
|
||||
const modalEl = ref<HTMLElement | null>(null) |
||||
const { user } = useGlobal() |
||||
|
||||
watch(user, () => { |
||||
window.doc_enabled = !!user.value |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
docsearch({ |
||||
container: '#searchbar', |
||||
typesenseCollectionName: 'nocodb-oss-docs-index', |
||||
typesenseServerConfig: { |
||||
nodes: [ |
||||
{ |
||||
host: 'rqf5uvajyeczwt3xp-1.a1.typesense.net', |
||||
port: 443, |
||||
protocol: 'https', |
||||
}, |
||||
], |
||||
apiKey: 'lNKDTZdJrE76Sg8WEyeN9mXT29l1xq7Q', |
||||
}, |
||||
typesenseSearchParameters: { |
||||
// Optional. |
||||
}, |
||||
}) |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div id="searchbar" :ref="modalEl" class="hidden"></div> |
||||
</template> |
||||
|
||||
<style></style> |
@ -0,0 +1,162 @@
|
||||
// this is derived from https://github.com/pacocoursey/cmdk
|
||||
|
||||
// The scores are arranged so that a continuous match of characters will
|
||||
// result in a total score of 1.
|
||||
//
|
||||
// The best case, this character is a match, and either this is the start
|
||||
// of the string, or the previous character was also a match.
|
||||
const SCORE_CONTINUE_MATCH = 1.9 |
||||
// A new match at the start of a word scores better than a new match
|
||||
// elsewhere as it's more likely that the user will type the starts
|
||||
// of fragments.
|
||||
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
|
||||
// hyphens, etc.
|
||||
const SCORE_SPACE_WORD_JUMP = 1.0 |
||||
const SCORE_NON_SPACE_WORD_JUMP = 0.8 |
||||
// Any other match isn't ideal, but we include it for completeness.
|
||||
const SCORE_CHARACTER_JUMP = 0.2 |
||||
// If the user transposed two letters, it should be significantly penalized.
|
||||
//
|
||||
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
|
||||
const SCORE_TRANSPOSITION = 0.3 |
||||
// The goodness of a match should decay slightly with each missing
|
||||
// character.
|
||||
//
|
||||
// i.e. "bad" is more likely than "bard" when "bd" is typed.
|
||||
//
|
||||
// This will not change the order of suggestions based on SCORE_* until
|
||||
// 100 characters are inserted between matches.
|
||||
const PENALTY_SKIPPED = 0.999 |
||||
// The goodness of an exact-case match should be higher than a
|
||||
// case-insensitive match by a small amount.
|
||||
//
|
||||
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
|
||||
//
|
||||
// This will not change the order of suggestions based on SCORE_* until
|
||||
// 1000 characters are inserted between matches.
|
||||
const PENALTY_CASE_MISMATCH = 0.999999 |
||||
// Match higher for letters closer to the beginning of the word
|
||||
// const PENALTY_DISTANCE_FROM_START = 0.9
|
||||
// If the word has more characters than the user typed, it should
|
||||
// be penalised slightly.
|
||||
//
|
||||
// i.e. "html" is more likely than "html5" if I type "html".
|
||||
//
|
||||
// However, it may well be the case that there's a sensible secondary
|
||||
// ordering (like alphabetical) that it makes sense to rely on when
|
||||
// there are many prefix matches, so we don't make the penalty increase
|
||||
// with the number of tokens.
|
||||
const PENALTY_NOT_COMPLETE = 0.98 |
||||
|
||||
const IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/ |
||||
const COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g |
||||
const IS_SPACE_REGEXP = /[\s-]/ |
||||
const COUNT_SPACE_REGEXP = /[\s-]/g |
||||
|
||||
function commandScoreInner( |
||||
string: string, |
||||
abbreviation: string, |
||||
lowerString: string, |
||||
lowerAbbreviation: string, |
||||
stringIndex: number, |
||||
abbreviationIndex: number, |
||||
memoizedResults: { [x: string]: number }, |
||||
) { |
||||
if (abbreviationIndex === abbreviation.length) { |
||||
if (stringIndex === string.length) { |
||||
return SCORE_CONTINUE_MATCH |
||||
} |
||||
return PENALTY_NOT_COMPLETE |
||||
} |
||||
|
||||
const memoizeKey = `${stringIndex},${abbreviationIndex}` |
||||
if (memoizedResults[memoizeKey] !== undefined) { |
||||
return memoizedResults[memoizeKey] |
||||
} |
||||
|
||||
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex) |
||||
let index = lowerString.indexOf(abbreviationChar, stringIndex) |
||||
let highScore = 0 |
||||
|
||||
let score, transposedScore, wordBreaks, spaceBreaks |
||||
|
||||
while (index >= 0) { |
||||
score = commandScoreInner( |
||||
string, |
||||
abbreviation, |
||||
lowerString, |
||||
lowerAbbreviation, |
||||
index + 1, |
||||
abbreviationIndex + 1, |
||||
memoizedResults, |
||||
) |
||||
if (score > highScore) { |
||||
if (index === stringIndex) { |
||||
score *= SCORE_CONTINUE_MATCH |
||||
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { |
||||
score *= SCORE_NON_SPACE_WORD_JUMP |
||||
wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP) |
||||
if (wordBreaks && stringIndex > 0) { |
||||
score *= PENALTY_SKIPPED ** wordBreaks.length |
||||
} |
||||
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { |
||||
score *= SCORE_SPACE_WORD_JUMP |
||||
spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP) |
||||
if (spaceBreaks && stringIndex > 0) { |
||||
score *= PENALTY_SKIPPED ** spaceBreaks.length |
||||
} |
||||
} else { |
||||
score *= SCORE_CHARACTER_JUMP |
||||
if (stringIndex > 0) { |
||||
score *= PENALTY_SKIPPED ** (index - stringIndex) |
||||
} |
||||
} |
||||
|
||||
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { |
||||
score *= PENALTY_CASE_MISMATCH |
||||
} |
||||
} |
||||
|
||||
if ( |
||||
(score < SCORE_TRANSPOSITION && lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) || |
||||
(lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
|
||||
lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) |
||||
) { |
||||
transposedScore = commandScoreInner( |
||||
string, |
||||
abbreviation, |
||||
lowerString, |
||||
lowerAbbreviation, |
||||
index + 1, |
||||
abbreviationIndex + 2, |
||||
memoizedResults, |
||||
) |
||||
|
||||
if (transposedScore * SCORE_TRANSPOSITION > score) { |
||||
score = transposedScore * SCORE_TRANSPOSITION |
||||
} |
||||
} |
||||
|
||||
if (score > highScore) { |
||||
highScore = score |
||||
} |
||||
|
||||
index = lowerString.indexOf(abbreviationChar, index + 1) |
||||
} |
||||
|
||||
memoizedResults[memoizeKey] = highScore |
||||
return highScore |
||||
} |
||||
|
||||
function formatInput(string: string) { |
||||
// convert all valid space characters to space so they match each other
|
||||
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ') |
||||
} |
||||
|
||||
export function commandScore(string: string, abbreviation: string): number { |
||||
/* NOTE: |
||||
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() |
||||
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. |
||||
*/ |
||||
return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {}) |
||||
} |
@ -1,3 +1,623 @@
|
||||
<script lang="ts" setup> |
||||
import { useMagicKeys, whenever } from '@vueuse/core' |
||||
// import { useNuxtApp } from '#app' |
||||
import { commandScore } from './command-score' |
||||
import type { ComputedRef, VNode } from '#imports' |
||||
import { iconMap, onClickOutside } from '#imports' |
||||
import type { CommandPaletteType } from '~/lib' |
||||
|
||||
interface CmdAction { |
||||
id: string |
||||
title: string |
||||
hotkey?: string |
||||
parent?: string |
||||
handler?: Function |
||||
scopePayload?: any |
||||
icon?: VNode | string |
||||
keywords?: string[] |
||||
section?: string |
||||
is_default?: number | null |
||||
} |
||||
|
||||
const props = defineProps<{ |
||||
open: boolean |
||||
data: CmdAction[] |
||||
scope?: string |
||||
placeholder?: string |
||||
hotkey?: string |
||||
loadTemporaryScope?: (scope: { scope: string; data: any }) => void |
||||
setActiveCmdView: (cmd: CommandPaletteType) => void |
||||
}>() |
||||
|
||||
const emits = defineEmits(['update:open', 'scope']) |
||||
|
||||
const vOpen = useVModel(props, 'open', emits) |
||||
|
||||
const { t } = useI18n() |
||||
|
||||
const activeScope = ref('root') |
||||
|
||||
const modalEl = ref<HTMLElement>() |
||||
|
||||
const cmdInputEl = ref<HTMLInputElement>() |
||||
|
||||
const cmdInput = ref('') |
||||
|
||||
const { user } = useGlobal() |
||||
|
||||
const selected = ref<string>() |
||||
|
||||
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => { |
||||
const rt: (CmdAction & { weight: number })[] = [] |
||||
for (const el of props.data) { |
||||
rt.push({ |
||||
...el, |
||||
title: el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title, |
||||
icon: el.section === 'Views' && el.is_default ? 'grid' : el.icon, |
||||
parent: el.parent || 'root', |
||||
weight: commandScore( |
||||
`${el.section}${el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title}${el.keywords?.join()}`, |
||||
cmdInput.value, |
||||
), |
||||
}) |
||||
} |
||||
return rt |
||||
}) |
||||
|
||||
const nestedScope = computed(() => { |
||||
const rt = [] |
||||
let parent = activeScope.value |
||||
while (parent !== 'root') { |
||||
const parentEl = formattedData.value.find((el) => el.id === parent) |
||||
rt.push({ |
||||
id: parent, |
||||
label: parentEl?.title, |
||||
icon: parentEl?.icon, |
||||
}) |
||||
parent = parentEl?.parent || 'root' |
||||
} |
||||
return rt.reverse() |
||||
}) |
||||
|
||||
const isThereAnyActionInScope = (sc: string): boolean => { |
||||
return formattedData.value.some((el) => { |
||||
if (el.parent === sc) { |
||||
if (!el.handler) { |
||||
return isThereAnyActionInScope(el.id) |
||||
} |
||||
return true |
||||
} |
||||
return false |
||||
}) |
||||
} |
||||
|
||||
const getAvailableScopes = (sc: string) => { |
||||
const tempChildScopes = formattedData.value.filter((el) => el.parent === sc && !el.handler).map((el) => el.id) |
||||
for (const el of tempChildScopes) { |
||||
tempChildScopes.push(...getAvailableScopes(el)) |
||||
} |
||||
return tempChildScopes |
||||
} |
||||
|
||||
const activeScopes = computed(() => { |
||||
return getAvailableScopes(activeScope.value) |
||||
}) |
||||
|
||||
const actionList = computed(() => { |
||||
const sections = formattedData.value.filter((el) => el.section).map((el) => el.section) |
||||
formattedData.value.sort((a, b) => { |
||||
if (a.section && b.section) { |
||||
if (sections.indexOf(a.section) < sections.indexOf(b.section)) return -1 |
||||
if (sections.indexOf(a.section) > sections.indexOf(b.section)) return 1 |
||||
return 0 |
||||
} |
||||
if (a.section) return 1 |
||||
if (b.section) return -1 |
||||
return 0 |
||||
}) |
||||
return formattedData.value.filter((el) => { |
||||
if (cmdInput.value === '') { |
||||
if (el.parent === activeScope.value) { |
||||
if (!el.handler) { |
||||
return isThereAnyActionInScope(el.id) |
||||
} |
||||
return true |
||||
} |
||||
return false |
||||
} else { |
||||
if (el.parent === activeScope.value || activeScopes.value.includes(el.parent || 'root')) { |
||||
if (!el.handler) { |
||||
return isThereAnyActionInScope(el.id) |
||||
} |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
}) |
||||
}) |
||||
|
||||
const searchedActionList = computed(() => { |
||||
if (cmdInput.value === '') return actionList.value |
||||
actionList.value.sort((a, b) => { |
||||
if (a.weight > b.weight) return -1 |
||||
if (a.weight < b.weight) return 1 |
||||
return 0 |
||||
}) |
||||
return actionList.value |
||||
.filter((el) => el.weight > 0) |
||||
.sort((a, b) => b.section?.toLowerCase().localeCompare(a.section?.toLowerCase() as string) || 0) |
||||
}) |
||||
|
||||
const actionListGroupedBySection = computed(() => { |
||||
const rt: { [key: string]: CmdAction[] } = {} |
||||
searchedActionList.value.forEach((el) => { |
||||
if (el.section === 'hidden') return |
||||
if (el.section) { |
||||
if (!rt[el.section]) rt[el.section] = [] |
||||
rt[el.section].push(el) |
||||
} else { |
||||
if (!rt.default) rt.default = [] |
||||
rt.default.push(el) |
||||
} |
||||
}) |
||||
return rt |
||||
}) |
||||
|
||||
const keys = useMagicKeys() |
||||
|
||||
const setAction = (action: string) => { |
||||
selected.value = action |
||||
nextTick(() => { |
||||
const actionIndex = searchedActionList.value.findIndex((el) => el.id === action) |
||||
if (actionIndex === -1) return |
||||
if (actionIndex === 0) { |
||||
document.querySelector('.cmdk-actions')?.scrollTo({ top: 0, behavior: 'smooth' }) |
||||
} else if (actionIndex === searchedActionList.value.length - 1) { |
||||
document.querySelector('.cmdk-actions')?.scrollTo({ top: 999999, behavior: 'smooth' }) |
||||
} else { |
||||
document.querySelector('.cmdk-action.selected')?.scrollIntoView({ |
||||
behavior: 'smooth', |
||||
block: 'nearest', |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
const selectFirstAction = () => { |
||||
if (searchedActionList.value.length > 0) { |
||||
setAction(searchedActionList.value[0].id) |
||||
} else { |
||||
selected.value = undefined |
||||
} |
||||
} |
||||
|
||||
const setScope = (scope: string) => { |
||||
activeScope.value = scope |
||||
|
||||
emits('scope', scope) |
||||
|
||||
nextTick(() => { |
||||
cmdInputEl.value?.focus() |
||||
selectFirstAction() |
||||
}) |
||||
} |
||||
|
||||
const show = () => { |
||||
if (!user.value) return |
||||
if (props.scope === 'disabled') return |
||||
vOpen.value = true |
||||
cmdInput.value = '' |
||||
nextTick(() => { |
||||
setScope(props.scope || 'root') |
||||
}) |
||||
} |
||||
|
||||
const hide = () => { |
||||
vOpen.value = false |
||||
} |
||||
|
||||
const fireAction = (action: CmdAction, preview = false) => { |
||||
if (preview) { |
||||
if (action?.scopePayload) { |
||||
setScope(action.scopePayload.scope) |
||||
if (action.scopePayload.data) { |
||||
props.loadTemporaryScope?.(action.scopePayload) |
||||
} |
||||
return |
||||
} |
||||
} |
||||
if (action?.handler) { |
||||
action.handler() |
||||
hide() |
||||
} else { |
||||
setScope(action.id) |
||||
} |
||||
} |
||||
|
||||
whenever(keys.ctrl_k, () => { |
||||
show() |
||||
}) |
||||
|
||||
whenever(keys.meta_k, () => { |
||||
show() |
||||
}) |
||||
|
||||
whenever(keys.Escape, () => { |
||||
if (vOpen.value) hide() |
||||
}) |
||||
|
||||
whenever(keys.ctrl_l, () => { |
||||
if (vOpen.value) hide() |
||||
}) |
||||
|
||||
whenever(keys.meta_l, () => { |
||||
if (vOpen.value) hide() |
||||
}) |
||||
|
||||
whenever(keys.ctrl_j, () => { |
||||
if (vOpen.value) hide() |
||||
}) |
||||
|
||||
whenever(keys.meta_j, () => { |
||||
if (vOpen.value) hide() |
||||
}) |
||||
|
||||
whenever(keys.arrowup, () => { |
||||
if (vOpen.value) { |
||||
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value) |
||||
if (idx > 0) { |
||||
setAction(searchedActionList.value[idx - 1].id) |
||||
} else if (idx === 0) { |
||||
setAction(searchedActionList.value[searchedActionList.value.length - 1].id) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
whenever(keys.arrowdown, () => { |
||||
if (vOpen.value) { |
||||
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value) |
||||
if (idx < searchedActionList.value.length - 1) { |
||||
setAction(searchedActionList.value[idx + 1].id) |
||||
} else if (idx === searchedActionList.value.length - 1) { |
||||
setAction(searchedActionList.value[0].id) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
whenever(keys.Enter, () => { |
||||
if (vOpen.value) { |
||||
const selectedEl = formattedData.value.find((el) => el.id === selected.value) |
||||
cmdInput.value = '' |
||||
if (selectedEl) { |
||||
fireAction(selectedEl, keys.shift.value) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
whenever(keys.Backspace, () => { |
||||
if (vOpen.value && cmdInput.value === '' && activeScope.value !== 'root') { |
||||
const activeEl = formattedData.value.find((el) => el.id === activeScope.value) |
||||
setScope(activeEl?.parent || 'root') |
||||
} |
||||
}) |
||||
|
||||
onClickOutside(modalEl, () => { |
||||
if (vOpen.value) hide() |
||||
}) |
||||
|
||||
defineExpose({ |
||||
open: show, |
||||
close: hide, |
||||
setScope, |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div /> |
||||
<div v-show="vOpen" class="cmdk-modal" :class="{ 'cmdk-modal-active': vOpen }"> |
||||
<div ref="modalEl" class="cmdk-modal-content h-[25.25rem]"> |
||||
<div class="cmdk-header"> |
||||
<div class="cmdk-input-wrapper"> |
||||
<GeneralIcon icon="search" class="h-4 w-4 text-gray-500" /> |
||||
<div |
||||
v-for="el of nestedScope" |
||||
:key="`cmdk-breadcrumb-${el.id}`" |
||||
v-e="['a:cmdk:setScope']" |
||||
class="text-gray-600 text-sm cursor-pointer flex gap-1 items-center font-medium capitalize" |
||||
@click="setScope(el.id)" |
||||
> |
||||
<component |
||||
:is="(iconMap as any)[el.icon]" |
||||
v-if="el.icon && typeof el.icon === 'string' && (iconMap as any)[el.icon]" |
||||
class="cmdk-action-icon" |
||||
:class="{ |
||||
'!text-blue-500': el.icon === 'grid', |
||||
'!text-purple-500': el.icon === 'form', |
||||
'!text-[#FF9052]': el.icon === 'kanban', |
||||
'!text-pink-500': el.icon === 'gallery', |
||||
}" |
||||
/> |
||||
<div v-else-if="el.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center"> |
||||
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="el.icon" readonly /> |
||||
</div> |
||||
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||
<template #title> |
||||
{{ el.label }} |
||||
</template> |
||||
<span class="truncate capitalize mr-4"> |
||||
{{ el.label }} |
||||
</span> |
||||
</a-tooltip> |
||||
|
||||
<span class="text-gray-400 text-sm font-medium pl-1">/</span> |
||||
</div> |
||||
<input |
||||
ref="cmdInputEl" |
||||
v-model="cmdInput" |
||||
class="cmdk-input" |
||||
type="text" |
||||
:placeholder="props.placeholder" |
||||
@input="selectFirstAction" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div class="cmdk-body"> |
||||
<div class="cmdk-actions nc-scrollbar-md"> |
||||
<div v-if="searchedActionList.length === 0"> |
||||
<div class="cmdk-action"> |
||||
<div class="cmdk-action-content">No action found.</div> |
||||
</div> |
||||
</div> |
||||
<template v-else> |
||||
<div |
||||
v-for="[title, section] of Object.entries(actionListGroupedBySection)" |
||||
:key="`cmdk-section-${title}`" |
||||
class="cmdk-action-section border-t-1 border-gray-200" |
||||
> |
||||
<div v-if="title !== 'default'" class="cmdk-action-section-header capitalize">{{ title }}</div> |
||||
<div class="cmdk-action-section-body"> |
||||
<div |
||||
v-for="act of section" |
||||
:key="act.id" |
||||
v-e="['a:cmdk:action']" |
||||
class="cmdk-action group" |
||||
:class="{ selected: selected === act.id }" |
||||
@mouseenter="setAction(act.id)" |
||||
@click="fireAction(act)" |
||||
> |
||||
<div class="cmdk-action-content w-full"> |
||||
<component |
||||
:is="(iconMap as any)[act.icon]" |
||||
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]" |
||||
class="cmdk-action-icon" |
||||
:class="{ |
||||
'!text-blue-500': act.icon === 'grid', |
||||
'!text-purple-500': act.icon === 'form', |
||||
'!text-[#FF9052]': act.icon === 'kanban', |
||||
'!text-pink-500': act.icon === 'gallery', |
||||
}" |
||||
/> |
||||
<div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center"> |
||||
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="act.icon" readonly /> |
||||
</div> |
||||
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||
<template #title> |
||||
{{ act.title }} |
||||
</template> |
||||
<span class="truncate capitalize mr-4 py-0.5"> |
||||
{{ act.title }} |
||||
</span> |
||||
</a-tooltip> |
||||
<div |
||||
class="bg-gray-200 text-gray-600 cmdk-keyboard hidden text-xs gap-2 p-0.5 items-center justify-center rounded-md ml-auto pl-2" |
||||
> |
||||
Enter |
||||
<div |
||||
class="bg-white border-1 items-center flex justify-center border-gray-300 text-gray-700 rounded h-5 w-5 px-0.25" |
||||
> |
||||
↩ |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
</div> |
||||
</div> |
||||
<CmdFooter active-cmd="cmd-k" :set-active-cmd-view="setActiveCmdView" /> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss"> |
||||
/* TODO Move styles to Windi Classes */ |
||||
:root { |
||||
--cmdk-secondary-background-color: rgb(230, 230, 230); |
||||
--cmdk-secondary-text-color: rgb(101, 105, 111); |
||||
--cmdk-selected-background: rgb(245, 245, 245); |
||||
|
||||
--cmdk-icon-color: var(--cmdk-secondary-text-color); |
||||
--cmdk-icon-size: 1.2em; |
||||
|
||||
--cmdk-modal-background: #fff; |
||||
} |
||||
|
||||
.cmdk-modal { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
width: 100%; |
||||
height: 100%; |
||||
background-color: rgba(255, 255, 255, 0.5); |
||||
z-index: 1000; |
||||
|
||||
color: rgb(60, 65, 73); |
||||
font-size: 16px; |
||||
|
||||
.cmdk-key { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
width: auto; |
||||
height: 1em; |
||||
font-size: 1.25em; |
||||
border-radius: 0.25em; |
||||
background: var(--cmdk-secondary-background-color); |
||||
color: var(--cmdk-secondary-text-color); |
||||
margin-right: 0.2em; |
||||
} |
||||
|
||||
.cmdk-modal-content { |
||||
position: relative; |
||||
display: flex; |
||||
flex-direction: column; |
||||
flex-shrink: 1; |
||||
-webkit-box-flex: 1; |
||||
flex-grow: 1; |
||||
padding: 0; |
||||
overflow: hidden; |
||||
margin: auto; |
||||
box-shadow: rgb(0 0 0 / 50%) 0px 16px 70px; |
||||
top: 20%; |
||||
border-radius: 16px; |
||||
max-width: 640px; |
||||
background: var(--cmdk-modal-background); |
||||
} |
||||
|
||||
.cmdk-input-wrapper { |
||||
@apply py-2 px-4 flex items-center gap-2; |
||||
} |
||||
|
||||
.cmdk-input { |
||||
@apply text-sm; |
||||
flex-grow: 1; |
||||
flex-shrink: 0; |
||||
margin: 0px; |
||||
border: none; |
||||
appearance: none; |
||||
background: transparent; |
||||
outline: none; |
||||
box-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important; |
||||
|
||||
caret-color: pink; |
||||
color: rgb(60, 65, 73); |
||||
} |
||||
|
||||
.cmdk-input::placeholder { |
||||
color: rgb(165, 165, 165); |
||||
opacity: 1; |
||||
} |
||||
|
||||
.cmdk-input:-ms-input-placeholder { |
||||
color: rgb(165, 165, 165); |
||||
} |
||||
|
||||
.cmdk-input::-ms-input-placeholder { |
||||
color: rgb(165, 165, 165); |
||||
} |
||||
|
||||
.cmdk-actions { |
||||
max-height: 310px; |
||||
margin: 0px; |
||||
padding: 0.5em 0px; |
||||
list-style: none; |
||||
scroll-behavior: smooth; |
||||
overflow: auto; |
||||
position: relative; |
||||
|
||||
--scrollbar-track: initial; |
||||
--scrollbar-thumb: initial; |
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); |
||||
overflow: overlay; |
||||
scrollbar-width: auto; |
||||
scrollbar-width: thin; |
||||
--scrollbar-thumb: #e1e3e6; |
||||
--scrollbar-track: #fff; |
||||
|
||||
.cmdk-action { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: flex-start; |
||||
justify-content: center; |
||||
outline: none; |
||||
transition: color 0s ease 0s; |
||||
width: 100%; |
||||
font-size: 0.9em; |
||||
border-left: 4px solid transparent; |
||||
|
||||
.cmdk-keyboard { |
||||
display: hidden; |
||||
} |
||||
|
||||
&.selected { |
||||
cursor: pointer; |
||||
background-color: #f4f4f5; |
||||
border-left: 4px solid var(--ant-primary-color); |
||||
outline: none; |
||||
|
||||
.cmdk-keyboard { |
||||
display: flex; |
||||
} |
||||
} |
||||
|
||||
.cmdk-action-content { |
||||
display: flex; |
||||
align-items: center; |
||||
flex-shrink: 0.01; |
||||
flex-grow: 1; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
padding: 0.75em 1em; |
||||
width: 100%; |
||||
} |
||||
|
||||
.cmdk-action-icon { |
||||
margin-right: 0.4em; |
||||
} |
||||
} |
||||
|
||||
.cmdk-action-section { |
||||
display: flex; |
||||
flex-direction: column; |
||||
width: 100%; |
||||
|
||||
.cmdk-action-section-header { |
||||
display: flex; |
||||
align-items: center; |
||||
padding: 8px 16px; |
||||
font-size: 14px; |
||||
color: #6a7184; |
||||
} |
||||
} |
||||
} |
||||
|
||||
.cmdk-footer { |
||||
display: flex; |
||||
border-top: 1px solid rgb(230, 230, 230); |
||||
background: rgba(242, 242, 242, 0.4); |
||||
font-size: 0.8em; |
||||
padding: 0.3em 0.6em; |
||||
color: var(--cmdk-secondary-text-color); |
||||
.cmdk-footer-left { |
||||
display: flex; |
||||
flex-grow: 1; |
||||
align-items: center; |
||||
.cmdk-key-helper { |
||||
display: flex; |
||||
align-items: center; |
||||
margin-right: 1.5em; |
||||
} |
||||
} |
||||
.cmdk-footer-right { |
||||
display: flex; |
||||
flex-grow: 1; |
||||
justify-content: flex-end; |
||||
align-items: center; |
||||
.nc-brand-icon { |
||||
margin-left: 0.5em; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
</style> |
||||
|
@ -0,0 +1,315 @@
|
||||
<script setup lang="ts"> |
||||
import { onKeyUp, useDebounceFn, useVModel } from '@vueuse/core' |
||||
import { iconMap, onClickOutside } from '#imports' |
||||
import type { CommandPaletteType } from '~/lib' |
||||
|
||||
const props = defineProps<{ |
||||
open: boolean |
||||
setActiveCmdView: (cmd: CommandPaletteType) => void |
||||
}>() |
||||
|
||||
const emits = defineEmits(['update:open']) |
||||
|
||||
const vOpen = useVModel(props, 'open', emits) |
||||
|
||||
const search = ref('') |
||||
|
||||
const modalEl = ref<HTMLElement>() |
||||
|
||||
const cmdInputEl = ref<HTMLInputElement>() |
||||
|
||||
const { user } = useGlobal() |
||||
|
||||
const viewStore = useViewsStore() |
||||
|
||||
const { recentViews, activeView } = storeToRefs(viewStore) |
||||
|
||||
const selected: Ref<string> = ref('') |
||||
|
||||
const newView = ref< |
||||
| { |
||||
viewId: string | null |
||||
tableId: string |
||||
baseId: string |
||||
} |
||||
| undefined |
||||
>() |
||||
|
||||
const filteredViews = computed(() => { |
||||
if (!recentViews.value) return [] |
||||
const filtered = recentViews.value.filter((v) => { |
||||
if (search.value === '') return true |
||||
return v.viewName.toLowerCase().includes(search.value.toLowerCase()) |
||||
}) |
||||
if (filtered[0]) { |
||||
selected.value = filtered[0]?.tableID + filtered[0]?.viewName |
||||
} |
||||
return filtered |
||||
}) |
||||
|
||||
const changeView = useDebounceFn( |
||||
async ({ viewId, tableId, baseId }: { viewId: string | null; tableId: string; baseId: string }) => { |
||||
await viewStore.changeView({ viewId, tableId, baseId }) |
||||
vOpen.value = false |
||||
}, |
||||
200, |
||||
) |
||||
|
||||
onKeyUp('Enter', async () => { |
||||
if (vOpen.value && newView.value) { |
||||
search.value = '' |
||||
await changeView({ viewId: newView.value.viewId, tableId: newView.value.tableId, baseId: newView.value.baseId }) |
||||
} |
||||
}) |
||||
|
||||
function scrollToTarget() { |
||||
const element = document.querySelector('.cmdk-action.selected') |
||||
element?.scrollIntoView() |
||||
} |
||||
|
||||
const moveUp = () => { |
||||
if (!filteredViews.value.length) return |
||||
const index = filteredViews.value.findIndex((v) => v.tableID + v.viewName === selected.value) |
||||
if (index === 0) { |
||||
selected.value = |
||||
filteredViews.value[filteredViews.value.length - 1].tableID + filteredViews.value[filteredViews.value.length - 1].viewName |
||||
|
||||
const cmdOption = filteredViews.value[filteredViews.value.length - 1] |
||||
newView.value = { |
||||
viewId: cmdOption.viewId ?? null, |
||||
tableId: cmdOption.tableID, |
||||
baseId: cmdOption.baseId, |
||||
} |
||||
document.querySelector('.actions')?.scrollTo({ top: 99999, behavior: 'smooth' }) |
||||
} else { |
||||
selected.value = filteredViews.value[index - 1].tableID + filteredViews.value[index - 1].viewName |
||||
const cmdOption = filteredViews.value[index - 1] |
||||
|
||||
newView.value = { |
||||
viewId: cmdOption.viewId ?? null, |
||||
tableId: cmdOption.tableID, |
||||
baseId: cmdOption.baseId, |
||||
} |
||||
nextTick(() => scrollToTarget()) |
||||
} |
||||
} |
||||
|
||||
const moveDown = () => { |
||||
if (!filteredViews.value.length) return |
||||
const index = filteredViews.value.findIndex((v) => v.tableID + v.viewName === selected.value) |
||||
if (index === filteredViews.value.length - 1) { |
||||
selected.value = filteredViews.value[0].tableID + filteredViews.value[0].viewName |
||||
|
||||
const cmdOption = filteredViews.value[0] |
||||
newView.value = { |
||||
viewId: cmdOption.viewId ?? null, |
||||
tableId: cmdOption.tableID, |
||||
baseId: cmdOption.baseId, |
||||
} |
||||
document.querySelector('.actions')?.scrollTo({ top: 0, behavior: 'smooth' }) |
||||
} else { |
||||
selected.value = filteredViews.value[index + 1].tableID + filteredViews.value[index + 1].viewName |
||||
const cmdOption = filteredViews.value[index + 1] |
||||
|
||||
newView.value = { |
||||
viewId: cmdOption.viewId ?? null, |
||||
tableId: cmdOption.tableID, |
||||
baseId: cmdOption.baseId, |
||||
} |
||||
nextTick(() => scrollToTarget()) |
||||
} |
||||
} |
||||
|
||||
const hide = () => { |
||||
vOpen.value = false |
||||
search.value = '' |
||||
} |
||||
|
||||
onClickOutside(modalEl, () => { |
||||
hide() |
||||
}) |
||||
|
||||
useEventListener('keydown', (e: KeyboardEvent) => { |
||||
if (e.key === 'Escape') { |
||||
hide() |
||||
} else if (e.key === 'Enter') { |
||||
if (newView.value) { |
||||
changeView({ viewId: newView.value.viewId, tableId: newView.value.tableId, baseId: newView.value.baseId }) |
||||
} |
||||
} else if (e.key === 'ArrowUp') { |
||||
if (!vOpen.value) return |
||||
e.preventDefault() |
||||
moveUp() |
||||
} else if (e.key === 'ArrowDown') { |
||||
if (!vOpen.value) return |
||||
e.preventDefault() |
||||
moveDown() |
||||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') { |
||||
if (!user.value) return |
||||
if (!vOpen.value) { |
||||
vOpen.value = true |
||||
} else { |
||||
moveUp() |
||||
} |
||||
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'l') { |
||||
if (!user.value) return |
||||
if (!vOpen.value) { |
||||
vOpen.value = true |
||||
} else moveDown() |
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { |
||||
hide() |
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') { |
||||
hide() |
||||
} else if (vOpen.value) { |
||||
cmdInputEl.value?.focus() |
||||
} |
||||
}) |
||||
|
||||
onMounted(() => { |
||||
document.querySelector('.cmdOpt-list')?.focus() |
||||
if (!activeView.value || !filteredViews.value.length) return |
||||
const index = filteredViews.value.findIndex( |
||||
(v) => v.viewName === activeView.value?.title && v.tableID === activeView.value?.fk_model_id, |
||||
) |
||||
if (index + 1 > filteredViews.value.length) { |
||||
selected.value = filteredViews.value[0].tableID + filteredViews.value[0].viewName |
||||
} else { |
||||
if (!filteredViews.value[index + 1]) return |
||||
selected.value = filteredViews.value[index + 1].tableID + filteredViews.value[index + 1].viewName |
||||
} |
||||
}) |
||||
</script> |
||||
|
||||
<template> |
||||
<div v-if="vOpen" class="cmdk-modal cmdl-modal" :class="{ 'cmdk-modal-active cmdl-modal-active': vOpen }"> |
||||
<div ref="modalEl" class="cmdk-modal-content cmdl-modal-content relative h-[25.25rem]"> |
||||
<div class="cmdk-input-wrapper"> |
||||
<GeneralIcon class="h-4 w-4 text-gray-500" icon="search" /> |
||||
<input ref="cmdInputEl" v-model="search" class="cmdk-input" placeholder="Search" type="text" /> |
||||
</div> |
||||
<div class="flex items-center bg-white w-full z-[50]"> |
||||
<div class="text-sm px-4 py-2 text-gray-500">Recent Views</div> |
||||
</div> |
||||
<div class="flex flex-col shrink grow overflow-hidden shadow-[rgb(0_0_0_/_50%)_0px_16px_70px] max-w-[650px] p-0"> |
||||
<div class="scroll-smooth actions overflow-auto nc-scrollbar-md mb-10 relative mx-0 px-0 py-2"> |
||||
<div v-if="filteredViews.length < 1" class="flex flex-col p-4 items-start justify-center text-md">No recent views</div> |
||||
<div v-else class="flex flex-col cmdOpt-list w-full"> |
||||
<div |
||||
v-for="cmdOption of filteredViews" |
||||
:key="cmdOption.tableID + cmdOption.viewName" |
||||
v-e="['a:cmdL:changeView']" |
||||
:class="{ |
||||
selected: selected === cmdOption.tableID + cmdOption.viewName, |
||||
}" |
||||
class="cmdk-action" |
||||
@click="changeView({ viewId: cmdOption.viewId!, tableId: cmdOption.tableID, baseId: cmdOption.baseId })" |
||||
> |
||||
<div class="cmdk-action-content"> |
||||
<div class="flex w-1/2 items-center"> |
||||
<div class="flex gap-2"> |
||||
<GeneralViewIcon :meta="{ type: cmdOption.viewType }" class="mt-0.5" /> |
||||
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||
<template #title> |
||||
{{ cmdOption.viewName }} |
||||
</template> |
||||
<span class="truncate max-w-56 capitalize"> |
||||
{{ cmdOption.viewName }} |
||||
</span> |
||||
</a-tooltip> |
||||
</div> |
||||
</div> |
||||
<div class="flex w-1/2 justify-end text-gray-600"> |
||||
<div class="flex gap-2 px-2 py-1 rounded-md items-center"> |
||||
<component :is="iconMap.projectGray" class="w-3 h-3 text-transparent" /> |
||||
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||
<template #title> |
||||
{{ cmdOption.baseName }} |
||||
</template> |
||||
<span class="max-w-32 text-xs truncate capitalize"> |
||||
{{ cmdOption.baseName }} |
||||
</span> |
||||
</a-tooltip> |
||||
<span class="text-bold"> / </span> |
||||
|
||||
<component :is="iconMap.table" class="w-3 h-3 text-transparent" /> |
||||
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg"> |
||||
<template #title> |
||||
{{ cmdOption.tableName }} |
||||
</template> |
||||
<span class="max-w-28 text-xs truncate capitalize"> |
||||
{{ cmdOption.tableName }} |
||||
</span> |
||||
</a-tooltip> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<CmdFooter active-cmd="cmd-l" :set-active-cmd-view="setActiveCmdView" /> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style lang="scss"> |
||||
/* TODO Move styles to Windi Classes */ |
||||
|
||||
:root { |
||||
--cmdk-secondary-background-color: rgb(230, 230, 230); |
||||
--cmdk-secondary-text-color: rgb(101, 105, 111); |
||||
--cmdk-selected-background: rgb(245, 245, 245); |
||||
|
||||
--cmdk-icon-color: var(--cmdk-secondary-text-color); |
||||
--cmdk-icon-size: 1.2em; |
||||
|
||||
--cmdk-modal-background: #fff; |
||||
} |
||||
.cmdk-modal { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
width: 100%; |
||||
height: 100%; |
||||
background-color: rgba(255, 255, 255, 0.5); |
||||
z-index: 1000; |
||||
|
||||
color: rgb(60, 65, 73); |
||||
font-size: 16px; |
||||
|
||||
.cmdk-action { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: flex-start; |
||||
justify-content: center; |
||||
outline: none; |
||||
transition: color 0s ease 0s; |
||||
width: 100%; |
||||
font-size: 0.9em; |
||||
border-left: 4px solid transparent; |
||||
|
||||
&.selected { |
||||
cursor: pointer; |
||||
background-color: rgb(248, 249, 251); |
||||
border-left: 4px solid #36f; |
||||
outline: none; |
||||
} |
||||
|
||||
.cmdk-action-content { |
||||
display: flex; |
||||
align-items: center; |
||||
flex-shrink: 0.01; |
||||
flex-grow: 1; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
padding: 0.75em 1em; |
||||
width: 640px; |
||||
} |
||||
|
||||
.cmdk-action-icon { |
||||
margin-right: 0.4em; |
||||
} |
||||
} |
||||
} |
||||
</style> |
@ -0,0 +1,3 @@
|
||||
<template> |
||||
<span></span> |
||||
</template> |
@ -1 +1,26 @@
|
||||
<template><span /></template> |
||||
<script setup lang="ts"> |
||||
const { commandPalette } = useCommandPalette() |
||||
</script> |
||||
|
||||
<template> |
||||
<NcButton |
||||
v-e="['c:quick-actions']" |
||||
type="text" |
||||
size="small" |
||||
class="nc-sidebar-top-button w-full !hover:bg-gray-200 !rounded-md !xs:hidden" |
||||
data-testid="nc-sidebar-search-btn" |
||||
:centered="false" |
||||
@click="commandPalette?.open()" |
||||
> |
||||
<div class="flex items-center gap-2"> |
||||
<MaterialSymbolsSearch class="!h-3.9" /> |
||||
Quick Actions |
||||
<div |
||||
class="inline-flex gap-1 justify-center text-xs px-[8px] py-[1px] uppercase border-1 border-gray-300 rounded-md bg-slate-150 text-gray-500" |
||||
> |
||||
<kbd class="text-[16px] mt-[0.5px]">⌘</kbd> |
||||
<kbd class="!leading-4">K</kbd> |
||||
</div> |
||||
</div> |
||||
</NcButton> |
||||
</template> |
||||
|