diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30eb8461..ed856f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,6 @@ jobs: - name: Build and run dev container task uses: devcontainers/ci@v0.3 with: - imageName: ghcr.io/ruby-ui/web-devcontainer - cacheFrom: ghcr.io/ruby-ui/web-devcontainer + imageName: ghcr.io/seth-ruby-ui/web-devcontainer + cacheFrom: ghcr.io/seth-ruby-ui/web-devcontainer push: always diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..08f2d4e3 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,67 @@ +name: Deploy with Kamal + +on: + push: + branches: + - main + workflow_dispatch: # Allow manual trigger + +jobs: + deploy: + runs-on: ubuntu-latest + + # Only deploy if tests pass + needs: [] + + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0.1' + bundler-cache: true + + - name: Install Kamal + run: gem install kamal + + - name: Set up Docker Buildx for cache + uses: docker/setup-buildx-action@v3 + + - name: Expose GitHub Runtime for cache + uses: crazy-max/ghaction-github-runtime@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.KAMAL_REGISTRY_USERNAME }} + password: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} + + - name: Set up SSH connection + run: | + mkdir -p ~/.ssh && echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa + eval $(ssh-agent -s) && ssh-add ~/.ssh/id_rsa + ssh-keyscan 45.55.81.54 >> ~/.ssh/known_hosts + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Create Kamal secrets file + run: | + mkdir -p .kamal + cat << EOF > .kamal/secrets + RAILS_MASTER_KEY=${{ secrets.RAILS_MASTER_KEY }} + KAMAL_REGISTRY_USERNAME=${{ secrets.KAMAL_REGISTRY_USERNAME }} + KAMAL_REGISTRY_PASSWORD=${{ secrets.KAMAL_REGISTRY_PASSWORD }} + EOF + + - name: Deploy with Kamal + run: bin/kamal deploy + env: + RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} + KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }} + KAMAL_REGISTRY_USERNAME: ${{ secrets.KAMAL_REGISTRY_USERNAME }} diff --git a/.gitignore b/.gitignore index 011574f2..05050541 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ yarn-error.log # Pnpm .pnpm-store + +# Ignore key files for decrypting credentials and more. +/config/credentials/*.key + +.env diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 00000000..2fb07d7d --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 00000000..70f9c4bc --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 00000000..fd364c2a --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 00000000..1435a677 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 00000000..45f73550 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 00000000..c5a55678 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 00000000..77744bdc --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 00000000..05b3055b --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 00000000..061f8059 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 00000000..4eaef68b --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,20 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Rails master key +RAILS_MASTER_KEY=$(cat config/credentials/production.key) + +# Registry credentials +# These should be set in your .env file or environment +# KAMAL_REGISTRY_USERNAME and KAMAL_REGISTRY_PASSWORD are read from ENV +KAMAL_REGISTRY_USERNAME=$KAMAL_REGISTRY_USERNAME +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Option 3: Read secrets via kamal secrets helpers +# These will handle logging in and fetching the secrets in as few calls as possible +# There are adapters for 1Password, LastPass + Bitwarden +# +# SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS) diff --git a/.ruby-version b/.ruby-version index 2aa51319..1454f6ed 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.7 +4.0.1 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..42ed5b37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Seth Ruby UI is a UI Component Library built on Phlex. It provides a set of reusable components for building web applications. + +## Development Commands + +```bash +# Initial setup +bin/setup # Install dependencies and setup database + +# Development server +bin/dev # Start development server with Overmind (includes Rails server, asset watching) +bin/rails server # Standard Rails server only + +# Database +bin/rails db:prepare # Setup database (creates, migrates, seeds) +bin/rails db:migrate # Run migrations +bin/rails db:seed # Seed database + +# Testing +bin/rails test # Run test suite (Minitest) +bin/rails test:system # Run system tests (Capybara + Selenium) + +# Code quality +bin/rubocop # Run RuboCop linter (configured in .rubocop.yml) +bin/rubocop -a # Auto-fix RuboCop issues + +# Background jobs +bin/jobs # Start SolidQueue worker (if using SolidQueue) +bundle exec sidekiq # Start Sidekiq worker (if using Sidekiq) +``` + +## Architecture + +## Technology Stack + +- **Rails 8** with Hotwire (Turbo + Stimulus) and Hotwire Native +- **Import Maps** for JavaScript (no Node.js dependency) +- **TailwindCSS v4** via tailwindcss-rails gem +- **Devise** for authentication with custom extensions +- **Minitest** for testing with parallel execution + +## Testing + +- **Minitest** with fixtures in `test/fixtures/` +- **System tests** use Capybara with Selenium WebDriver +- **Test parallelization** enabled via `parallelize(workers: :number_of_processors)` +- **WebMock** configured to disable external HTTP requests +- **Test database** reset between runs + +## Important Conventions + +### Do NOT +- Commit, push, or create PRs unless explicitly directed +- Use hash syntax for enums (use positional arguments) +- Skip tenant scoping for multi-tenant models + +### Git Operations - ALWAYS Confirm First +Before executing any permanent git or GitHub operation, **always ask for user confirmation**. This includes: +- `git commit` - Show the commit message and files to be committed +- `git push` - Show what will be pushed and to which remote/branch +- `gh pr create` - Show the PR title, description, and target branch +- `git reset --hard`, `git rebase`, or any destructive operations + +**Never run these commands without explicit user approval**, even if the user asked to "commit and push" or "create a PR". Always show what will happen first and wait for confirmation. + +### Do +- Use fixtures for test data +- Add YARD documentation to public methods +- Make UI responsive and dark-mode compatible +- Use Hotwire (Turbo + Stimulus) for JavaScript functionality +- Use the ruby ui components and app components instead of creating new ones. diff --git a/Dockerfile b/Dockerfile index c887a669..fe59b4a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,40 +1,44 @@ # syntax=docker/dockerfile:1 # check=error=true +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t jumpstart . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name jumpstart jumpstart + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=3.4.7 -FROM quay.io/evl.ms/fullstaq-ruby:${RUBY_VERSION}-jemalloc-slim AS base +ARG RUBY_VERSION=4.0.1 +ARG GIT_COMMIT_HASH=unknown +FROM ruby:${RUBY_VERSION} AS base LABEL fly_launch_runtime="rails" # Rails app lives here WORKDIR /rails -# Update gems and bundler -RUN gem update --system --no-document && \ - gem install -N bundler - # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl sqlite3 && \ + apt-get install --no-install-recommends -y curl libvips sqlite3 postgresql-client && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment -ENV BUNDLE_DEPLOYMENT="1" \ +ARG GIT_COMMIT_HASH +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development:test" \ - RAILS_ENV="production" - + BUNDLE_WITHOUT="development" \ + GIT_COMMIT_HASH="${GIT_COMMIT_HASH}" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems and node modules RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev node-gyp pkg-config python-is-python3 imagemagick libvips libvips-dev libvips-tools poppler-utils && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Install Node.js +# Install JavaScript dependencies ARG NODE_VERSION=20.10.0 ARG PNPM_VERSION=10.8.0 ENV PATH=/usr/local/node/bin:$PATH @@ -44,14 +48,16 @@ RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz rm -rf /tmp/node-build-master # Install application gems -COPY Gemfile Gemfile.lock ./ +COPY Gemfile Gemfile.lock ./.ruby-version vendor ./ +COPY ruby_ui.gemspec ./ +COPY lib ./lib RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile # Install node modules -COPY package.json ./ -RUN pnpm install +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile # Copy application code COPY . . @@ -66,25 +72,18 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile # Final stage for app image FROM base - -# Copy built artifacts: gems, application -COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" -COPY --from=build /rails /rails - # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ - useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ - mkdir /data && \ - chown -R 1000:1000 db log storage tmp /data + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash USER 1000:1000 -# Deployment options -ENV DATABASE_URL="sqlite3:///data/production.sqlite3" +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] -# Start the server by default, this can be overwritten at runtime -EXPOSE 3000 -VOLUME /data -CMD ["./bin/rails", "server"] +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile index 17257075..9cee3c99 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby "3.4.7" +ruby "4.0.1" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "8.1.2" @@ -34,11 +34,14 @@ gem "lucide-rails", "0.7.3" # gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem -gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] +gem "tzinfo-data", platforms: %i[windows jruby] # Reduces boot times through caching; required in config/boot.rb gem "bootsnap", require: false +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + # Use Sass to process CSS # gem "sassc-rails" @@ -49,7 +52,7 @@ gem "bootsnap", require: false group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem - gem "debug", platforms: %i[mri mingw x64_mingw] + gem "debug", platforms: %i[mri windows] end group :development do @@ -73,11 +76,13 @@ end gem "phlex", github: "phlex-ruby/phlex" gem "phlex-rails", github: "phlex-ruby/phlex-rails" -gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false -# gem "ruby_ui", path: "../ruby_ui" +# gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false +gem "seth_ruby_ui", path: "." gem "pry", "0.16.0" gem "tailwind_merge", "~> 1.3.2" -gem "rouge", "~> 4.7" +gem "rouge", "~> 4.6" + +gem "kamal", github: "basecamp/kamal", branch: "main" diff --git a/Gemfile.lock b/Gemfile.lock index 15cde4d8..975422bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,20 @@ +GIT + remote: https://github.com/basecamp/kamal.git + revision: 9c6252d0358e4a828400826f2d6d13d329a4b671 + branch: main + specs: + kamal (2.10.1) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + GIT remote: https://github.com/phlex-ruby/phlex-rails.git revision: 2a0078767af4feea43e0772620e1d88529dc4d1a @@ -12,12 +29,13 @@ GIT specs: phlex (2.0.2) -GIT - remote: https://github.com/ruby-ui/ruby_ui.git - revision: f88242965bcb682a51223b07fa9019cb6e3ddbf6 - branch: main +PATH + remote: . specs: - ruby_ui (1.1.0) + seth_ruby_ui (0.1.0) + phlex (>= 2.0) + rouge + tailwind_merge (>= 0.12) GEM remote: https://rubygems.org/ @@ -100,6 +118,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.23.0) @@ -124,7 +143,9 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + dotenv (3.2.0) drb (2.2.3) + ed25519 (1.4.0) erb (6.0.1) erubi (1.13.1) globalid (1.3.0) @@ -139,7 +160,7 @@ GEM reline (>= 0.4.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.18.1) + json (2.19.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -159,7 +180,8 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) net-imap (0.6.2) @@ -169,14 +191,20 @@ GEM net-protocol net-protocol (0.2.2) timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) net-smtp (0.5.1) net-protocol + net-ssh (7.3.0) nio4r (2.7.5) nokogiri (1.19.0) mini_portile2 (~> 2.8.2) racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.1) ast (~> 2.4.1) @@ -280,6 +308,13 @@ GEM sqlite3 (2.9.0) mini_portile2 (~> 2.8.0) sqlite3 (2.9.0-x86_64-linux-gnu) + sshkit (1.25.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct standard (1.54.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -298,6 +333,8 @@ GEM tailwind_merge (1.3.2) sin_lru_redux (~> 2.5) thor (1.5.0) + thruster (0.1.19) + thruster (0.1.19-x86_64-linux) timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.20) @@ -322,7 +359,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS ruby @@ -334,6 +371,7 @@ DEPENDENCIES cssbundling-rails (= 1.4.3) debug jsbundling-rails (= 1.3.1) + kamal! lucide-rails (= 0.7.3) phlex! phlex-rails! @@ -341,19 +379,20 @@ DEPENDENCIES pry (= 0.16.0) puma (= 7.2.0) rails (= 8.1.2) - rouge (~> 4.7) - ruby_ui! + rouge (~> 4.6) selenium-webdriver + seth_ruby_ui! sqlite3 (= 2.9.0) standard stimulus-rails (= 1.3.4) tailwind_merge (~> 1.3.2) + thruster turbo-rails (= 2.0.20) tzinfo-data web-console RUBY VERSION - ruby 3.4.7p58 + ruby 4.0.1p0 BUNDLED WITH - 2.6.4 + 4.0.7 diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index ed1f1b64..199dfac4 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -49,6 +49,15 @@ --warning-foreground: hsl(0 0% 100%); --success: hsl(87 100% 37%); --success-foreground: hsl(0 0% 100%); + + /* code colors */ + --code: oklch(0.97 0 0); + --code-foreground: oklch(0.145 0 0); + --code-number: oklch(0.556 0 0); + + /* spacing and text */ + --spacing: 0.25rem; + --text-sm: 0.875rem; } .dark { @@ -90,6 +99,11 @@ --warning-foreground: hsl(0 0% 100%); --success: hsl(84 81% 44%); --success-foreground: hsl(0 0% 100%); + + /* code colors */ + --code: oklch(0.205 0 0); + --code-foreground: oklch(0.985 0 0); + --code-number: oklch(0.708 0 0); } @theme inline { @@ -135,6 +149,9 @@ --color-warning-foreground: var(--warning-foreground); --color-success: var(--success); --color-success-foreground: var(--success-foreground); + --color-code: var(--code); + --color-code-foreground: var(--code-foreground); + --color-code-number: var(--code-number); } /* Container settings */ @@ -152,3 +169,28 @@ @apply bg-background text-foreground; } } + +/* Shiki line numbers */ +code[data-line-numbers] { + counter-reset: line; + font-size: var(--text-sm); +} + +[data-line-numbers] [data-line]::before { + font-size: var(--text-sm); + counter-increment: line; + content: counter(line); + width: calc(var(--spacing) * 16); + padding-right: calc(var(--spacing) * 6); + text-align: right; + color: var(--color-code-number); + background-color: var(--color-code); + display: inline-block; + position: sticky; + left: 0; +} + +[data-line-numbers] [data-line] { + display: inline-block; + width: 100%; +} diff --git a/app/blocks/sidebar_01.rb b/app/blocks/sidebar_01.rb new file mode 100644 index 00000000..906686ad --- /dev/null +++ b/app/blocks/sidebar_01.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +# class Blocks::Sidebar01 < RubyUI::Block +class Blocks::Sidebar01 < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon() + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon() + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon() + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: { strategy: "fixed", placement: "right-start" }) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon() + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: '#') { "Profile" } + DropdownMenuItem(href: '#') { "Billing" } + DropdownMenuItem(href: '#') { "Team" } + DropdownMenuItem(href: '#') { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon() + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: '#') { "Profile" } + DropdownMenuItem(href: '#') { "Billing" } + DropdownMenuItem(href: '#') { "Team" } + DropdownMenuItem(href: '#') { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/blocks/sidebar_02.rb b/app/blocks/sidebar_02.rb new file mode 100644 index 00000000..24907358 --- /dev/null +++ b/app/blocks/sidebar_02.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +# class Blocks::Sidebar01 < RubyUI::Block +class Blocks::Sidebar02 < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def view_template + SidebarWrapper do + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: {strategy: "fixed", placement: "right-start"}) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/blocks/sidebar_02/app_sidebar.rb b/app/blocks/sidebar_02/app_sidebar.rb new file mode 100644 index 00000000..6b5e339f --- /dev/null +++ b/app/blocks/sidebar_02/app_sidebar.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +class Blocks::Sidebar02::AppSidebar < Views::Base + def view_template + Sidebar(collapsible: :icon) do + SidebarHeader do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + Blocks::Sidebar02::Index::FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu(options: {strategy: "fixed", placement: "right-start"}) do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + Blocks::Sidebar02::Index::WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + DropdownMenu() do + SidebarMenuAction( + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) do + ellipsis_icon + span(class: "sr-only") { "More" } + end + DropdownMenuContent do + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Team" } + DropdownMenuItem(href: "#") { "Subscription" } + end + end + end + end + end + end + SidebarGroup(class: "mt-auto") do + SidebarGroupContent do + SidebarMenu do + nav_secondary.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + item[:icon].call + span { item[:label] } + end + end + end + end + end + end + end + SidebarRail() + end + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def message_circle_question + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_line_join: "round", + class: "lucide lucide-message-circle-question-icon lucide-message-circle-question" + ) do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end +end diff --git a/app/blocks/sidebar_02/index.rb b/app/blocks/sidebar_02/index.rb new file mode 100644 index 00000000..b6efbe53 --- /dev/null +++ b/app/blocks/sidebar_02/index.rb @@ -0,0 +1,203 @@ +class Blocks::Sidebar02::Index < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "📊"}, + {name: "Movies & TV Shows", emoji: "🎬"}, + {name: "Books & Articles", emoji: "📚"}, + {name: "Recipes & Meal Planning", emoji: "🍽️"}, + {name: "Travel & Places", emoji: "🌍"}, + {name: "Health & Fitness", emoji: "🏋️"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "🏡"}, + {name: "Work & Projects", emoji: "💼"}, + {name: "Side Projects", emoji: "🚀"}, + {name: "Learning & Courses", emoji: "📚"}, + {name: "Writing & Blogging", emoji: "📝"}, + {name: "Design & Development", emoji: "🎨"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + render Blocks::Sidebar02::AppSidebar.new + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + Separator(orientation: :vertical, class: "mr-2 h-4") + Breadcrumb do + BreadcrumbList do + BreadcrumbItem(class: "hidden md:block") do + BreadcrumbLink(href: "#") { "Building Your Application" } + end + BreadcrumbSeparator(class: "hidden md:block") + BreadcrumbItem do + BreadcrumbPage { "Data Fetching" } + end + end + end + end + div(class: "flex flex-1 flex-col gap-4 p-4") do + div(class: "grid auto-rows-min gap-4 md:grid-cols-3") do + div(class: "aspect-video rounded-xl bg-muted/50") + div(class: "aspect-video rounded-xl bg-muted/50") + div(class: "aspect-video rounded-xl bg-muted/50") + end + div(class: "min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def nav_secondary + [ + {label: "Settings", icon: -> { settings_icon }}, + {label: "Help & Support", icon: -> { message_circle_question }} + ] + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def calendar_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-calendar" + ) do |s| + s.path(d: "M8 2v4") + s.path(d: "M16 2v4") + s.rect(width: "18", height: "18", x: "3", y: "4", rx: "2") + s.path(d: "M3 10h18") + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def settings_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-settings" + ) do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73 + l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def plus_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-plus" + ) do |s| + s.path(d: "M5 12h14") + s.path(d: "M12 5v14") + end + end + + def gallery_vertical_end + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "lucide lucide-gallery-vertical-end size-4") do |s| + s.path d: "M7 2h10" + s.path d: "M5 6h14" + s.rect width: "18", height: "12", x: "3", y: "10", rx: "2" + end + end + + def ellipsis_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-ellipsis-icon lucide-ellipsis") do |s| + s.circle cx: "12", cy: "12", r: "1" + s.circle cx: "19", cy: "12", r: "1" + s.circle cx: "5", cy: "12", r: "1" + end + end + + def message_circle_question + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_line_join: "round", class: "lucide lucide-message-circle-question-icon lucide-message-circle-question") do |s| + s.path d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" + s.path d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" + s.path d: "M12 17h.01" + end + end +end diff --git a/app/components/block_display.rb b/app/components/block_display.rb new file mode 100644 index 00000000..43e21794 --- /dev/null +++ b/app/components/block_display.rb @@ -0,0 +1,443 @@ +class Components::BlockDisplay < Components::Base + def initialize(content:, description: nil) + @description = description + @content = content + @files = extract_files_from_block + end + + def view_template + div( + class: "group/block-view-wrapper", + data: { + controller: "custom-tabs block-code-viewer", + tab: "preview", + action: "tab-change->custom-tabs#setTab" + } + ) do + tool_bar + block_viewer_view + block_viewer_code + end + end + + def tool_bar + div(class: "hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex") do + Tabs(default: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + Separator(orientation: :vertical) + + render_header + end + end + end + end + + def render_tool_bar + div(class: "hidden w-full items-center gap-2 pl-2 md:pr-6 lg:flex") do + Tabs(default: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + end + end + end + end + + def block_viewer_view + div(class: "hidden group-data-[tab=code]/block-view-wrapper:hidden md:h-(--height) lg:flex") do + div(class: "relative grid w-full gap-4") do + div(class: "absolute inset-0 right-4 [background-image:radial-gradient(#d4d4d4_1px,transparent_1px)] [background-size:20px_20px] dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]") + render_preview_tab + end + end + end + + def block_viewer_code + div(class: "bg-code text-code-foreground mr-[14px] flex overflow-hidden rounded-xl border group-data-[tab=preview]/block-view-wrapper:hidden h-[600px]") do + render_file_tree + render_code_content + end + end + + def render_file_tree + div(class: "w-72") do + div( + data_slot: "sidebar-wrapper", + style: "--sidebar-width:16rem;--sidebar-width-icon:3rem", + class: "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar min-h-svh w-full flex !min-h-full flex-col border-r" + ) do + div( + data_slot: "sidebar", + class: "bg-sidebar text-sidebar-foreground flex h-full flex-col w-full flex-1" + ) do + SidebarGroupLabel(data_slot: "sidebar-group-label", class: "h-12 rounded-none border-b px-4 text-sm") { "Files" } + SidebarGroup(data_slot: "sidebar-group", class: "p-0") do + SidebarGroupContent(data_slot: "sidebar-group-content") do + SidebarMenu(data_slot: "sidebar-menu", class: "translate-x-0 gap-1.5") do + render_file_tree_items + end + end + end + end + end + end + end + + def render_file_tree_items + grouped_files = group_files_by_directory + grouped_files.each do |dir, files| + render_directory_item(dir, files) + end + end + + def render_directory_item(dir, files) + depth = dir.split("/").size - 1 + padding_left = "#{1 + (depth * 1.4)}rem" + + SidebarMenuItem(data_slot: "sidebar-menu-item") do + Collapsible( + open: true, + data_slot: "collapsible", + class: "group/collapsible" + ) do + CollapsibleTrigger(data_slot: "collapsible-trigger") do + SidebarMenuButton( + as: :button, + type: "button", + class: "rounded-none pl-(--index) whitespace-nowrap h-8 text-sm hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15", + style: "--index:#{padding_left}" + ) do + chevron_icon(collapsible: true) + folder_icon + span { dir.split("/").last } + end + end + CollapsibleContent(data_slot: "collapsible-content") do + SidebarMenuSub(data_slot: "sidebar-menu-sub", class: "m-0 w-full translate-x-0 border-none p-0") do + files.each_with_index do |file, index| + render_file_item(file, index, depth + 1) + end + end + end + end + end + end + + def render_file_item(file, index, depth) + padding_left = "#{1 + (depth * 1.4) + 0.5}rem" + + SidebarMenuSubItem(data_slot: "sidebar-menu-item") do + SidebarMenuButton( + active: index == 0, + as: :button, + data_slot: "sidebar-menu-button", + class: "rounded-none pl-(--index) whitespace-nowrap h-8 text-sm hover:bg-muted-foreground/15 focus:bg-muted-foreground/15 focus-visible:bg-muted-foreground/15 active:bg-muted-foreground/15 data-[active=true]:bg-muted-foreground/15", + style: "--index:#{padding_left}", + data: { + action: "click->block-code-viewer#selectFile", + file_path: file[:path], + block_code_viewer_target: "fileButton", + index: depth + } + ) do + chevron_icon(invisible: true) + file_icon + span { file[:name] } + end + end + end + + def group_files_by_directory + grouped = {} + @files.each do |file| + dir = File.dirname(file[:path]) + grouped[dir] ||= [] + grouped[dir] << file + end + grouped + end + + def render_code_content + figure(class: "!mx-0 mt-0 flex min-w-0 flex-1 flex-col rounded-xl border-none overflow-hidden") do + render_file_header + render_code_body + end + end + + def render_file_header + @files.each_with_index do |file, index| + figcaption( + class: "text-code-foreground [&_svg]:text-code-foreground relative flex h-12 shrink-0 items-center gap-2 border-b px-4 py-2 [&_svg]:size-4 [&_svg]:opacity-70 #{"hidden" unless index == 0}", + data: { + file_path: file[:path], + block_code_viewer_target: "fileHeader" + } + ) do + file_icon + span(class: "text-sm font-light") { file[:path] } + + # Clipboard button + RubyUI.Clipboard(success: "Copied!", error: "Copy failed!", class: "absolute right-2") do + RubyUI.ClipboardSource do + pre(class: "hidden") { plain file[:code] } + end + RubyUI.ClipboardTrigger do + RubyUI.Button( + variant: :ghost, + size: :icon, + class: "size-7" + ) { clipboard_icon } + end + end + end + end + end + + def render_code_body + @files.each_with_index do |file, index| + div( + class: "overflow-y-auto flex-1 min-h-0 #{"hidden" unless index == 0}", + data: { + file_path: file[:path], + block_code_viewer_target: "fileContent" + } + ) do + render CodeblockWithLineNumbers.new(code: file[:code], syntax: file[:syntax]) + end + end + end + + def render_tab_trigger(value, label, icon_method) + TabsTrigger(value: value) do + icon_method.call + span { label } + end + end + + def render_tab_contents + TabsContent(value: "preview") { render_preview_tab } + TabsContent(value: "code") { render_code_tab } + end + + def render_preview_tab + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1", data: {controller: "iframe-theme"}) do + iframe(src: render_block_path(id: @content.to_s, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + end + end + + def render_code_tab + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + Codeblock("@display_code", syntax: :ruby, class: "-m-px") + end + end + + def render_header + div do + if @title + div do + Components.Heading(level: 4) { @title.capitalize } + end + end + p { @description } if @description + end + end + + def eye_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: + "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" + ) + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" + ) + end + end + + def code_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" + ) + end + end + + def chevron_icon(invisible: false, collapsible: false) + classes = ["lucide", "lucide-chevron-right", "transition-transform", "duration-200"] + classes << "invisible" if invisible + classes << "group-data-[state=open]/collapsible:rotate-90" if collapsible + + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: classes.join(" ") + ) do |s| + s.path(d: "m9 18 6-6-6-6") + end + end + + def folder_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-folder" + ) do |s| + s.path(d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z") + end + end + + def file_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-file h-4 w-4" + ) do |s| + s.path(d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z") + s.path(d: "M14 2v4a2 2 0 0 0 2 2h4") + end + end + + def clipboard_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-clipboard" + ) do |s| + s.rect(width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1") + s.path(d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2") + end + end + + private + + def extract_files_from_block + files = [] + + main_file_path = get_file_path_for_class(@content) + if File.exist?(main_file_path) + content = File.read(main_file_path) + files << { + name: File.basename(main_file_path), + path: relative_path_from_app(main_file_path), + code: content, + syntax: :ruby + } + end + + if @content.to_s.include?("::") + dir_path = File.dirname(main_file_path) + if File.directory?(dir_path) && dir_path != File.dirname(dir_path) + Dir.glob("#{dir_path}/*.rb").sort.each do |file_path| + next if file_path == main_file_path + content = File.read(file_path) + files << { + name: File.basename(file_path), + path: relative_path_from_app(file_path), + code: content, + syntax: :ruby + } + end + end + end + + files + end + + def get_file_path_for_class(klass) + parts = klass.to_s.split("::") + filename = parts.pop.underscore + path_parts = parts.map(&:underscore) + + Rails.root.join("app", *path_parts, "#{filename}.rb").to_s + end + + def relative_path_from_app(absolute_path) + absolute_path.sub(Rails.root.join("app").to_s + "/", "") + end + + # Inner component for rendering code with Shiki highlighting + class CodeblockWithLineNumbers < Components::Base + def initialize(code:, syntax:) + @code = code + @syntax = syntax + end + + def view_template + div( + class: "relative", + data: { + controller: "shiki-highlighter", + shiki_highlighter_language_value: @syntax.to_s + } + ) do + # Hidden code content for Shiki to process + pre( + class: "hidden", + data: {shiki_highlighter_target: "code"} + ) do + plain @code + end + + # Output container for Shiki-generated HTML + div( + class: "bg-code text-code-foreground overflow-y-auto", + data: {shiki_highlighter_target: "output"} + ) + end + end + end +end diff --git a/app/components/block_viewer.rb b/app/components/block_viewer.rb new file mode 100644 index 00000000..d3879ccc --- /dev/null +++ b/app/components/block_viewer.rb @@ -0,0 +1,23 @@ +class Components::BlockViewer < Components::Base + def view_template + render ComponentPreview.new(title: "Example", context: self) do + <<~RUBY + Button(disabled: true) { "Disabled" } + RUBY + end + + render Docs::VisualCodeExample.new(title: "Disabled", context: self) do + <<~RUBY + Button(disabled: true) { "Disabled" } + RUBY + end + + # render ComponentPreview.new( + # title: "Example", + # context: self, + # type: :block, + # content: Blocks::Sidebar02::Index, + # content_attributes: {sidebar_state: "open"} + # ) + end +end diff --git a/app/components/component_preview.rb b/app/components/component_preview.rb new file mode 100644 index 00000000..c2da35fb --- /dev/null +++ b/app/components/component_preview.rb @@ -0,0 +1,24 @@ +module Components + class ComponentPreview < Components::Base + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code + @title = title + @description = description + @src = src + @context = context + @type = type + @content = content + @content_attributes = content_attributes + end + + def view_template(&) + if @type == :block && @content + div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border md:-mx-1", data: {controller: "iframe-theme"}) do + iframe(src: render_block_path(id: @content.to_s, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + end + else + render ComponentPreviewTabs.new(title: @title, description: @description, context: @context, ruby_code: @ruby_code) { capture(&) } + end + end + end +end diff --git a/app/components/component_preview_tabs.rb b/app/components/component_preview_tabs.rb new file mode 100644 index 00000000..230a6877 --- /dev/null +++ b/app/components/component_preview_tabs.rb @@ -0,0 +1,107 @@ +module Components + class ComponentPreviewTabs < Components::Base + def initialize(context:, ruby_code: nil, title: nil, description: nil) + @title = title + @description = description + @context = context + @ruby_code = ruby_code + end + + def view_template(&) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) + div(id: @title) do + div(class: "relative") do + Tabs(default_value: "preview") do + div(class: "flex justify-between items-end mb-4 gap-x-2") do + render_header + TabsList do + render_tab_trigger("preview", "Preview", method(:eye_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) + end + end + render_tab_contents(&) + end + end + end + # render ComponentRender.new(context: @context, ruby_code: @ruby_code) { capture(&) } + end + + def render_header + div do + if @title + div do + Components.Heading(level: 4) { @title.capitalize } + end + end + p { @description } if @description + end + end + + def render_tab_trigger(value, label, icon_method) + TabsTrigger(value: value) do + icon_method.call + span { label } + end + end + + def render_tab_contents(&) + TabsContent(value: "preview") { render_preview_tab(&) } + TabsContent(value: "code") { render_code_tab } + end + + def render_preview_tab(&) + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + div(class: "preview flex min-h-[350px] w-full justify-center p-10 items-center") do + decoded_code = CGI.unescapeHTML(@display_code) + @context.instance_eval(decoded_code) + end + end + end + + def render_code_tab + div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do + Codeblock(@display_code, syntax: :ruby, class: "-m-px") + end + end + + def eye_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: + "M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" + ) + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M15 12a3 3 0 11-6 0 3 3 0 016 0z" + ) + end + end + + def code_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + fill: "none", + viewbox: "0 0 24 24", + stroke_width: "1.5", + stroke: "currentColor", + class: "w-4 h-4 mr-2" + ) do |s| + s.path( + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" + ) + end + end + end +end diff --git a/app/components/component_render.rb b/app/components/component_render.rb new file mode 100644 index 00000000..a2f77b28 --- /dev/null +++ b/app/components/component_render.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Components + class ComponentRender < Components::Base + def initialize(context:, ruby_code: nil) + @ruby_code = ruby_code + @context = context + end + + def view_template(&) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) + decoded_code = CGI.unescapeHTML(@display_code) + @context.instance_eval(decoded_code) + end + # standard:enable Style/ArgumentsForwarding + end +end diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index 9f8f2757..12aabd6b 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -13,15 +13,19 @@ def self.reset_collected_code @@collected_code = [] end - def initialize(title: nil, description: nil, src: nil, context: nil) + def initialize(ruby_code: nil, title: nil, description: nil, src: nil, context: nil, type: :component, content: nil, content_attributes: nil) + @ruby_code = ruby_code @title = title @description = description @src = src @context = context + @type = type + @content = content + @content_attributes = content_attributes end def view_template(&) - @display_code = CGI.unescapeHTML(capture(&)) + @display_code = @ruby_code || CGI.unescapeHTML(capture(&)) @@collected_code << @display_code div(id: @title) do @@ -55,7 +59,7 @@ def render_header def render_tab_triggers TabsList do render_tab_trigger("preview", "Preview", method(:eye_icon)) - render_tab_trigger("code", "Code", method(:code_icon)) + render_tab_trigger("code", "Code", method(:code_icon)) if @type == :component end end @@ -72,15 +76,25 @@ def render_tab_contents(&) end def render_preview_tab(&block) - return iframe_preview if @src + block_class_name = @content.to_s + + return iframe_preview(block_class_name) if @type == :block raw_preview end - def iframe_preview + def iframe_preview(block_name) div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do - iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"}) + if @content + iframe(src: render_block_path(id: block_name, attributes: @content_attributes), class: "size-full", data: {iframe_theme_target: "iframe"}) + else + iframe(srcdoc: safe("
You cannot render a ruby block for a block preview
"), class: "size-full") + # TODO + # decoded_code = CGI.unescapeHTML(@display_code) + # html_content = render_block_to_html(decoded_code) + # iframe(srcdoc: safe(html_content), class: "size-full") + end end end end @@ -100,6 +114,14 @@ def render_code_tab end end + def render_block_to_html(code) + # Extract the component from "render ComponentName.new" pattern + # and evaluate it to generate standalone HTML + # component_code = code.strip.sub(/^render\s+/, "") + # component = eval(component_code) + # component.call + end + def eye_icon svg( xmlns: "http://www.w3.org/2000/svg", diff --git a/app/components/ruby_ui/codeblock/codeblock.rb b/app/components/ruby_ui/codeblock/codeblock.rb index 2e74f152..51f1900f 100644 --- a/app/components/ruby_ui/codeblock/codeblock.rb +++ b/app/components/ruby_ui/codeblock/codeblock.rb @@ -1,12 +1,7 @@ # frozen_string_literal: true -require "rouge" - module RubyUI class Codeblock < Base - FORMATTER = ::Rouge::Formatters::HTML.new - ROUGE_CSS = Rouge::Themes::Github.mode(:dark).render(scope: ".highlight") # See themes here: https://rouge-ruby.github.io/docs/Rouge/CSSTheme.html - def initialize(code, syntax:, clipboard: true, clipboard_success: "Copied!", clipboard_error: "Copy failed!", **attrs) @code = code @syntax = syntax.to_sym @@ -22,7 +17,6 @@ def initialize(code, syntax:, clipboard: true, clipboard_success: "Copied!", cli end def view_template - style { ROUGE_CSS } # For faster load times, move this to the head of your document. (Also move ROUGE_CSS value to head of document) if @clipboard with_clipboard else @@ -35,7 +29,7 @@ def view_template def default_attrs { style: {tab_size: 2}, - class: "highlight text-sm max-h-[350px] after:content-none flex font-mono overflow-auto overflow-x rounded-md border !bg-stone-900 [&_pre]:p-4" + class: "max-h-[350px] font-mono overflow-auto rounded-md border" } end @@ -54,16 +48,30 @@ def with_clipboard def codeblock div(**attrs) do - div(class: "after:content-none") do - pre { raw(safe(FORMATTER.format(lexer.lex(@code)))) } + div( + class: "relative", + data: { + controller: "shiki-highlighter", + shiki_highlighter_language_value: @syntax.to_s + } + ) do + # Hidden code content for Shiki to process + pre( + class: "hidden", + data: {shiki_highlighter_target: "code"} + ) do + plain @code + end + + # Output container for Shiki-generated HTML + div( + class: "overflow-auto", + data: {shiki_highlighter_target: "output"} + ) end end end - def lexer - Rouge::Lexer.find(@syntax) - end - def clipboard_icon svg( xmlns: "http://www.w3.org/2000/svg", diff --git a/app/components/shared/logo.rb b/app/components/shared/logo.rb index a12f7ff2..4e00a9c8 100644 --- a/app/components/shared/logo.rb +++ b/app/components/shared/logo.rb @@ -5,12 +5,22 @@ module Shared class Logo < Components::Base def view_template a(href: root_url, class: "mr-6 flex items-center space-x-2") do - Heading(level: 2, class: "flex items-center pb-0 border-0") { + Heading(level: 2, class: "flex items-center pb-0 border-0") do img(src: image_url("logo.svg"), class: "h-4 block dark:hidden") img(src: image_url("logo_dark.svg"), class: "h-4 hidden dark:block") span(class: "sr-only") { "RubyUI" } - Badge(variant: :amber, size: :sm, class: "ml-2 whitespace-nowrap") { "1.0" } - } + Badge(variant: :amber, size: :sm, class: "ml-2 whitespace-nowrap") { commit_hash } + end + end + end + + private + + def commit_hash + @commit_hash ||= ENV.fetch("GIT_COMMIT_HASH") do + `git rev-parse --short HEAD`.strip + rescue + "unknown" end end end diff --git a/app/components/shared/navbar.rb b/app/components/shared/navbar.rb index aa8f34da..9142b1c5 100644 --- a/app/components/shared/navbar.rb +++ b/app/components/shared/navbar.rb @@ -79,7 +79,7 @@ def twitter_link end def github_link - Link(href: "https://github.com/PhlexUI/phlex_ui", variant: :ghost, icon: true) do + Link(href: "https://github.com/sethhorsley/ruby-ui-web", variant: :ghost, icon: true) do github_icon end end diff --git a/app/controllers/docs/sidebar_controller.rb b/app/controllers/docs/sidebar_controller.rb index d6ffc469..b0521adc 100644 --- a/app/controllers/docs/sidebar_controller.rb +++ b/app/controllers/docs/sidebar_controller.rb @@ -14,4 +14,10 @@ def inset_example render Views::Docs::Sidebar::InsetExample.new(sidebar_state:) end + + def nested_example + sidebar_state = cookies.fetch(:sidebar_state, "true") == "true" + + render Views::Docs::Sidebar::NestedExample.new(sidebar_state:) + end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 6ca7a4c0..8d6886d4 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,9 +1,21 @@ # frozen_string_literal: true class PagesController < ApplicationController - layout -> { Views::Layouts::PagesLayout } + layout false def home render Views::Pages::Home.new end + + def blocks + render Views::Pages::Blocks.new + end + + def render_block + self.class.layout -> { Views::Layouts::ExamplesLayout } + block_class_name = params[:id] + attributes = params[:attributes]&.permit!&.to_h&.symbolize_keys || {} + block_class = block_class_name.constantize + render block_class.new(**attributes) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 14b9df61..d0ff6dac 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,7 +1,7 @@ require "rubygems" module ApplicationHelper - def component_files(component, gem_name = "ruby_ui") + def component_files(component, gem_name = "seth_ruby_ui") # Find the gem specification gem_spec = Gem::Specification.find_by_name(gem_name) return [] unless gem_spec diff --git a/app/javascript/controllers/block_code_viewer_controller.js b/app/javascript/controllers/block_code_viewer_controller.js new file mode 100644 index 00000000..0a3329c9 --- /dev/null +++ b/app/javascript/controllers/block_code_viewer_controller.js @@ -0,0 +1,32 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["fileButton", "fileHeader", "fileContent"] + + selectFile(event) { + const selectedPath = event.currentTarget.dataset.filePath + + // Update button states + this.fileButtonTargets.forEach(button => { + const isSelected = button.dataset.filePath === selectedPath + button.dataset.active = isSelected.toString() + button.classList.toggle("bg-accent", isSelected) + button.classList.toggle("text-accent-foreground", isSelected) + button.classList.toggle("text-muted-foreground", !isSelected) + }) + + // Update file headers + this.fileHeaderTargets.forEach(header => { + const isSelected = header.dataset.filePath === selectedPath + header.classList.toggle("hidden", !isSelected) + }) + + // Update file contents + this.fileContentTargets.forEach(content => { + const isSelected = content.dataset.filePath === selectedPath + content.classList.toggle("hidden", !isSelected) + }) + } +} + + diff --git a/app/javascript/controllers/custom_tabs_controller.js b/app/javascript/controllers/custom_tabs_controller.js new file mode 100644 index 00000000..770ab68c --- /dev/null +++ b/app/javascript/controllers/custom_tabs_controller.js @@ -0,0 +1,9 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="custom-tabs" +export default class extends Controller { + setTab(event) { + this.element.dataset.tab = event.detail.value; + } +} + diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index e92a12d6..badb5f96 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -87,3 +87,14 @@ application.register("ruby-ui--tooltip", RubyUi__TooltipController) import SidebarMenuController from "./sidebar_menu_controller" application.register("sidebar-menu", SidebarMenuController) + +import NestedSidebarController from "./nested_sidebar_controller" +application.register("nested-sidebar", NestedSidebarController) +import CustomTabsController from "./custom_tabs_controller" +application.register("custom-tabs", CustomTabsController) + +import BlockCodeViewerController from "./block_code_viewer_controller" +application.register("block-code-viewer", BlockCodeViewerController) + +import ShikiHighlighterController from "./shiki_highlighter_controller" +application.register("shiki-highlighter", ShikiHighlighterController) diff --git a/app/javascript/controllers/nested_sidebar_controller.js b/app/javascript/controllers/nested_sidebar_controller.js new file mode 100644 index 00000000..593dc4f0 --- /dev/null +++ b/app/javascript/controllers/nested_sidebar_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="nested-sidebar" +// Switches content panels when icon rail buttons are clicked +export default class extends Controller { + static targets = ["icon", "panel"] + static values = { active: String } + + select(event) { + this.activeValue = event.currentTarget.dataset.section + } + + activeValueChanged() { + this.iconTargets.forEach((icon) => { + const isActive = icon.dataset.section === this.activeValue + icon.dataset.active = isActive + }) + + this.panelTargets.forEach((panel) => { + panel.hidden = panel.dataset.section !== this.activeValue + }) + } +} diff --git a/app/javascript/controllers/ruby_ui/collapsible_controller.js b/app/javascript/controllers/ruby_ui/collapsible_controller.js index cb367da3..9d819fe9 100644 --- a/app/javascript/controllers/ruby_ui/collapsible_controller.js +++ b/app/javascript/controllers/ruby_ui/collapsible_controller.js @@ -33,6 +33,7 @@ export default class extends Controller { open() { if (this.hasContentTarget) { this.contentTarget.classList.remove('hidden') + this.element.dataset.state = 'open' this.openValue = true } } @@ -41,6 +42,7 @@ export default class extends Controller { close() { if (this.hasContentTarget) { this.contentTarget.classList.add('hidden') + this.element.dataset.state = 'closed' this.openValue = false } } diff --git a/app/javascript/controllers/ruby_ui/tabs_controller.js b/app/javascript/controllers/ruby_ui/tabs_controller.js index e46d69c4..cf5743bd 100644 --- a/app/javascript/controllers/ruby_ui/tabs_controller.js +++ b/app/javascript/controllers/ruby_ui/tabs_controller.js @@ -29,6 +29,14 @@ export default class extends Controller { this.activeContentTarget() && this.activeContentTarget().classList.remove("hidden"); this.activeTriggerTarget().dataset.state = "active"; + + // Dispatch custom event for external listeners + this.element.dispatchEvent( + new CustomEvent("tab-change", { + detail: { value: currentValue }, + bubbles: true + }) + ); } activeTriggerTarget() { diff --git a/app/javascript/controllers/shiki_highlighter_controller.js b/app/javascript/controllers/shiki_highlighter_controller.js new file mode 100644 index 00000000..c62e83fa --- /dev/null +++ b/app/javascript/controllers/shiki_highlighter_controller.js @@ -0,0 +1,51 @@ +import { Controller } from "@hotwired/stimulus" +import { codeToHtml } from "shiki" + +export default class extends Controller { + static targets = ["code", "output"] + static values = { + language: { type: String, default: "ruby" } + } + + async connect() { + await this.highlightCode() + } + + async highlightCode() { + if (!this.hasCodeTarget || !this.hasOutputTarget) return + + const code = this.codeTarget.textContent + const lang = this.languageValue + + const isDark = document.documentElement.classList.contains('dark') + const theme = isDark ? 'github-dark' : 'github-light' + + try { + const html = await codeToHtml(code, { + lang: lang, + theme: theme, + transformers: [ + { + pre(node) { + node.properties["class"] = "no-scrollbar min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 !bg-transparent" + node.properties["data-line-numbers"] = "" + }, + code(node) { + node.properties["data-line-numbers"] = "" + }, + line(node, line) { + node.properties["data-line"] = line + this.addClassToHast(node, "line") + } + } + ] + }) + + this.outputTarget.innerHTML = html + } catch (error) { + console.error('Shiki highlighting error:', error) + this.outputTarget.textContent = code + } + } +} + diff --git a/app/lib/ruby_ui/file_manager.rb b/app/lib/ruby_ui/file_manager.rb index b1b3d58b..d8152e1e 100644 --- a/app/lib/ruby_ui/file_manager.rb +++ b/app/lib/ruby_ui/file_manager.rb @@ -28,7 +28,7 @@ def dependencies(component_name) end def gem_path - @gem_path ||= Gem::Specification.find_by_name("ruby_ui").gem_dir + @gem_path ||= Gem::Specification.find_by_name("seth_ruby_ui").gem_dir end DEPENDENCIES = YAML.load_file(File.join(gem_path, "lib/generators/ruby_ui/dependencies.yml")).freeze diff --git a/app/views/docs/button.rb b/app/views/docs/button.rb index ee943b12..cc59bb42 100644 --- a/app/views/docs/button.rb +++ b/app/views/docs/button.rb @@ -45,11 +45,9 @@ def view_template RUBY end - render Docs::VisualCodeExample.new(title: "Link", context: self) do - <<~RUBY - Button(variant: :link) { "Link" } - RUBY - end + render Docs::VisualCodeExample.new(ruby_code: <<~RUBY, title: "Link", context: self) + Button(variant: :link) { "Link" } + RUBY render Docs::VisualCodeExample.new(title: "Disabled", context: self) do <<~RUBY diff --git a/app/views/docs/sidebar.rb b/app/views/docs/sidebar.rb index 165872fa..bde247e7 100644 --- a/app/views/docs/sidebar.rb +++ b/app/views/docs/sidebar.rb @@ -29,12 +29,24 @@ def view_template end end - render Docs::VisualCodeExample.new(title: "Example", src: "/docs/sidebar/example", context: self) do - Views::Docs::Sidebar::Example::CODE - end + render Docs::VisualCodeExample.new( + title: "Example", + context: self, + type: :block, + content: Views::Docs::Sidebar::Example, + content_attributes: {sidebar_state: "open"} + ) + + render Docs::VisualCodeExample.new( + title: "Inset variant", + context: self, + type: :block, + content: Views::Docs::Sidebar::InsetExample, + content_attributes: {sidebar_state: "open"} + ) - render Docs::VisualCodeExample.new(title: "Inset variant", src: "/docs/sidebar/inset", context: self) do - Views::Docs::Sidebar::InsetExample::CODE + render Docs::VisualCodeExample.new(title: "Nested sidebar", src: "/docs/sidebar/nested", context: self) do + Views::Docs::Sidebar::NestedExample::CODE end render Docs::VisualCodeExample.new(title: "Dialog variant", context: self) do diff --git a/app/views/docs/sidebar/nested_example.rb b/app/views/docs/sidebar/nested_example.rb new file mode 100644 index 00000000..28b0363a --- /dev/null +++ b/app/views/docs/sidebar/nested_example.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +class Views::Docs::Sidebar::NestedExample < Views::Base + FAVORITES = [ + {name: "Project Management & Task Tracking", emoji: "\u{1F4CA}"}, + {name: "Movies & TV Shows", emoji: "\u{1F3AC}"}, + {name: "Books & Articles", emoji: "\u{1F4DA}"}, + {name: "Recipes & Meal Planning", emoji: "\u{1F37D}\u{FE0F}"}, + {name: "Travel & Places", emoji: "\u{1F30D}"}, + {name: "Health & Fitness", emoji: "\u{1F3CB}\u{FE0F}"} + ].freeze + + WORKSPACES = [ + {name: "Personal Life Management", emoji: "\u{1F3E1}"}, + {name: "Work & Projects", emoji: "\u{1F4BC}"}, + {name: "Side Projects", emoji: "\u{1F680}"}, + {name: "Learning & Courses", emoji: "\u{1F4DA}"}, + {name: "Writing & Blogging", emoji: "\u{270D}\u{FE0F}"}, + {name: "Design & Development", emoji: "\u{1F3A8}"} + ].freeze + + def initialize(sidebar_state:) + @sidebar_state = sidebar_state + end + + CODE = <<~RUBY + SidebarWrapper do + Sidebar(collapsible: :icon, variant: :inset) do + div(class: "flex h-full", data: {controller: "nested-sidebar", nested_sidebar_active_value: "home"}) do + # Icon rail + div(class: "flex flex-col items-center w-12 shrink-0 border-r border-sidebar-border py-4") do + div(class: "flex flex-col items-center gap-1") do + icon_rail_button("home", active: true) { home_icon() } + icon_rail_button("favorites") { star_icon() } + icon_rail_button("workspaces") { briefcase_icon() } + icon_rail_button("settings") { settings_icon() } + end + div(class: "mt-auto") do + DropdownMenu(options: {strategy: "fixed", placement: "right-end"}) do + button( + type: "button", + class: "flex items-center justify-center size-8 shrink-0 rounded-full bg-sidebar-accent text-sidebar-accent-foreground font-semibold text-sm", + data: { + ruby_ui__dropdown_menu_target: "trigger", + action: "click->ruby-ui--dropdown-menu#toggle" + } + ) { "JD" } + DropdownMenuContent(class: "min-w-56 rounded-lg z-50") do + DropdownMenuLabel do + div(class: "flex flex-col") do + span(class: "truncate font-semibold") { "John Doe" } + span(class: "truncate text-xs text-muted-foreground") { "john@example.com" } + end + end + DropdownMenuSeparator() + DropdownMenuItem(href: "#") { "Profile" } + DropdownMenuItem(href: "#") { "Billing" } + DropdownMenuItem(href: "#") { "Settings" } + DropdownMenuSeparator() + DropdownMenuItem(href: "#") { "Sign out" } + end + end + end + end + + # Content panels (one per icon, toggled by nested-sidebar controller) + div(class: "flex-1 flex flex-col min-w-0 group-data-[collapsible=icon]:hidden") do + # Home panel + div(data: {nested_sidebar_target: "panel", section: "home"}) do + SidebarContent do + SidebarGroup do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + search_icon() + span { "Search" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#", active: true) do + home_icon() + span { "Home" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + inbox_icon() + span { "Inbox" } + SidebarMenuBadge { 4 } + end + end + end + end + end + end + + # Favorites panel + div(hidden: true, data: {nested_sidebar_target: "panel", section: "favorites"}) do + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Favorites" } + SidebarMenu do + FAVORITES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + end + end + end + end + end + end + + # Workspaces panel + div(hidden: true, data: {nested_sidebar_target: "panel", section: "workspaces"}) do + SidebarContent do + SidebarGroup do + SidebarGroupLabel { "Workspaces" } + SidebarMenu do + WORKSPACES.each do |item| + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + span { item[:emoji] } + span { item[:name] } + end + end + end + end + end + end + end + + # Settings panel + div(hidden: true, data: {nested_sidebar_target: "panel", section: "settings"}) do + SidebarContent do + SidebarGroup do + SidebarGroupContent do + SidebarMenu do + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + settings_icon() + span { "Settings" } + end + end + SidebarMenuItem do + SidebarMenuButton(as: :a, href: "#") do + message_circle_question_icon() + span { "Help & Support" } + end + end + end + end + end + end + end + end + end + SidebarRail() + end + SidebarInset do + header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do + SidebarTrigger(class: "-ml-1") + end + end + end + RUBY + + def view_template + decoded_code = CGI.unescapeHTML(CODE) + instance_eval(decoded_code) + end + + private + + def icon_rail_button(section, active: false, &block) + classes = [ + "flex items-center justify-center size-8 rounded-md transition-colors [&>svg]:size-4 [&>svg]:shrink-0", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + "data-[active=false]:text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" + ].join(" ") + button( + type: "button", + class: classes, + data: { + nested_sidebar_target: "icon", + section: section, + action: "click->nested-sidebar#select", + active: active + }, + &block + ) + end + + def home_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def star_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a.53.53 0 0 0 .4.29l5.16.756a.53.53 0 0 1 .294.904l-3.733 3.638a.53.53 0 0 0-.153.469l.882 5.14a.53.53 0 0 1-.77.56l-4.614-2.426a.53.53 0 0 0-.494 0L7.14 18.73a.53.53 0 0 1-.77-.56l.882-5.14a.53.53 0 0 0-.153-.47L3.365 8.925a.53.53 0 0 1 .294-.904l5.16-.756a.53.53 0 0 0 .4-.29z") + end + end + + def briefcase_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16") + s.rect(width: "20", height: "14", x: "2", y: "6", rx: "2") + end + end + + def search_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def inbox_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def settings_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z") + s.circle(cx: "12", cy: "12", r: "3") + end + end + + def message_circle_question_icon + svg(xmlns: "http://www.w3.org/2000/svg", width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round") do |s| + s.path(d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z") + s.path(d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3") + s.path(d: "M12 17h.01") + end + end +end diff --git a/app/views/docs/tabs.rb b/app/views/docs/tabs.rb index 33b6ff34..34bc6688 100644 --- a/app/views/docs/tabs.rb +++ b/app/views/docs/tabs.rb @@ -129,6 +129,36 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "Custom Tabs", context: self) do + <<~RUBY + div( + class: "group/custom-tab", + data: { + controller: "custom-tabs", + tab: "first", + action: "tab-change->custom-tabs#setTab" + } + ) do + div(class: "block") do + Tabs(default: "first") do + TabsList do + TabsTrigger(value: "first") { "first" } + TabsTrigger(value: "second") { "second" } + end + end + end + + div(class: "hidden group-data-[tab=second]/custom-tab:hidden md:h-50 lg:flex") do + plain "first1" + end + + div(class: "bg-code text-code-foreground mr-[14px] flex overflow-hidden rounded-xl border group-data-[tab=first]/custom-tab:hidden md:h-50") do + plain "first2" + end + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/app/views/layouts/pages_layout.rb b/app/views/layouts/pages_layout.rb index 98bfc95e..9dfb5653 100644 --- a/app/views/layouts/pages_layout.rb +++ b/app/views/layouts/pages_layout.rb @@ -5,7 +5,12 @@ module Layouts class PagesLayout < Views::Base include Phlex::Rails::Layout - def view_template(&block) + def initialize(page_info = nil, **user_attrs) + @page_info = page_info + super(**user_attrs) + end + + def view_template doctype html do @@ -13,7 +18,7 @@ def view_template(&block) body do render Shared::Navbar.new - main(class: "relative", &block) + main(class: "relative") { yield } render Shared::Flashes.new(notice: flash[:notice], alert: flash[:alert]) end end diff --git a/app/views/pages/base.rb b/app/views/pages/base.rb new file mode 100644 index 00000000..f95a6624 --- /dev/null +++ b/app/views/pages/base.rb @@ -0,0 +1,15 @@ +class Views::Pages::Base < Views::Base + PageInfo = Data.define(:title) + + def around_template + render layout.new(page_info) do + super + end + end + + def page_info + PageInfo.new( + title: page_title + ) + end +end diff --git a/app/views/pages/blocks.rb b/app/views/pages/blocks.rb new file mode 100644 index 00000000..28832de2 --- /dev/null +++ b/app/views/pages/blocks.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +class Views::Pages::Blocks < Views::Pages::Base + def layout = Views::Layouts::PagesLayout + + def page_title = "Building Blocks for the Web" + + def view_template + div(class: "mx-auto w-full max-w-5xl md:max-w-6xl px-4 py-14 md:py-16 space-y-10") do + div(class: "text-center space-y-4") do + Components.Heading(level: 1, as: "h1", class: "text-4xl md:text-6xl font-bold tracking-tight") { "Building Blocks for the Web" } + p(class: "text-muted-foreground text-base md:text-lg") { "Clean, modern building blocks. Copy and paste into your apps." } + p(class: "text-muted-foreground text-base md:text-lg") { "Works with all React frameworks. Open Source. Free forever." } + div(class: "flex flex-col sm:flex-row items-center justify-center gap-3 pt-2") do + Link(variant: :primary, href: blocks_path, class: "px-5") { "Browse Blocks" } + Link(variant: :ghost, href: docs_introduction_path, class: "px-5") { "Add a block" } + end + end + + # render Components::BlockViewer.new + # render ComponentPreview.new(title: "Example", context: self, type: :block, content: Blocks::Sidebar02) + # + render BlockDisplay.new(description: "A dashboard with sidebar, charts and data table", content: Blocks::Sidebar02) + # render Docs::VisualCodeExample.new( + # title: "Example", + # context: self, + # type: :block, + # content: Blocks::Sidebar02, + # ) + end + end + + def search_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-search" + ) do |s| + s.circle(cx: "11", cy: "11", r: "8") + s.path(d: "M21 21L16.7 16.7") + end + end + + def home_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-house" + ) do |s| + s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8") + s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z") + end + end + + def inbox_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-inbox" + ) do |s| + s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12") + s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z") + end + end + + def external_icon_link + svg( + xmlns: "http://www.w3.org/2000/svg", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "lucide lucide-external-link-icon lucide-external-link size-3" + ) do |s| + s.path(d: "M15 3h6v6") + s.path(d: "M10 14 21 3") + s.path(d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6") + end + end + + def info_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + viewbox: "0 0 24 24", + fill: "currentColor", + class: "w-5 h-5" + ) do |s| + s.path( + fill_rule: "evenodd", + d: + "M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z", + clip_rule: "evenodd" + ) + end + end +end diff --git a/app/views/pages/home.rb b/app/views/pages/home.rb index e476ffbf..ff91cd28 100644 --- a/app/views/pages/home.rb +++ b/app/views/pages/home.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true -class Views::Pages::Home < Views::Base +class Views::Pages::Home < Views::Pages::Base + def layout = Views::Layouts::PagesLayout + + def page_title = "RubyUI" + def view_template render HomeView::Banner.new do |banner| banner.cta do diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 00000000..a0fee126 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Load .env file if it exists +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +# Run kamal with all arguments +bundle exec kamal "$@" diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 00000000..36bde2d8 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config/application.rb b/config/application.rb index b0f97a18..710cdcc8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -14,7 +14,7 @@ class Application < Rails::Application # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. # Common ones are `templates`, `generators`, or `middleware`, for example. - config.autoload_lib(ignore: %w[assets tasks]) + config.autoload_lib(ignore: %w[assets tasks generators]) # Configuration for the application, engines, and railties goes here. # diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index 7651e988..ae4e8d93 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -V8SNuyxE0Iq+Yq161KEN/j3hjKfRN68Q81rARXyEOeFKNPw7o/SktsjnFSg9jDgZ4692i8RZiz5h63ZcLe12+6Pd6lA2gNOYv8aGUdl686JnDDL92PLC3ZOMfZx1JGD0G33SZumqxrVgpMLgpY5FKLEGy09jNHEdD9mYv3siJKU89xd0pdKTCoIbpcVXfEfuPyuul8hRi8VJjPLoIeQnXTSNNVNlvhVqFAEDIhPoilhqbxSlK8uSJPfAlUCzeU3A5x1haKDR3grU324jWkQX5fSO7JRXc2MqJxVNu/npX6CWaXH/NTVmbPi1YmcpI8LbROzfDLHx5ejH1KocKgIGbJss6Mo9zZZHD1xY4IiEQT+AofOjpwapnFhFvcuNMZImTaczxICVcJdM+EffipFUNuA2QGMX--SXsZ0psbmaVD96/1--lU3yc2xi8ZE/Mgo31zKzKA== \ No newline at end of file +zGRHLfjzX++JAoqNQwf2VClxciN8Z738wxBeGE3zTkZjMEXRKaxLadYdJBa785iOyvirWl8XSBdB4RrKrR9nPSciwt/pG4r05OsZssx4d2WUzSQTkc03z45rw8x+n/XfdNB9ODl4anrKUkgngbMD2EwlyZO7SkWmxu09z7LsK3c1AKYzoPWptu4HGusAwARnmk9iN9D0KrccKFALe+r/R2+Siq5PC6AO8+5FKkMmMQUPD01xI3cSHqQnef7d1526ajcoTlARY+QbkdaXl1OV4e1OzkD7SpYysdHDsG4F45Xptp70IWQlQfLhIbcJpKNtFS/7wsHq5VDqVSyn7dx1BktH+umDU8G6sOt3l205e6UkytrUb/dVaReFnNoxrIfPg6qtLwOnFOpPpq6IjXZ+QqKV7U09nTGLGOchG/6Oqime+OMd0zDZq89J04OkGWzpb2v4xrXE8HKz6fDJ55B2VsrH7wYcmKiqN1OySyfMeuZn5VRZcAJa6xFj--iqfNOy+25dikJ3Bl--m1Rw8BtUjT2I/xGFerfSUg== \ No newline at end of file diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 00000000..96def0d8 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,116 @@ +# Name of your application. Used to uniquely configure containers. +service: rubyui-web + +# Name of the container image. +image: rh63e/rubyui-web + +# Deploy to these servers. +servers: + web: + - 45.55.81.54 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + # ssl: true + host: seth.rubyui.com + # Proxy connects to your container on port 80 by default. + app_port: 3000 + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # Set KAMAL_REGISTRY_SERVER environment variable to override + # For local: use localhost:5001 or registry.docker.io (Docker Hub) + # For CI/CD: use ghcr.io (GitHub Container Registry) + server: registry.hub.docker.com + username: + - KAMAL_REGISTRY_USERNAME + + # Always use an access token rather than real password (pulled from .kamal/secrets). + password: + - KAMAL_REGISTRY_PASSWORD + +# Configure builder setup. +builder: + arch: amd64 + cache: + type: gha + options: mode=max + image: app-build-cache + secrets: + - RAILS_MASTER_KEY + # Pass in additional build args needed for your Dockerfile. + args: + GIT_COMMIT_HASH: <%= `git rev-parse --short HEAD`.strip %> + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +# +env: + # clear: + # DB_HOST: 192.168.0.2 + secret: + - RAILS_MASTER_KEY + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal app logs -r job" will tail logs from the first server in the job section. +# +# aliases: +# shell: app exec --interactive --reuse "bash" +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# Use a different ssh user than root +# +# ssh: +# user: app + +# Use a persistent storage volume. +# +# volumes: +# - "app_storage:/app/storage" + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +# +# asset_path: /app/public/assets + +# Configure rolling deploys by setting a wait time between batches of restarts. +# +# boot: +# limit: 10 # Can also specify as a percentage of total hosts, such as "25%" +# wait: 2 + +# Use accessory services (secrets come from .kamal/secrets). +# +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# port: 3306 +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: valkey/valkey:8 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/initializers/phlex.rb b/config/initializers/phlex.rb index 13fd15ae..7f30d9a7 100644 --- a/config/initializers/phlex.rb +++ b/config/initializers/phlex.rb @@ -7,6 +7,10 @@ module Components extend Phlex::Kit end +module Blocks + extend Phlex::Kit +end + Rails.autoloaders.main.push_dir( "#{Rails.root}/app/views", namespace: Views ) @@ -14,3 +18,7 @@ module Components Rails.autoloaders.main.push_dir( "#{Rails.root}/app/components", namespace: Components ) + +Rails.autoloaders.main.push_dir( + "#{Rails.root}/app/blocks", namespace: Blocks +) diff --git a/config/routes.rb b/config/routes.rb index a6da0ff2..42dfaf50 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,7 @@ get "sidebar", to: "docs#sidebar", as: :docs_sidebar get "sidebar/example", to: "docs/sidebar#example", as: :docs_sidebar_example get "sidebar/inset", to: "docs/sidebar#inset_example", as: :docs_sidebar_inset + get "sidebar/nested", to: "docs/sidebar#nested_example", as: :docs_sidebar_nested get "skeleton", to: "docs#skeleton", as: :docs_skeleton get "switch", to: "docs#switch", as: :docs_switch get "table", to: "docs#table", as: :docs_table @@ -63,8 +64,15 @@ get "typography", to: "docs#typography", as: :docs_typography end + get "blocks", to: "pages#blocks", as: :blocks + get "blocks/:id", to: "pages#render_block", as: :render_block + match "/404", to: "errors#not_found", via: :all match "/500", to: "errors#internal_server_error", via: :all + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", :as => :rails_health_check + root "pages#home" end diff --git a/lib/generators/ruby_ui/component/all_generator.rb b/lib/generators/ruby_ui/component/all_generator.rb new file mode 100644 index 00000000..0195d648 --- /dev/null +++ b/lib/generators/ruby_ui/component/all_generator.rb @@ -0,0 +1,30 @@ +begin + require "rails/generators" +rescue LoadError + # Rails not available, skip generator definition +end + +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + module Component + class AllGenerator < Rails::Generators::Base + namespace "ruby_ui:component:all" + + source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) + class_option :force, type: :boolean, default: false + + def generate_components + say "Generating all components..." + + Dir.children(self.class.source_root).each do |folder_name| + next if folder_name.ends_with?(".rb") + + run "bin/rails generate ruby_ui:component #{folder_name} --force #{options["force"]}" + end + end + end + end + end + end +end diff --git a/lib/generators/ruby_ui/component_generator.rb b/lib/generators/ruby_ui/component_generator.rb new file mode 100644 index 00000000..5dcba872 --- /dev/null +++ b/lib/generators/ruby_ui/component_generator.rb @@ -0,0 +1,106 @@ +require_relative "javascript_utils" + +begin + require "rails/generators" +rescue LoadError + # Rails not available, skip generator definition +end + +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + class ComponentGenerator < Rails::Generators::Base + include RubyUI::Generators::JavascriptUtils + + namespace "ruby_ui:component" + + source_root File.expand_path("../../../app/components/ruby_ui", __dir__) + argument :component_name, type: :string, required: true + class_option :force, type: :boolean, default: false + + def generate_component + if component_not_found? + say "Component not found: #{component_name}", :red + exit + end + + say "Generating #{component_name} files..." + end + + def copy_related_component_files + say "Generating components" + + components_file_paths.each do |file_path| + component_file_name = file_path.split("/").last + copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name, component_file_name), + force: options["force"] + end + end + + def copy_js_files + return if js_controller_file_paths.empty? + + say "Generating Stimulus controllers" + + js_controller_file_paths.each do |file_path| + controller_file_name = file_path.split("/").last + copy_file file_path, Rails.root.join("app/javascript/controllers/ruby_ui", controller_file_name), + force: options["force"] + end + + # Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)` + return if using_importmap? + + say "Updating Stimulus controllers manifest" + run "rake stimulus:manifest:update" + end + + def install_dependencies + return if dependencies.blank? + + say "Installing dependencies" + + install_components_dependencies(dependencies["components"]) + install_gems_dependencies(dependencies["gems"]) + install_js_packages(dependencies["js_packages"]) + end + + private + + def component_not_found? = !Dir.exist?(component_folder_path) + + def component_folder_name = component_name.underscore + + def component_folder_path = File.join(self.class.source_root, component_folder_name) + + def components_file_paths = Dir.glob(File.join(component_folder_path, "*.rb")) + + def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js")) + + def install_components_dependencies(components) + components&.each do |component| + run "bin/rails generate ruby_ui:component #{component} --force #{options["force"]}" + end + end + + def install_gems_dependencies(gems) + gems&.each do |ruby_gem| + run "bundle show #{ruby_gem} > /dev/null 2>&1 || bundle add #{ruby_gem}" + end + end + + def install_js_packages(js_packages) + js_packages&.each do |js_package| + install_js_package(js_package) + end + end + + def dependencies + @dependencies ||= YAML.load_file(File.join(__dir__, "dependencies.yml")).freeze + + @dependencies[component_folder_name] + end + end + end + end +end diff --git a/lib/generators/ruby_ui/dependencies.yml b/lib/generators/ruby_ui/dependencies.yml new file mode 100644 index 00000000..31ee03a5 --- /dev/null +++ b/lib/generators/ruby_ui/dependencies.yml @@ -0,0 +1,74 @@ +accordion: + js_packages: + - "motion" + +alert_dialog: + components: + - "Button" + +calendar: + js_packages: + - "mustache" + +carousel: + js_packages: + - "embla-carousel" + +chart: + js_packages: + - "chart.js" + +clipboard: + js_packages: + - "@floating-ui/dom" + +codeblock: + components: + - "Button" + - "Clipboard" + + gems: + - "rouge" + +combobox: + js_packages: + - "@floating-ui/dom" + +command: + js_packages: + - "fuse.js" + +context_menu: + js_packages: + - "tippy.js" + +dropdown_menu: + js_packages: + - "@floating-ui/dom" + +hover_card: + js_packages: + - "tippy.js" + +masked_input: + components: + - "Input" + + js_packages: + - "maska" + +pagination: + components: + - "Button" + +popover: + js_packages: + - "@floating-ui/dom" + +select: + js_packages: + - "@floating-ui/dom" + +tooltip: + js_packages: + - "@floating-ui/dom" diff --git a/lib/generators/ruby_ui/install/docs_generator.rb b/lib/generators/ruby_ui/install/docs_generator.rb new file mode 100644 index 00000000..feef4ae9 --- /dev/null +++ b/lib/generators/ruby_ui/install/docs_generator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +begin + require "rails/generators" +rescue LoadError + # Rails not available, skip generator definition +end + +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + module Install + class DocsGenerator < Rails::Generators::Base + namespace "ruby_ui:install:docs" + source_root File.expand_path("../../../../app/components/ruby_ui", __dir__) + class_option :force, type: :boolean, default: false + + def copy_docs_files + say "Installing RubyUI documentation files..." + + docs_file_paths.each do |source_path| + dest_filename = File.basename(source_path).sub("_docs", "") + copy_file source_path, Rails.root.join("app/views/docs", dest_filename), force: options["force"] + end + + say "" + say "Documentation installed to app/views/docs/", :green + end + + private + + def docs_file_paths + Dir.glob(File.join(self.class.source_root, "*", "*_docs.rb")) + end + end + end + end + end +end diff --git a/lib/generators/ruby_ui/install/install_generator.rb b/lib/generators/ruby_ui/install/install_generator.rb new file mode 100644 index 00000000..7c0f585a --- /dev/null +++ b/lib/generators/ruby_ui/install/install_generator.rb @@ -0,0 +1,90 @@ +begin + require "rails/generators" +rescue LoadError + # Rails not available, skip generator definition +end + +require_relative "../javascript_utils" + +if defined?(Rails::Generators::Base) + module RubyUI + module Generators + class InstallGenerator < Rails::Generators::Base + include RubyUI::Generators::JavascriptUtils + + namespace "ruby_ui:install" + + source_root File.expand_path("templates", __dir__) + + def install_phlex_rails + say "Checking for phlex-rails" + + if gem_installed?("phlex-rails") + say "phlex-rails is already installed", :green + else + say "Adding phlex-rails to Gemfile" + run %(bundle add phlex-rails) + + say "Generating phlex-rails structure" + run "bin/rails generate phlex:install" + end + end + + def install_tailwind_merge + say "Checking for tailwind_merge" + + if gem_installed?("tailwind_merge") + say "tailwind_merge is already installed", :green + else + say "Adding phlex-rails to Gemfile" + run %(bundle add tailwind_merge) + end + end + + def install_ruby_ui_initializer + say "Creating RubyUI initializer" + template "ruby_ui.rb.erb", Rails.root.join("config/initializers/ruby_ui.rb") + end + + def add_ruby_ui_module_to_components_base + say "Adding RubyUI Kit to Components::Base" + insert_into_file Rails.root.join("app/components/base.rb"), after: "class Components::Base < Phlex::HTML" do + "\n include RubyUI" + end + end + + def add_tailwind_css + say "Adding Tailwind css" + + css_path = if using_importmap? + Rails.root.join("app/assets/tailwind/application.css") + else + Rails.root.join("app/assets/stylesheets/application.tailwind.css") + end + + template "tailwind.css.erb", css_path + end + + def install_tailwind_plugins + say "Installing tw-animate-css plugin" + install_js_package("tw-animate-css") + end + + def add_ruby_ui_base + say "Adding RubyUI::Base component" + template "../../../../app/components/ruby_ui/base.rb", Rails.root.join("app/components/ruby_ui/base.rb") + end + + private + + def gem_installed?(name) + Gem::Specification.find_all_by_name(name).any? + end + + def using_tailwindcss_rails_gem? + File.exist?(Rails.root.join("app/assets/tailwind/application.css")) + end + end + end + end +end diff --git a/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb b/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb new file mode 100644 index 00000000..a5f251f1 --- /dev/null +++ b/lib/generators/ruby_ui/install/templates/ruby_ui.rb.erb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + extend Phlex::Kit +end + +# Allow using RubyUI instead RubyUi +Rails.autoloaders.main.inflector.inflect( + "ruby_ui" => "RubyUI" +) + +# Allow using RubyUI::ComponentName instead Components::RubyUI::ComponentName +Rails.autoloaders.main.push_dir( + Rails.root.join("app/components/ruby_ui"), namespace: RubyUI +) + +# Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName +Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*")) diff --git a/lib/generators/ruby_ui/install/templates/tailwind.css.erb b/lib/generators/ruby_ui/install/templates/tailwind.css.erb new file mode 100644 index 00000000..dc7fa403 --- /dev/null +++ b/lib/generators/ruby_ui/install/templates/tailwind.css.erb @@ -0,0 +1,153 @@ +@import "tailwindcss"; + +<% if using_importmap? %> +@import "../../../vendor/javascript/tw-animate-css.js"; +<% else %> +@import "tw-animate-css"; +<% end %> + +@custom-variant dark (&:is(.dark *)); + +: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); + --destructive-foreground: 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.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --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); + + /* ruby_ui specific */ + --warning: hsl(38 92% 50%); + --warning-foreground: hsl(0 0% 100%); + --success: hsl(87 100% 37%); + --success-foreground: hsl(0 0% 100%); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 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.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --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(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); + + /* ruby_ui specific */ + --warning: hsl(38 92% 50%); + --warning-foreground: hsl(0 0% 100%); + --success: hsl(84 81% 44%); + --success-foreground: hsl(0 0% 100%); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + /* ruby_ui specific */ + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); +} + +/* Container settings */ +@utility container { + margin-inline: auto; + padding-inline: 2rem; + max-width: 1400px; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/lib/generators/ruby_ui/javascript_utils.rb b/lib/generators/ruby_ui/javascript_utils.rb new file mode 100644 index 00000000..4c17a80c --- /dev/null +++ b/lib/generators/ruby_ui/javascript_utils.rb @@ -0,0 +1,61 @@ +module RubyUI + module Generators + module JavascriptUtils + def install_js_package(package) + if using_importmap? + pin_with_importmap(package) + elsif using_yarn? + run "yarn add #{package}" + elsif using_npm? + run "npm install #{package}" + elsif using_pnpm? + run "pnpm install #{package}" + else + say "Could not detect the package manager, you need to install '#{package}' manually", :red + end + end + + def pin_with_importmap(package) + case package + when "motion" + pin_motion + when "tippy.js" + pin_tippy_js + else + run "bin/importmap pin #{package}" + end + end + + def using_importmap? + File.exist?(Rails.root.join("config/importmap.rb")) && File.exist?(Rails.root.join("bin/importmap")) + end + + def using_npm? = File.exist?(Rails.root.join("package-lock.json")) + + def using_pnpm? = File.exist?(Rails.root.join("pnpm-lock.yaml")) + + def using_yarn? = File.exist?(Rails.root.join("yarn.lock")) + + def pin_motion + say <<~TEXT + WARNING: Installing motion from CDN because `bin/importmap pin motion` doesn't download the correct file. + TEXT + + inject_into_file Rails.root.join("config/importmap.rb"), <<~RUBY + pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@11.11.17/+esm"\n + RUBY + end + + def pin_tippy_js + say <<~TEXT + WARNING: Installing tippy.js from CDN because `bin/importmap pin tippy.js` doesn't download the correct file. + TEXT + + inject_into_file Rails.root.join("config/importmap.rb"), <<~RUBY + pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm" + pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n + RUBY + end + end + end +end diff --git a/lib/ruby_ui.rb b/lib/ruby_ui.rb new file mode 100644 index 00000000..491be44a --- /dev/null +++ b/lib/ruby_ui.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module RubyUI + VERSION = "0.1.0" + + class Error < StandardError; end +end diff --git a/package.json b/package.json index fb707361..37f35244 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "maska": "3.2.0", "motion": "12.34.0", "mustache": "4.2.0", + "shiki": "^3.14.0", "tailwindcss": "4.1.18", "tippy.js": "6.3.7", "tw-animate-css": "1.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index add13de4..ee75b593 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: mustache: specifier: 4.2.0 version: 4.2.0 + shiki: + specifier: ^3.14.0 + version: 3.14.0 tailwindcss: specifier: 4.1.18 version: 4.1.18 @@ -249,6 +252,27 @@ packages: '@rails/actioncable@8.1.200': resolution: {integrity: sha512-on0DSb7AFUkq1ocxivDNQhhGW/RQpY91zvRVyyaEWP4gOOZWy33P/UyxjQk74IENWNrTqs8+zOGHwTjiiFruRw==} + '@shikijs/core@3.14.0': + resolution: {integrity: sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw==} + + '@shikijs/engine-javascript@3.14.0': + resolution: {integrity: sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ==} + + '@shikijs/engine-oniguruma@3.14.0': + resolution: {integrity: sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug==} + + '@shikijs/langs@3.14.0': + resolution: {integrity: sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg==} + + '@shikijs/themes@3.14.0': + resolution: {integrity: sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA==} + + '@shikijs/types@3.14.0': + resolution: {integrity: sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@tailwindcss/forms@0.5.11': resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} peerDependencies: @@ -259,6 +283,18 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + autoprefixer@10.4.24: resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} engines: {node: ^10 || ^12 || >=14} @@ -278,6 +314,15 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chart.js@4.5.1: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} @@ -289,11 +334,21 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + electron-to-chromium@1.5.283: resolution: {integrity: sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w==} @@ -330,9 +385,36 @@ packages: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + maska@3.2.0: resolution: {integrity: sha512-zSmSgs5/q9vMSmrdZT3rKOv9uLznNWR/niuuAdBZDTvB3SMKOX9vhMtDijFyExz+B4UClu2rvksylUh/ea1bLA==} + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + mini-svg-data-uri@1.4.4: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true @@ -369,6 +451,12 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.3: + resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -383,22 +471,61 @@ packages: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + + shiki@3.14.0: + resolution: {integrity: sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -408,6 +535,15 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@esbuild/aix-ppc64@0.27.2': @@ -514,6 +650,39 @@ snapshots: '@rails/actioncable@8.1.200': {} + '@shikijs/core@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.3 + + '@shikijs/engine-oniguruma@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + + '@shikijs/themes@3.14.0': + dependencies: + '@shikijs/types': 3.14.0 + + '@shikijs/types@3.14.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@tailwindcss/forms@0.5.11(tailwindcss@4.1.18)': dependencies: mini-svg-data-uri: 1.4.4 @@ -524,6 +693,18 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.18 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + autoprefixer@10.4.24(postcss@8.5.3): dependencies: browserslist: 4.28.1 @@ -545,6 +726,12 @@ snapshots: caniuse-lite@1.0.30001766: {} + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chart.js@4.5.1: dependencies: '@kurkle/color': 0.3.4 @@ -555,8 +742,16 @@ snapshots: clsx@2.1.1: {} + comma-separated-tokens@2.0.3: {} + cssesc@3.0.0: {} + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + electron-to-chromium@1.5.283: {} embla-carousel@8.6.0: {} @@ -602,8 +797,57 @@ snapshots: fuse.js@7.1.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + html-void-elements@3.0.0: {} + maska@3.2.0: {} + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + mini-svg-data-uri@1.4.4: {} motion-dom@12.34.0: @@ -623,6 +867,14 @@ snapshots: node-releases@2.0.27: {} + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.3: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.0.1 + regex-recursion: 6.0.2 + picocolors@1.1.1: {} postcss-selector-parser@6.0.10: @@ -638,18 +890,73 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + property-information@7.1.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + + shiki@3.14.0: + dependencies: + '@shikijs/core': 3.14.0 + '@shikijs/engine-javascript': 3.14.0 + '@shikijs/engine-oniguruma': 3.14.0 + '@shikijs/langs': 3.14.0 + '@shikijs/themes': 3.14.0 + '@shikijs/types': 3.14.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + tailwindcss@4.1.18: {} tippy.js@6.3.7: dependencies: '@popperjs/core': 2.11.8 + trim-lines@3.0.1: {} + tslib@2.8.1: {} tw-animate-css@1.4.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -657,3 +964,15 @@ snapshots: picocolors: 1.1.1 util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + zwitch@2.0.4: {} diff --git a/ruby_ui.gemspec b/ruby_ui.gemspec new file mode 100644 index 00000000..7221853c --- /dev/null +++ b/ruby_ui.gemspec @@ -0,0 +1,19 @@ +require_relative "lib/ruby_ui" + +Gem::Specification.new do |s| + s.name = "seth_ruby_ui" + s.version = RubyUI::VERSION + s.summary = "RubyUI is a UI Component Library built on Phlex." + s.description = "RubyUI is a UI Component Library for Ruby developers. Built on top of the Phlex framework." + s.authors = ["Seth Horsley"] + s.files = Dir["README.md", "LICENSE.txt", "lib/**/*", "app/components/ruby_ui/**/*"] + s.require_paths = ["lib"] + s.homepage = "https://rubygems.org/gems/ruby_ui" + s.license = "MIT" + + s.required_ruby_version = ">= 3.2" + + s.add_dependency "phlex", ">= 2.0" + s.add_dependency "rouge" + s.add_dependency "tailwind_merge", ">= 0.12" +end