diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000000..a82e737ec844 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Git +.git +.gitignore + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Dependencies +node_modules +vendor/ +.bundle + +# Build outputs +dist/ +_site/ +.sass-cache/ +.jekyll-cache/ +.jekyll-metadata + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Temporary files +*.tmp +*.temp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000000..d38fcebc62b9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Use Ruby 3.3.5 as base image +FROM ruby:3.3.5-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Node.js 22.10.0 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs + +# Verify versions +RUN ruby --version && node --version && npm --version + +# Set working directory +WORKDIR /app + +# Copy dependency files first for better caching +COPY Gemfile Gemfile.lock ./ +COPY package.json package-lock.json* ./ + +# Install bundler and Ruby gems +RUN gem install bundler +RUN bundle config set --local path 'vendor/bundle' +RUN bundle install + +# Install Node.js packages +RUN npm install + +# Copy the rest of the application +COPY . . + +# Set up Jekyll source directory +ENV JEKYLL_ENV=development +ENV BUNDLE_PATH=vendor/bundle + +# Expose port 4000 for Jekyll +EXPOSE 4000 + +# Default command - can be overridden in docker-compose +CMD ["npm", "run", "watch"] diff --git a/Gemfile.lock b/Gemfile.lock index c78265925574..c6d32d669bbd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,43 +3,20 @@ GEM specs: addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - bigdecimal (3.1.8) colorator (1.1.0) concurrent-ruby (1.3.4) em-websocket (0.5.3) eventmachine (>= 0.12.9) http_parser.rb (~> 0) eventmachine (1.2.7) - ffi (1.17.0) - ffi (1.17.0-aarch64-linux-gnu) - ffi (1.17.0-aarch64-linux-musl) - ffi (1.17.0-arm-linux-gnu) - ffi (1.17.0-arm-linux-musl) - ffi (1.17.0-arm64-darwin) - ffi (1.17.0-x86-linux-gnu) - ffi (1.17.0-x86-linux-musl) - ffi (1.17.0-x86_64-darwin) - ffi (1.17.0-x86_64-linux-gnu) - ffi (1.17.0-x86_64-linux-musl) + ffi (1.17.2) forwardable-extended (2.6.0) - google-protobuf (4.28.3) - bigdecimal - rake (>= 13) - google-protobuf (4.28.3-aarch64-linux) - bigdecimal - rake (>= 13) - google-protobuf (4.28.3-arm64-darwin) - bigdecimal - rake (>= 13) - google-protobuf (4.28.3-x86-linux) - bigdecimal - rake (>= 13) - google-protobuf (4.28.3-x86_64-darwin) - bigdecimal - rake (>= 13) - google-protobuf (4.28.3-x86_64-linux) - bigdecimal - rake (>= 13) + google-protobuf (3.25.8) + google-protobuf (3.25.8-aarch64-linux) + google-protobuf (3.25.8-arm64-darwin) + google-protobuf (3.25.8-x86-linux) + google-protobuf (3.25.8-x86_64-darwin) + google-protobuf (3.25.8-x86_64-linux) http_parser.rb (0.8.0) i18n (1.14.6) concurrent-ruby (~> 1.0) @@ -82,82 +59,40 @@ GEM rexml (3.3.9) rouge (4.5.1) safe_yaml (1.0.5) - sass-embedded (1.80.6) - google-protobuf (~> 4.28) - rake (>= 13) - sass-embedded (1.80.6-aarch64-linux-android) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-aarch64-linux-gnu) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-aarch64-linux-musl) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-aarch64-mingw-ucrt) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-arm-linux-androideabi) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-arm-linux-gnueabihf) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-arm-linux-musleabihf) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-arm64-darwin) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-riscv64-linux-android) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-riscv64-linux-gnu) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-riscv64-linux-musl) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86-cygwin) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86-linux-android) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86-linux-gnu) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86-linux-musl) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86-mingw-ucrt) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86_64-cygwin) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86_64-darwin) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86_64-linux-android) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86_64-linux-gnu) - google-protobuf (~> 4.28) - sass-embedded (1.80.6-x86_64-linux-musl) - google-protobuf (~> 4.28) + sass-embedded (1.69.5) + google-protobuf (~> 3.23) + rake (>= 13.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) unicode-display_width (2.6.0) webrick (1.9.0) PLATFORMS + aarch64-linux aarch64-linux aarch64-linux-android - aarch64-linux-gnu aarch64-linux-musl aarch64-mingw-ucrt + arm-linux + arm-linux arm-linux-androideabi - arm-linux-gnu - arm-linux-gnueabihf arm-linux-musl arm-linux-musleabihf arm64-darwin + riscv64-linux riscv64-linux-android - riscv64-linux-gnu riscv64-linux-musl ruby x86-cygwin x86-linux + x86-linux x86-linux-android - x86-linux-gnu x86-linux-musl x86-mingw-ucrt x86_64-cygwin x86_64-darwin + x86_64-linux x86_64-linux-android - x86_64-linux-gnu x86_64-linux-musl DEPENDENCIES diff --git a/README.md b/README.md index cc2ff41e9a35..cb6bce982a92 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,23 @@ The site was recently overhauled. Here you can find documentation on how to add ## Running Locally +### Using Docker + +A [`Dockerfile`](./Dockerfile) and [`docker-compose.yml`](./docker-compose.yml) are provided to build and view the website locally using a containerised environment. To build the docker container use + +```sh +docker compose build +``` + +And then run the container with: + +```sh +docker compose up +``` + +The site is viewable at [`http://localhost:4000/`](http://localhost:4000/). + + ### Tools - (`asdf` for runtime version management) diff --git a/content/_includes/head.html b/content/_includes/head.html index 69b813faba63..5376f1ff1fb1 100644 --- a/content/_includes/head.html +++ b/content/_includes/head.html @@ -17,12 +17,12 @@ {% endif %} - - - + + + {% include external/mathjax.html -%} {% include external/font.html %} - + {% if page.react and (site.pages | where: "name" page.name) %} {%- assign react_source = page.name | append: ".js" -%} {% elsif (site.collections | where: "label" page.collection) %} @@ -30,5 +30,5 @@ {% else %} {%- assign react_source = "default.js" -%} {% endif %} - + diff --git a/content/_includes/post-list.html b/content/_includes/post-list.html index 2970d9999a3e..2e6edcf252b5 100644 --- a/content/_includes/post-list.html +++ b/content/_includes/post-list.html @@ -7,7 +7,7 @@ {%- assign date_format = constants.date_format | default: "%b %-d, %Y" -%} {{ post.date | date: date_format }}
- + {{ post.title | escape }}
diff --git a/content/pages/search.md b/content/pages/search.md index d0bb72ad623a..2d2691ec91c6 100644 --- a/content/pages/search.md +++ b/content/pages/search.md @@ -4,5 +4,5 @@ title: Search Results react: true --- - + {%- include react/root.html id='site-search' -%} diff --git a/content/properties.css.liquid b/content/properties.css.liquid index 6ca655ab6011..dcfa13b0d293 100644 --- a/content/properties.css.liquid +++ b/content/properties.css.liquid @@ -4,6 +4,6 @@ permalink: properties.css @layer components { :root { - --fiqci-banner-image: url({{ '/assets/images/FiQCI-banner.jpg' | absolute_url }}); + --fiqci-banner-image: url({{ '/assets/images/FiQCI-banner.jpg' | relative_url }}); } } diff --git a/content/site.js.liquid b/content/site.js.liquid index 8b718f7ee81e..61128ec35eaf 100644 --- a/content/site.js.liquid +++ b/content/site.js.liquid @@ -8,9 +8,9 @@ permalink: site.js "key": "{{ forloop.index }}", "type": "{{ publication.filters.Type | default: 'Post' }}", "title": "{{ publication.title }}", - "url": "{{ publication.url | absolute_url }}", + "url": "{{ publication.url | relative_url }}", "date": "{{ publication.date | date: '%-d.%-m.%Y' }}", - "teaser": "{{ publication.header.teaser | absolute_url }}", + "teaser": "{{ publication.header.teaser | relative_url }}", "filters": { {%- for category in publication.filters %} "{{ category[0] }}": "{{ category[1] }}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000000..2fad6248dfba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + fiqci-dev: + build: . + ports: + - "4000:4000" + - "35729:35729" + volumes: + # Mount source code but exclude dependencies + - .:/app + - /app/node_modules # prevent host's node_modules from overriding container's node_modules + - /app/vendor # prevent host's vendor directory from overriding container's Ruby gems + environment: + - BUNDLE_PATH=vendor/bundle + - NODE_ENV=development + - JEKYLL_ENV=development + - JEKYLL_HOST=0.0.0.0 + command: npm run watch + working_dir: /app diff --git a/package-lock.json b/package-lock.json index 6b3600e31b78..6fde4a206299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,15 @@ "dependencies": { "@cscfi/csc-ui": "^2.3.0", "@cscfi/csc-ui-react": "^2.3.0", + "date-fns": "^4.1.0", "dompurify": "^3.2.4", - "framer-motion": "^12.23.9", + "framer-motion": "^12.23.22", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", "katex": "^0.16.21", "lunr": "^2.3.9", - "prismjs": "^1.30.0" + "prismjs": "^1.30.0", + "react-calendar": "^6.0.0" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -2263,6 +2265,15 @@ } } }, + "node_modules/@wojtekmaj/date-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-2.0.2.tgz", + "integrity": "sha512-Do66mSlSNifFFuo3l9gNKfRMSFi26CRuQMsDJuuKO/ekrDWuTTtE4ZQxoFCUOG+NgxnpSeBq/k5TY8ZseEzLpA==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/date-utils?sponsor=1" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2909,6 +2920,15 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -3114,6 +3134,16 @@ "license": "MIT", "peer": true }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -3643,12 +3673,12 @@ } }, "node_modules/framer-motion": { - "version": "12.23.9", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz", - "integrity": "sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==", + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.9", + "motion-dom": "^12.23.21", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, @@ -3730,6 +3760,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-user-locale": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-3.0.0.tgz", + "integrity": "sha512-iJfHSmdYV39UUBw7Jq6GJzeJxUr4U+S03qdhVuDsR9gCEnfbqLy9gYDJFBJQL1riqolFUKQvx36mEkp2iGgJ3g==", + "license": "MIT", + "dependencies": { + "memoize": "^10.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -4316,6 +4358,21 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "license": "MIT" }, + "node_modules/memoize": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz", + "integrity": "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/memoize?sponsor=1" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4368,6 +4425,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -4409,9 +4478,9 @@ } }, "node_modules/motion-dom": { - "version": "12.23.9", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.9.tgz", - "integrity": "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==", + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.23.6" @@ -5068,6 +5137,31 @@ "node": ">=0.10.0" } }, + "node_modules/react-calendar": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-6.0.0.tgz", + "integrity": "sha512-6wqaki3Us0DNDjZDr0DYIzhSFprNoy4FdPT9Pjy5aD2hJJVjtJwmdMT9VmrTUo949nlk35BOxehThxX62RkuRQ==", + "license": "MIT", + "dependencies": { + "@wojtekmaj/date-utils": "^2.0.2", + "clsx": "^2.0.0", + "get-user-locale": "^3.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-calendar?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6028,6 +6122,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", diff --git a/package.json b/package.json index 4be2da2c4b69..7a67b0a48431 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "tailwindcss": "npx tailwindcss --input=${npm_package_config_tailwindcss_inputDir}/main.css --output=${npm_package_config_webpack_outputDir}/main.css", "webpack": "npx webpack", "jekyll": "bundle exec jekyll", - "jekyll:serve": "npm run-script jekyll -- serve --config ${npm_package_config_jekyll_configFilename},${npm_package_config_jekyll_webpackConfigFilename}", + "jekyll:serve": "npm run-script jekyll -- serve --config ${npm_package_config_jekyll_configFilename},${npm_package_config_jekyll_webpackConfigFilename} --livereload --host ${JEKYLL_HOST:-127.0.0.1}", "jekyll:build": "npm run-script jekyll -- build --config ${npm_package_config_jekyll_configFilename},${npm_package_config_jekyll_webpackConfigFilename}", "clean": "npm run-script jekyll -- clean --config ${npm_package_config_jekyll_configFilename},${npm_package_config_jekyll_webpackConfigFilename} && rm ${npm_package_config_jekyll_webpackConfigFilename}", "watch": "npx concurrently --kill-others --prefix-colors ${npm_package_config_conc_colors} --passthrough-arguments 'npm:tailwindcss -- {1}' 'npm:webpack -- --mode=development {1}' 'npm:jekyll:serve -- --livereload' -- --watch", @@ -52,13 +52,15 @@ "dependencies": { "@cscfi/csc-ui": "^2.3.0", "@cscfi/csc-ui-react": "^2.3.0", + "date-fns": "^4.1.0", "dompurify": "^3.2.4", - "framer-motion": "^12.23.9", + "framer-motion": "^12.23.22", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", "katex": "^0.16.21", "lunr": "^2.3.9", - "prismjs": "^1.30.0" + "prismjs": "^1.30.0", + "react-calendar": "^6.0.0" }, "private": "true" } diff --git a/src/components/Blogs.jsx b/src/components/Blogs.jsx index 5a3ce3b2e6fd..dbf4d4192b45 100644 --- a/src/components/Blogs.jsx +++ b/src/components/Blogs.jsx @@ -82,8 +82,8 @@ const FilterTheme = ({ selectedTheme, handleChangeTheme }) => ( value={selectedTheme} items={[ { name: 'HPC+QC+AI', value: 'HPC+QC+AI' }, - { name: 'Programming', value: 'programming' }, - { name: 'Algorithm', value: 'algorithm' }, + { name: 'Programming', value: 'Programming' }, + { name: 'Algorithm', value: 'Algorithm' }, { name: 'Technical', value: 'Technical' }, ]} placeholder='Choose a theme' diff --git a/src/components/Events.jsx b/src/components/Events.jsx index ea558304243b..af4d261c5c4c 100644 --- a/src/components/Events.jsx +++ b/src/components/Events.jsx @@ -77,9 +77,9 @@ const FilterTheme = ({ selectedTheme, handleChangeTheme }) => ( value={selectedTheme} items={[ { name: 'HPC+QC+AI', value: 'HPC+QC+AI' }, - { name: 'Programming', value: 'programming' }, - { name: 'Webinar/Lecture', value: 'webinar/lecture' }, - { name: 'Course/Workshop', value: 'course/workshop' }, + { name: 'Programming', value: 'Programming' }, + { name: 'Webinar/Lecture', value: 'Webinar/Lecture' }, + { name: 'Course/Workshop', value: 'Course/Workshop' }, ]} placeholder='Choose a theme' onChangeValue={handleChangeTheme} diff --git a/src/components/ServiceStatus.jsx b/src/components/ServiceStatus.jsx index da3270d16b4d..d21af3be8158 100644 --- a/src/components/ServiceStatus.jsx +++ b/src/components/ServiceStatus.jsx @@ -1,9 +1,11 @@ import React, { useState } from 'react' import { useStatus } from '../hooks/useStatus' +import { useBookings } from '../hooks/useBookings.jsx'; import { mdiInformation, mdiClose, mdiAlert } from '@mdi/js'; -import { CCard, CCardTitle, CCardContent, CIcon } from '@cscfi/csc-ui-react'; +import { CCard, CCardTitle, CCardContent, CIcon, CButton } from '@cscfi/csc-ui-react'; import { StatusModal } from './StatusModal/StatusModal'; +import { BookingModal } from './bookingCalendar.jsx'; const StatusCard = (props) => { const isOnline = props.health; @@ -39,6 +41,7 @@ const StatusCard = (props) => { export const ServiceStatus = (props) => { const { status: statusList } = useStatus("https://fiqci-backend.2.rahtiapp.fi/devices/healthcheck"); + const { bookingData: bookingData } = useBookings("https://fiqci-backend.2.rahtiapp.fi/bookings") const qcs = props["quantum-computers"] || []; const devicesWithStatus = (qcs.length === 0 || !Array.isArray(statusList)) @@ -51,7 +54,8 @@ export const ServiceStatus = (props) => { }; }); - const [modalOpen, setModalOpen] = useState(false); + const [bookingModalOpen, setBookingModalOpen] = useState(false) + const [modalOpen, setModalOpen] = useState(false); const [modalProps, setModalProps] = useState({}); const handleCardClick = (qc) => { setModalProps({ ...qc, devicesWithStatus }); @@ -87,11 +91,27 @@ export const ServiceStatus = (props) => { {props.alert?.type ? props.alert?.text : 'Loading...'}

-
+
+

Reservations

+

+ VTT devices can at times be reserved. At these times the queue will be paused. + Reservations can be viewed from this calendar. Note that making reservations through FiQCI is not currently possible. +

+ setBookingModalOpen(true)}>View Reservations +
+ +

Devices

+
{devicesWithStatus.map((qc, index) => ( handleCardClick(qc)} /> ))} + +
+ {bookingModalOpen && ( + + )} + {modalOpen && ( )} diff --git a/src/components/SiteSearch.jsx b/src/components/SiteSearch.jsx index d73bb7dd0d99..8b10196ae0bd 100644 --- a/src/components/SiteSearch.jsx +++ b/src/components/SiteSearch.jsx @@ -6,6 +6,7 @@ import { CCardTitle, CCardContent, CCardActions } from '@cscfi/csc-ui-react'; import { prependBaseURL } from '../utils/url'; +import { capitalizeFirstLetter } from "../utils/textUtils"; const style = { "--_c-button-font-size": 14, @@ -107,10 +108,6 @@ function findItemByRef(ref, store) { return Object.values(store).flat().find(item => item.key.toLowerCase() === normalizedRef); } -function capitalizeFirstLetter(val) { - return String(val).charAt(0).toUpperCase() + String(val).slice(1); -} - const SearchBar = ({ setResults, setQuery, query }) => { const handleSearchBar = (e) => { const input = e.target.value; diff --git a/src/components/StatusModal/StatusModal.jsx b/src/components/StatusModal/StatusModal.jsx index 0f2ca50b10a7..3fac9f6481d8 100644 --- a/src/components/StatusModal/StatusModal.jsx +++ b/src/components/StatusModal/StatusModal.jsx @@ -4,26 +4,7 @@ import { useState, useEffect } from 'react' import { CModal } from '@cscfi/csc-ui-react'; import { ModalContent } from './StatusModalConent'; - -export default function useWindowSize() { - const [width, setWidth] = useState( - typeof window !== 'undefined' ? window.innerWidth : 0 - ); - - useEffect(() => { - const onResize = () => setWidth(window.innerWidth); - - window.addEventListener('resize', onResize); - // In case the window was resized before the listener attached - onResize(); - - return () => { - window.removeEventListener('resize', onResize); - }; - }, []); - - return { width }; -} +import { useWindowSize } from '../../utils/modalUtils'; export const StatusModal = (props) => { const { isModalOpen, setIsModalOpen, ...modalProps } = props; diff --git a/src/components/bookingCalendar.jsx b/src/components/bookingCalendar.jsx new file mode 100644 index 000000000000..9b77e22e6453 --- /dev/null +++ b/src/components/bookingCalendar.jsx @@ -0,0 +1,382 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import Calendar from "react-calendar"; +import "../stylesheets/Calendar.css"; +import { format, parseISO } from "date-fns"; +import { CModal, CCard, CCardTitle, CCardContent, CSelect, CCardActions, CButton, CTextField } from '@cscfi/csc-ui-react'; +import { capitalizeFirstLetter } from "../utils/textUtils"; +import { useWindowSize } from "../utils/modalUtils"; + +export const BookingModal = (props) => { + const { bookingData } = props; + + const { width } = useWindowSize(); + let size; + + if (width >= 2600) size = 'large'; + else if (width >= 768) size = 'medium'; + else size = 'small'; + + const modalWidths = { small: '90vw', medium: '1400px', large: '50vw' } + + return ( + props.setIsModalOpen(e.detail)} + > + + + {props.name} + + + + +
+ props.setIsModalOpen(false)} text>Close +
+
+
+
+ ) +} + +// Helper to group bookings by date +const groupBookingsByDate = (bookings) => { + return bookings.reduce((acc, booking) => { + const dateKey = format(parseISO(booking.start_time), "yyyy-MM-dd"); + if (!acc[dateKey]) acc[dateKey] = []; + acc[dateKey].push(booking); + return acc; + }, {}); +} + +const BookingCalendar = (props) => { + const { bookingData } = props; + // Set initial selectedDate to today + const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedBooking, setSelectedBooking] = useState(null); + const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, content: '' }); + const gridRef = useRef(null); + const [bookings, setBookings] = useState(bookingData) + const [bookingsByDate, setBookingsByDate] = useState(groupBookingsByDate(bookingData)) + const [filter, setFilter] = useState("All") + const [view, setView] = useState("List") + const [validDate, setValidDate] = useState(true) + + useEffect(() => { + setBookings(bookingData) + }, [bookingData]) + + useEffect(() => { + setBookings(bookingData.filter(b => filter.toLowerCase() === "all" || b.device === filter.toLowerCase())) + }, [filter]) + + useEffect(() => { + setBookingsByDate(groupBookingsByDate(bookings)) + }, [bookings]) + + const handleFilterChange = useCallback(selectedFilter => { + setFilter(selectedFilter.detail || 'All'); + }, []) + + const handleViewChange = useCallback(selectedView => { + setView(selectedView.detail || 'List'); + }, []) + + // Helper to get reserved minutes for the selected day + const getReservedMinutes = () => { + // Use local time for all calculations + const selectedYear = selectedDate.getFullYear(); + const selectedMonth = selectedDate.getMonth(); + const selectedDay = selectedDate.getDate(); + // Start and end of the selected day in local time + const dayStart = new Date(selectedYear, selectedMonth, selectedDay, 0, 0, 0, 0).getTime(); + const dayEnd = new Date(selectedYear, selectedMonth, selectedDay, 23, 59, 59, 999).getTime(); + let reserved = Array.from({ length: 24 * 60 }, () => []); + (bookings || []).forEach(b => { + const start = parseISO(b.start_time); + const end = parseISO(b.end_time); + const bookingStart = start.getTime(); + const bookingEnd = end.getTime(); + // If booking overlaps this day at all (local time) + if (bookingEnd > dayStart && bookingStart < dayEnd) { + // Clamp booking to this day + const minStart = Math.max(bookingStart, dayStart); + const maxEnd = Math.min(bookingEnd, dayEnd + 1); // +1 to include last minute + // For each minute of the day + for (let i = 0; i < 24 * 60; i++) { + const minuteTime = new Date(selectedYear, selectedMonth, selectedDay, Math.floor(i / 60), i % 60).getTime(); + if (minuteTime >= minStart && minuteTime < maxEnd) { + reserved[i].push(b); + } + } + } + }); + return reserved; + }; + + const reservedMinutes = getReservedMinutes(); + + const handleDateChange = (value) => { + try { + const newDate = parseISO(value.detail); + if (isNaN(newDate) || newDate.toString() === 'Invalid Date') { + setValidDate(false); + setSelectedDate(new Date()); + console.log(newDate) + return; + } + setSelectedDate(newDate); + setValidDate(true); + console.log(newDate) + } catch { + console.log("here") + setValidDate(false); + } + } + + // Helper to show tooltip (for both mouse and touch) + const showTooltip = (event, content) => { + let x = 0, y = 0; + if (event.touches && event.touches.length > 0) { + x = event.touches[0].clientX; + y = event.touches[0].clientY; + } else { + x = event.clientX; + y = event.clientY; + } + setTooltip({ visible: true, x, y, content }); + }; + + const hideTooltip = () => setTooltip({ ...tooltip, visible: false }); + + // Close tooltip when user touches/clicks outside the tooltip + useEffect(() => { + if (!tooltip.visible) return; + const handlePointerDown = (e) => { + // If tooltip ref exists and click is outside, close + const tooltipEl = document.getElementById('booking-tooltip-overlay'); + if (tooltipEl && !tooltipEl.contains(e.target)) { + hideTooltip(); + } + }; + document.addEventListener('mousedown', handlePointerDown, true); + document.addEventListener('touchstart', handlePointerDown, true); + return () => { + document.removeEventListener('mousedown', handlePointerDown, true); + document.removeEventListener('touchstart', handlePointerDown, true); + }; + }, [tooltip.visible]); + + // Close tooltip on scroll (mobile) + useEffect(() => { + const handler = () => hideTooltip(); + window.addEventListener('scroll', handler, true); + return () => window.removeEventListener('scroll', handler, true); + }, []); + + return ( +
+
+
+ +
+ +
+
+ +
+ +
+
+
+ +

Partially Reserved

+
+
+
+

Selected Day

+
+
+
+

Today

+
+ {view === "Grid" && ( +
+
+

Booked

+
+ )} +
+
+
+ {/* Calendar */} + setSelectedDate(value)} + tileContent={({ date }) => + bookingsByDate[format(date, "yyyy-MM-dd")] ? ( + + ) : null + } + /> + + {/* Daily booking list or grid */} + {selectedDate && ( +
+

+ Reservations on {format(selectedDate, "PPP")} +

+ {view === "List" ? ( +
    + {(bookingsByDate[format(selectedDate, "yyyy-MM-dd")] || []) + .filter(b => filter.toLowerCase() === "all" || b.device === filter.toLowerCase()) + .map( + (b) => ( +
  • setSelectedBooking(b)} + > + {capitalizeFirstLetter(b.device)} - {capitalizeFirstLetter(b.type)} ( + {format(parseISO(b.start_time), "HH:mm")}– + {format(parseISO(b.end_time), "HH:mm")}) +
  • + ) + )} +
+ ) : ( +
+ {/* Hour labels on the left */} +
+
+ {"00".toString().padStart(2, '0')} +
+
+ {"06".toString().padStart(2, '0')} +
+
+ {"12".toString().padStart(2, '0')} +
+
+ {"18".toString().padStart(2, '0')} +
+
+ {"23".toString().padStart(2, '0')} +
+
+ {/* Responsive grid: 24 rows (hours), 60 cols (minutes) */} +
+
+ {Array.from({ length: 24 }).map((_, hour) => ( +
+ {Array.from({ length: 60 }).map((_, min) => { + const i = hour * 60 + min; + const bookings = reservedMinutes[i]; + let background; + if (bookings.length === 0) { + background = '#e5e7eb'; + } else { + background = '#f87171'; + } + const tooltipContent = bookings.length + ? bookings.map(b => `${capitalizeFirstLetter(b.device)}: ${capitalizeFirstLetter(b.type)} (${format(parseISO(b.start_time), "HH:mm")}-${format(parseISO(b.end_time), "HH:mm")})`).join('\n') + : `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`; + return ( +
{ setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} + onMouseLeave={() => { setSelectedBooking(null); hideTooltip(); }} + onTouchStart={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} + onClick={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} + /> + ); + })} +
+ ))} +
+ {/* Tooltip overlay */} + {tooltip.visible && ( +
+ {tooltip.content} +
+ )} +
+
+ )} + +
+ )} +
+
+ ); +} diff --git a/src/hooks/useBookings.jsx b/src/hooks/useBookings.jsx new file mode 100644 index 000000000000..8b887f77213e --- /dev/null +++ b/src/hooks/useBookings.jsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react' + +export const useBookings = (bookingUrl) => { + const [bookingData, setBookingData] = useState([]) + const [error, setError] = useState(null); + + useEffect(() => { + const fetchBookings = async () => { + const url = bookingUrl; + try { + const resp = await fetch (url); + const result = await resp.json(); + const splitBookings = (bookings) => { + const split = []; + bookings.forEach((booking) => { + const start = new Date(booking.start_time); + const end = new Date(booking.end_time); + + let current = new Date(start); + current.setHours(0, 0, 0, 0); + + const endDay = new Date(end); + endDay.setHours(0, 0, 0, 0); + + if (current.getTime() === endDay.getTime()) { + split.push(booking); + } else { + // Split across days + while (current <= endDay) { + let dayEnd = new Date(current); + dayEnd.setHours(23, 59, 59, 999); + + // Clamp to booking start/end + const splitStart = current.getTime() === new Date(start).setHours(0,0,0,0) + ? start + : new Date(current); + + const splitEnd = current.getTime() === endDay.getTime() + ? end + : dayEnd; + + split.push({ + ...booking, + id: `${booking.id}-${current.toISOString().slice(0,10)}`, + start_time: splitStart.toISOString(), + end_time: splitEnd.toISOString(), + }); + + current.setDate(current.getDate() + 1); + } + } + }); + return split; + }; + + const bookings = Array.isArray(result?.data) ? result.data : []; + const processedBookings = splitBookings(bookings); + setBookingData(processedBookings); + } catch (err) { + console.error(err); + setError(err); + } + } + + fetchBookings(); +}, [bookingUrl]); + + return { bookingData, error }; +} diff --git a/src/stylesheets/Calendar.css b/src/stylesheets/Calendar.css new file mode 100644 index 000000000000..1c950ce4dbea --- /dev/null +++ b/src/stylesheets/Calendar.css @@ -0,0 +1,157 @@ +.react-calendar { + width: 40%; + height: fit-content; + background: white; + border: 1px solid #e5e7eb; + font-family: 'Arial', 'Helvetica', sans-serif; + line-height: 1.125em; +} + +.react-calendar--doubleView { + width: 700px; +} + +.react-calendar--doubleView .react-calendar__viewContainer { + display: flex; + margin: -0.5em; +} + +.react-calendar--doubleView .react-calendar__viewContainer > * { + width: 50%; + margin: 0.5em; +} + +.react-calendar, +.react-calendar *, +.react-calendar *:before, +.react-calendar *:after { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +.react-calendar button { + margin: 0; + border: 0; + outline: none; +} + +.react-calendar button:enabled:hover { + cursor: pointer; +} + +.react-calendar__navigation { + display: flex; + height: 44px; + margin-bottom: 1em; +} + +.react-calendar__navigation button { + min-width: 44px; + background: none; +} + +.react-calendar__navigation button:disabled { + background-color: #f0f0f0; +} + +.react-calendar__navigation button:enabled:hover, +.react-calendar__navigation button:enabled:focus { + background-color: #e6e6e6; +} + +.react-calendar__month-view__weekdays { + text-align: center; + text-transform: uppercase; + font: inherit; + font-size: 0.75em; + font-weight: bold; + text-decoration: none !important; +} + +abbr:where([title]) { + text-decoration: none !important +} + +.react-calendar__month-view__weekdays__weekday { + padding: 0.5em; +} + +.react-calendar__month-view__weekNumbers .react-calendar__tile { + display: flex; + align-items: center; + justify-content: center; + font: inherit; + font-size: 0.75em; + font-weight: bold; +} + + +.react-calendar__month-view__days__day--neighboringMonth, +.react-calendar__decade-view__years__year--neighboringDecade, +.react-calendar__century-view__decades__decade--neighboringCentury { + color: #757575; +} + +.react-calendar__year-view .react-calendar__tile, +.react-calendar__decade-view .react-calendar__tile, +.react-calendar__century-view .react-calendar__tile { + padding: 2em 0.5em; +} + +.react-calendar__tile { + max-width: 100%; + padding: 10px 6.6667px; + background: none; + text-align: center; + font: inherit; + font-size: 0.833em; +} + +.react-calendar__tile:disabled { + background-color: #f0f0f0; + color: #ababab; +} + +.react-calendar__month-view__days__day--neighboringMonth:disabled, +.react-calendar__decade-view__years__year--neighboringDecade:disabled, +.react-calendar__century-view__decades__decade--neighboringCentury:disabled { + color: #cdcdcd; +} + +.react-calendar__tile:enabled:hover, +.react-calendar__tile:enabled:focus { + background-color: #e6e6e6; +} + +.react-calendar__tile--now { + background: #CCE6F1; +} + +.react-calendar__tile--now:enabled:hover, +.react-calendar__tile--now:enabled:focus { + background: #CCE6F1; +} + +.react-calendar__tile--hasActive { + background: #CCE6F1; +} + +.react-calendar__tile--hasActive:enabled:hover, +.react-calendar__tile--hasActive:enabled:focus { + background: #CCE6F1; +} + +.react-calendar__tile--active { + background: #006edc; + color: white; +} + +.react-calendar__tile--active:enabled:hover, +.react-calendar__tile--active:enabled:focus { + background: #1087ff; +} + +.react-calendar--selectRange .react-calendar__tile--hover { + background-color: #e6e6e6; +} diff --git a/src/utils/modalUtils.jsx b/src/utils/modalUtils.jsx new file mode 100644 index 000000000000..dd420a2efeab --- /dev/null +++ b/src/utils/modalUtils.jsx @@ -0,0 +1,21 @@ +import { useState, useEffect } from "react"; + +export const useWindowSize = () => { + const [width, setWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : 0 + ); + + useEffect(() => { + const onResize = () => setWidth(window.innerWidth); + + window.addEventListener('resize', onResize); + // In case the window was resized before the listener attached + onResize(); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, []); + + return { width }; +} \ No newline at end of file diff --git a/src/utils/textUtils.js b/src/utils/textUtils.js new file mode 100644 index 000000000000..f50d8b52cd8d --- /dev/null +++ b/src/utils/textUtils.js @@ -0,0 +1,3 @@ +export const capitalizeFirstLetter = (val) => { + return String(val).charAt(0).toUpperCase() + String(val).slice(1); +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index 35a7b5d2af7d..fba4390a6f71 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -47,6 +47,7 @@ fs.writeFileSync(jekyllConfigFilepath, yaml.stringify(jekyllConfig)); module.exports = { entry: entryFiles, + devtool: 'inline-source-map', output: { filename: "[name].js", path: outputDirpath,