mirror of
https://github.com/ClaperCo/Claper.git
synced 2026-02-24 04:01:04 +01:00
Version 2.4.0
## ⚠️ Breaking changes - S3 variables are now named: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION and S3_BUCKET - Users now have roles. Refer to the `roles` table and assign a role to a user with the `role_id` column in the `users` table. ## Features - Add Admin Panel to manage users and presentations - Add user roles: user, admin - Add `LANGUAGES` setting to configure available languages in the app - Add hideable presenter attendee count (#183 #155) - Add Hungarian translation (#161) - Add Latvian translation (#163) - Add custom S3 endpoint with `S3_SCHEME`, `S3_HOST`, `S3_PORT` and `S3_PUBLIC_URL` ## Fixes and improvements - Upgrade JS dependencies - Upgrade Elixir dependencies, including Phoenix Live View to 1.0.17 - Upgrade to Tailwind 4+ - Refactor view templates to use {} instead of <%= %> - Fix event name validation to be required - Docker image is now using Ubuntu instead of Alpine for better dependencies support - Fix scrollbar not showing in event manager when no presentation file (#164) (@aryel780) - Fix settings scroll for small screen (#168) - Fix duplicate key quiz when duplicate (#182) - Fix email change confirmation (#172) - Fix italian translation (#179) - Fix random poll choices (#184)
This commit is contained in:
22
.env.sample
22
.env.sample
@@ -3,7 +3,7 @@ BASE_URL=http://localhost:4000
|
||||
# SECURE_COOKIE=false
|
||||
|
||||
DATABASE_URL=postgres://claper:claper@db:5432/claper
|
||||
SECRET_KEY_BASE=0LZiQBLw4WvqPlz4cz8RsHJlxNiSqM9B48y4ChyJ5v1oA0L/TPIqRjQNdPZN3iEG # Generate with `mix phx.gen.secret`
|
||||
SECRET_KEY_BASE=0LZiQBLw4WvqPlz4cz8RsHJlxNiSqM9B48y4ChyJ5v1oA0L/TPIqRjQNdPZN3iEG # Generate with `mix phx.gen.secret`
|
||||
# ⚠️ Don't use this exact value for SECRET_KEY_BASE or someone would be able to sign a cookie with user_id=1 and log in as the admin!
|
||||
|
||||
# Storage configuration
|
||||
@@ -12,10 +12,19 @@ PRESENTATION_STORAGE=local
|
||||
PRESENTATION_STORAGE_DIR=/app/uploads
|
||||
#MAX_FILE_SIZE_MB=15
|
||||
|
||||
#AWS_ACCESS_KEY_ID=xxx
|
||||
#AWS_SECRET_ACCESS_KEY=xxx
|
||||
#AWS_REGION=eu-west-3
|
||||
#AWS_PRES_BUCKET=xxx
|
||||
# The standard AWS environment variables
|
||||
#S3_ACCESS_KEY_ID=xxx
|
||||
#S3_SECRET_ACCESS_KEY=xxx
|
||||
#S3_REGION=eu-west-3
|
||||
#S3_BUCKET=xxx
|
||||
|
||||
# If you're using an alternative S3-compatible service, port optional
|
||||
#S3_SCHEME=https://
|
||||
#S3_HOST=www.example.com
|
||||
#S3_PORT=443
|
||||
|
||||
# If the public S3-compatible URL is different from the one used to write data
|
||||
#S3_PUBLIC_URL=https://www.example.com
|
||||
|
||||
# Mail configuration
|
||||
|
||||
@@ -39,6 +48,7 @@ MAIL_FROM_NAME=Claper
|
||||
#ALLOW_UNLINK_EXTERNAL_PROVIDER=false
|
||||
#LOGOUT_REDIRECT_URL=https://google.com
|
||||
#GS_JPG_RESOLUTION=300x300
|
||||
#LANGUAGES=en,fr,es,it,nl,de
|
||||
|
||||
# OIDC configuration
|
||||
|
||||
@@ -49,4 +59,4 @@ MAIL_FROM_NAME=Claper
|
||||
# OIDC_SCOPES="openid email profile"
|
||||
# OIDC_LOGO_URL=""
|
||||
# OIDC_PROPERTY_MAPPINGS="roles:custom_attributes.roles,organization:custom_attributes.organization"
|
||||
# OIDC_AUTO_REDIRECT_LOGIN=true
|
||||
# OIDC_AUTO_REDIRECT_LOGIN=true
|
||||
|
||||
4
.github/workflows/elixir.yml
vendored
4
.github/workflows/elixir.yml
vendored
@@ -42,8 +42,8 @@ jobs:
|
||||
- name: Set up Elixir
|
||||
uses: erlef/setup-beam@v1
|
||||
with:
|
||||
elixir-version: '1.16.2'
|
||||
otp-version: '26'
|
||||
elixir-version: '1.18.4'
|
||||
otp-version: '28'
|
||||
- name: Restore dependencies cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -30,16 +30,21 @@ claper-*.tar
|
||||
# Ignore digested assets cache.
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
# Env files except sample
|
||||
.env*
|
||||
!.env.sample
|
||||
|
||||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
priv/static/images/.DS_Store
|
||||
priv/static/.DS_Store
|
||||
.env
|
||||
priv/static/fonts/.DS_Store
|
||||
test/e2e/node_modules
|
||||
.DS_Store
|
||||
priv/static/.well-known/apple-developer-merchantid-domain-association
|
||||
priv/static/loaderio-eb3b956a176cdd4f54eb8570ce8bbb06.txt
|
||||
.elixir_ls
|
||||
|
||||
tailwind.config.js
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,3 +1,35 @@
|
||||
### v.2.4.0
|
||||
|
||||
### ⚠️ Breaking changes
|
||||
|
||||
- S3 variables are now named: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION and S3_BUCKET
|
||||
- Users now have roles. Refer to the `roles` table and assign a role to a user with the `role_id` column in the `users` table.
|
||||
|
||||
### Features
|
||||
|
||||
- Add Admin Panel to manage users and presentations
|
||||
- Add user roles: user, admin
|
||||
- Add `LANGUAGES` setting to configure available languages in the app
|
||||
- Add hideable presenter attendee count (#183 #155)
|
||||
- Add Hungarian translation (#161)
|
||||
- Add Latvian translation (#163)
|
||||
- Add custom S3 endpoint with `S3_SCHEME`, `S3_HOST`, `S3_PORT` and `S3_PUBLIC_URL`
|
||||
|
||||
### Fixes and improvements
|
||||
|
||||
- Upgrade JS dependencies
|
||||
- Upgrade Elixir dependencies, including Phoenix Live View to 1.0.17
|
||||
- Upgrade to Tailwind 4+
|
||||
- Refactor view templates to use {} instead of <%= %>
|
||||
- Fix event name validation to be required
|
||||
- Docker image is now using Ubuntu instead of Alpine for better dependencies support
|
||||
- Fix scrollbar not showing in event manager when no presentation file (#164) (@aryel780)
|
||||
- Fix settings scroll for small screen (#168)
|
||||
- Fix duplicate key quiz when duplicate (#182)
|
||||
- Fix email change confirmation (#172)
|
||||
- Fix italian translation (#179)
|
||||
- Fix random poll choices (#184)
|
||||
|
||||
### v.2.3.2
|
||||
|
||||
### Fixes and improvements
|
||||
|
||||
112
CLAUDE.md
Normal file
112
CLAUDE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Setup and Dependencies
|
||||
```bash
|
||||
# Install dependencies
|
||||
mix deps.get
|
||||
mix setup # Runs deps.get + ecto.setup
|
||||
|
||||
# Setup database
|
||||
mix ecto.setup # Creates DB, runs migrations, seeds
|
||||
mix ecto.reset # Drops DB and runs ecto.setup
|
||||
|
||||
# Install frontend dependencies
|
||||
cd assets && npm install && cd ..
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
```bash
|
||||
# Start Phoenix server
|
||||
mix phx.server
|
||||
|
||||
# Or inside IEx
|
||||
iex -S mix phx.server
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run tests
|
||||
mix test
|
||||
|
||||
# Run specific test file
|
||||
mix test test/path/to/test_file.exs
|
||||
|
||||
# Run test with specific line number
|
||||
mix test test/path/to/test_file.exs:42
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format code
|
||||
mix format
|
||||
|
||||
# Run Credo for code analysis
|
||||
mix credo
|
||||
```
|
||||
|
||||
### Building Assets
|
||||
```bash
|
||||
# For production deployment
|
||||
mix assets.deploy
|
||||
```
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
Claper is an interactive presentation platform built with Phoenix Framework and Elixir. It enables real-time audience interaction during presentations through polls, forms, messages, and quizzes.
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Phoenix LiveView Architecture**
|
||||
- Real-time updates without JavaScript through WebSocket connections
|
||||
- LiveView modules in `lib/claper_web/live/` handle interactive UI
|
||||
- Presence tracking for real-time user counts
|
||||
|
||||
2. **Main Domain Contexts** (in `lib/claper/`)
|
||||
- `Accounts` - User management, authentication, OIDC integration
|
||||
- `Events` - Core presentation/event management
|
||||
- `Posts` - Audience messages and reactions
|
||||
- `Polls` - Interactive polls with real-time voting
|
||||
- `Forms` - Custom forms for audience feedback
|
||||
- `Quizzes` - Quiz functionality with LTI support
|
||||
- `Presentations` - Slide management and state tracking
|
||||
- `Embeds` - External content embedding
|
||||
|
||||
3. **Authentication & Authorization**
|
||||
- Multiple auth methods: email/password, OIDC
|
||||
- Role-based access control with admin panel
|
||||
- LTI 1.3 support for educational platforms
|
||||
|
||||
4. **Real-time Features**
|
||||
- Phoenix PubSub for broadcasting updates
|
||||
- Phoenix Presence for tracking online users
|
||||
- LiveView for reactive UI without custom JavaScript
|
||||
|
||||
5. **Background Jobs**
|
||||
- Oban for background job processing
|
||||
- Email sending
|
||||
|
||||
6. **Frontend Stack**
|
||||
- Tailwind CSS for styling (with DaisyUI components)
|
||||
- Alpine.js for minimal JavaScript interactions
|
||||
- esbuild for JavaScript bundling
|
||||
- Separate admin and user interfaces
|
||||
|
||||
7. **Key LiveView Modules**
|
||||
- `EventLive.Show` - Attendee view
|
||||
- `EventLive.Presenter` - Presenter control view
|
||||
- `EventLive.Manage` - Event management interface
|
||||
- `AdminLive.*` - Admin panel components
|
||||
|
||||
8. **Database Structure**
|
||||
- PostgreSQL with Ecto
|
||||
- Key models: User, Event, Post, Poll, Quiz, PresentationState
|
||||
- Soft deletes for users
|
||||
- UUID-based public identifiers
|
||||
|
||||
9. **LTI Integration**
|
||||
- LTI 1.3 support for quizzes, publish score to LMS
|
||||
- LTI launch handling in `LtiController`
|
||||
89
Dockerfile
89
Dockerfile
@@ -1,28 +1,22 @@
|
||||
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
|
||||
# Alpine to avoid DNS resolution issues in production.
|
||||
#
|
||||
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
|
||||
# https://hub.docker.com/_/ubuntu?tab=tags
|
||||
#
|
||||
#
|
||||
# This file is based on these images:
|
||||
#
|
||||
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.13.2-erlang-24.2.1-debian-bullseye-20210902-slim
|
||||
#
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.16.0-erlang-26.2.1-alpine-3.18.4"
|
||||
ARG RUNNER_IMAGE="alpine:3.18.4"
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:1.18.4-erlang-28.0.1-ubuntu-noble-20250619"
|
||||
ARG RUNNER_IMAGE="ubuntu:24.04"
|
||||
|
||||
FROM ${BUILDER_IMAGE} as builder
|
||||
|
||||
# install build dependencies
|
||||
# RUN apt-get update -y && apt-get install -y curl build-essential git \
|
||||
# && apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
RUN apk add --no-cache -U build-base git curl bash ca-certificates nodejs npm openssl ncurses
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
curl \
|
||||
bash \
|
||||
ca-certificates \
|
||||
nodejs \
|
||||
npm \
|
||||
openssl \
|
||||
libncurses5-dev \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_VERSION 16.20.0
|
||||
ENV NODE_VERSION 22.17.0
|
||||
ENV PRESENTATION_STORAGE_DIR /app/uploads
|
||||
|
||||
# custom ERL_FLAGS are passed for (public) multi-platform builds
|
||||
@@ -30,19 +24,6 @@ ENV PRESENTATION_STORAGE_DIR /app/uploads
|
||||
ARG ERL_FLAGS
|
||||
ENV ERL_FLAGS=$ERL_FLAGS
|
||||
|
||||
# Install nvm with node and npm
|
||||
# RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash \
|
||||
# && . $HOME/.nvm/nvm.sh \
|
||||
# && nvm install $NODE_VERSION \
|
||||
# && nvm alias default $NODE_VERSION \
|
||||
# && nvm use default
|
||||
|
||||
# ENV NODE_PATH $HOME/.nvm/versions/node/v$NODE_VERSION/lib/node_modules
|
||||
# ENV PATH $HOME/.nvm/versions/node/v$NODE_VERSION/bin:$PATH
|
||||
|
||||
# RUN ln -sf $HOME/.nvm/versions/node/v$NODE_VERSION/bin/npm /usr/bin/npm
|
||||
# RUN ln -sf $HOME/.nvm/versions/node/v$NODE_VERSION/bin/node /usr/bin/node
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
@@ -66,25 +47,14 @@ RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
# note: if your project uses a tool like https://purgecss.com/,
|
||||
# which customizes asset compilation based on what it finds in
|
||||
# your Elixir templates, you will need to move the asset compilation
|
||||
# step down so that `lib` is available.
|
||||
COPY assets assets
|
||||
|
||||
# Compile the release
|
||||
COPY lib lib
|
||||
|
||||
RUN mix compile
|
||||
|
||||
RUN npm install -g sass
|
||||
RUN cd assets && npm i && \
|
||||
sass --no-source-map --style=compressed css/custom.scss ../priv/static/assets/custom.css
|
||||
RUN mix assets.deploy
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy.nosass
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
@@ -94,20 +64,27 @@ RUN mix release
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
# RUN apt-get update -y && apt-get install -y curl libstdc++6 openssl libncurses5 locales ghostscript default-jre libreoffice-java-common \
|
||||
# && apt-get install -y libreoffice --no-install-recommends && apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
RUN apk add --no-cache curl libstdc++6 openssl ncurses ghostscript openjdk11-jre
|
||||
RUN apt-get update -y && apt-get install -y curl libstdc++6 openssl locales ghostscript default-jre libreoffice-java-common \
|
||||
&& apt-get install -y libreoffice --no-install-recommends && apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
# RUN apk add --no-cache curl libstdc++ openssl ncurses ghostscript openjdk11-jre
|
||||
|
||||
# Install LibreOffice & Common Fonts
|
||||
RUN apk --no-cache add bash libreoffice util-linux libreoffice-common \
|
||||
font-droid-nonlatin font-droid ttf-dejavu ttf-freefont ttf-liberation && \
|
||||
rm -rf /var/cache/apk/*
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libreoffice \
|
||||
fonts-dejavu \
|
||||
fonts-freefont-ttf \
|
||||
fonts-liberation \
|
||||
fonts-droid-fallback \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Microsoft Core Fonts
|
||||
RUN apk --no-cache add msttcorefonts-installer fontconfig && \
|
||||
update-ms-fonts && \
|
||||
fc-cache -f && \
|
||||
rm -rf /var/cache/apk/*
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ttf-mscorefonts-installer \
|
||||
fontconfig \
|
||||
&& fc-cache -f \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
|
||||
@@ -61,19 +61,18 @@ RUN ARCH=$(uname -m) && \
|
||||
&& rm asdf-v${ASDF_VERSION}-linux-${ASDF_ARCH}.tar.gz \
|
||||
&& mv asdf /usr/bin/
|
||||
|
||||
|
||||
# Install Erlang and Elixir using asdf
|
||||
RUN asdf plugin add erlang \
|
||||
&& asdf plugin add elixir \
|
||||
&& asdf install erlang 26.2.1 \
|
||||
&& asdf install elixir 1.16.0 \
|
||||
&& asdf set -u erlang 26.2.1 \
|
||||
&& asdf set -u elixir 1.16.0
|
||||
&& asdf install erlang 28.0.1 \
|
||||
&& asdf install elixir 1.18.4-otp-28 \
|
||||
&& asdf set -u erlang 28.0.1 \
|
||||
&& asdf set -u elixir 1.18.4-otp-28
|
||||
|
||||
# Install Node.js using asdf
|
||||
RUN asdf plugin add nodejs \
|
||||
&& asdf install nodejs 22.1.0 \
|
||||
&& asdf set -u nodejs 22.1.0
|
||||
&& asdf install nodejs 22.17.0 \
|
||||
&& asdf set -u nodejs 22.17.0
|
||||
|
||||
ENV PATH="/root/.asdf/shims:$PATH"
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Claper has a two-sided mission:
|
||||
- The first one is to help these people presenting an idea or a message by giving them the opportunity to make their presentation unique and to have real-time feedback from their audience.
|
||||
- The second one is to help each participant to take their place, to be an actor in the presentation, in the meeting and to feel important and useful.
|
||||
|
||||
Supported languages: 🇬🇧 English, 🇫🇷 French, 🇩🇪 German, 🇪🇸 Spanish, 🇳🇱 Dutch, 🇮🇹 Italian
|
||||
Supported languages: 🇬🇧 English, 🇫🇷 French, 🇩🇪 German, 🇪🇸 Spanish, 🇳🇱 Dutch, 🇮🇹 Italian, 🇭🇺 Hungarian, 🇱🇻 Latvian
|
||||
|
||||
### Built With
|
||||
|
||||
|
||||
63
assets/css/admin.css
Normal file
63
assets/css/admin.css
Normal file
@@ -0,0 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
@utility btn {
|
||||
@apply rounded-full !font-display text-small-body;
|
||||
}
|
||||
|
||||
@utility input {
|
||||
@apply rounded-full focus:outline-none focus-within:outline-none focus:border-2 focus-within:border-2 !font-display text-small-body transition-all;
|
||||
}
|
||||
|
||||
@utility select {
|
||||
@apply rounded-full focus:outline-none focus-within:outline-none focus:border-2 focus-within:border-2 !font-display text-small-body transition-all;
|
||||
}
|
||||
|
||||
@utility label {
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
@utility fieldset-legend {
|
||||
@apply ml-3;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "claper";
|
||||
default: true; /* set as default */
|
||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||
color-scheme: light; /* color of browser-provided UI */
|
||||
|
||||
--color-primary: #140753;
|
||||
--color-primary-content: #ffffff;
|
||||
--color-secondary: #f4f4f4;
|
||||
--color-secondary-content: #140553;
|
||||
--color-accent: #8611ed;
|
||||
--color-accent-content: #ffffff;
|
||||
--color-neutral: #000000;
|
||||
--color-neutral-content: #ffffff;
|
||||
--color-info: #79bfe2;
|
||||
--color-info-content: #0e3649;
|
||||
--color-success: #3cb957;
|
||||
--color-success-content: #143e1d;
|
||||
--color-warning: #ffb62e;
|
||||
--color-warning-content: #523500;
|
||||
--color-error: #e7000b;
|
||||
--color-error-content: #fff;
|
||||
/* border radius */
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.25rem;
|
||||
--radius-box: 0.5rem;
|
||||
|
||||
/* base sizes */
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
|
||||
/* border size */
|
||||
--border: 1px;
|
||||
|
||||
/* effects */
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@import "./modern.css" layer(theme);
|
||||
@@ -1,12 +1,26 @@
|
||||
@import url('air-datepicker/air-datepicker.css');
|
||||
@import url("animate.css/animate.min.css");
|
||||
@import 'air-datepicker/air-datepicker.css';
|
||||
@import 'animate.css/animate.min.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@import './theme.css' layer(theme);
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: light --default;
|
||||
include: toggle;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
@@ -19,19 +33,19 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
.alert-info {
|
||||
color: #31708f;
|
||||
background-color: #d9edf7;
|
||||
border-color: #bce8f1;
|
||||
color: var(--color-primary-700);
|
||||
background-color: var(--color-primary-100);
|
||||
border-color: var(--color-primary-200);
|
||||
}
|
||||
.alert-warning {
|
||||
color: #8a6d3b;
|
||||
background-color: #fcf8e3;
|
||||
border-color: #faebcc;
|
||||
color: var(--color-supporting-yellow-700);
|
||||
background-color: var(--color-supporting-yellow-50);
|
||||
border-color: var(--color-supporting-yellow-100);
|
||||
}
|
||||
.alert-danger {
|
||||
color: #a94442;
|
||||
background-color: #f2dede;
|
||||
border-color: #ebccd1;
|
||||
color: var(--color-supporting-red-700);
|
||||
background-color: var(--color-supporting-red-100);
|
||||
border-color: var(--color-supporting-red-200);
|
||||
}
|
||||
.alert p {
|
||||
margin-bottom: 0;
|
||||
@@ -40,7 +54,7 @@
|
||||
display: none;
|
||||
}
|
||||
.invalid-feedback {
|
||||
color: #a94442;
|
||||
color: var(--color-supporting-red-700);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -80,12 +94,13 @@
|
||||
background-color: #fefefe;
|
||||
margin: 15vh auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
width: 80%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.phx-modal-close {
|
||||
color: #aaa;
|
||||
color: var(--color-neutral-400);
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
@@ -93,7 +108,7 @@
|
||||
|
||||
.phx-modal-close:hover,
|
||||
.phx-modal-close:focus {
|
||||
color: black;
|
||||
color: var(--color-neutral-900);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -104,13 +119,21 @@
|
||||
.input:focus~.label,
|
||||
.input:active~.label,
|
||||
.input.filled~.label {
|
||||
@apply text-sm transition-all duration-100 -top-1.5 text-primary-500;
|
||||
font-size: 0.875rem;
|
||||
transition-property: all;
|
||||
transition-duration: 100ms;
|
||||
top: -0.375rem;
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
.input:focus~.icon,
|
||||
.input:active~.icon,
|
||||
.input.filled~.icon {
|
||||
@apply transition-all duration-100 left-3 top-6 h-5;
|
||||
transition-property: all;
|
||||
transition-duration: 100ms;
|
||||
left: 0.75rem;
|
||||
top: 1.5rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.date-placeholder-hidden::-webkit-datetime-edit {
|
||||
@@ -284,8 +307,22 @@
|
||||
url('/fonts/Roboto/roboto-v29-latin-900italic.svg#Roboto') format('svg'); /* Legacy iOS */
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 100 300 400 500 700 900;
|
||||
src: url('/fonts/Montserrat/Montserrat-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: italic;
|
||||
font-weight: 100 300 400 500 700 900;
|
||||
src: url('/fonts/Montserrat/Montserrat-Italic-VariableFont_wght.ttf');
|
||||
}
|
||||
|
||||
.bg-gradient-animate {
|
||||
background: linear-gradient(-45deg, #b80fef, #8611ed, #14bfdb, #14bfdb);
|
||||
background: linear-gradient(-45deg, var(--color-secondary-500), var(--color-secondary-600), var(--color-primary-400), var(--color-primary-400));
|
||||
background-size: 400% 400%;
|
||||
animation: gradient 15s ease infinite;
|
||||
}
|
||||
@@ -305,7 +342,7 @@
|
||||
.arrow_box {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 2px solid #e1e1e1;
|
||||
border: 2px solid var(--color-neutral-100);
|
||||
}
|
||||
.arrow_box.arrow_right:after, .arrow_box.arrow_right:before {
|
||||
left: 100%;
|
||||
@@ -419,8 +456,7 @@
|
||||
left: 0;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
border-top: 3px solid;
|
||||
@apply border-supporting-red-600;
|
||||
border-top: var(--color-supporting-red-600) 3px solid;
|
||||
|
||||
-webkit-transform:rotate(-20deg);
|
||||
-moz-transform:rotate(-20deg);
|
||||
@@ -431,15 +467,16 @@
|
||||
|
||||
/* Air datepicker */
|
||||
.air-datepicker-body--day-name {
|
||||
@apply text-primary-600;
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-selected-, .air-datepicker-cell.-selected-.-current- {
|
||||
@apply bg-primary-500 text-white hover:bg-primary-600;
|
||||
background-color: var(--color-primary-500);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.air-datepicker-cell.-current- {
|
||||
@apply text-secondary-500;
|
||||
color: var(--color-secondary-500);
|
||||
}
|
||||
|
||||
.animate__slow_slow {
|
||||
|
||||
152
assets/css/modern.css
Normal file
152
assets/css/modern.css
Normal file
@@ -0,0 +1,152 @@
|
||||
@theme {
|
||||
/* Colors */
|
||||
/* Main colors */
|
||||
--color-*: initial;
|
||||
--color-sky-blue: #81EBFE;
|
||||
--color-teal: #14BFDB;
|
||||
--color-azure: #29ACED;
|
||||
--color-mauve: #8611ED;
|
||||
--color-purple: #B80FEF;
|
||||
--color-navy: #140553;
|
||||
|
||||
/* Light colors */
|
||||
--color-cold-pink: #F3DEFA;
|
||||
--color-warm-pink: #FFD2F6;
|
||||
--color-yeallaw: #FFEAC2;
|
||||
--color-pistacio: #D4FFF4;
|
||||
--color-sky-tern: #C8EDFF;
|
||||
--color-tealy: #81EBFE;
|
||||
--color-earl-night: #0A7BB4;
|
||||
--color-dark-night: #184992;
|
||||
|
||||
/* Gradient */
|
||||
--gradient-primary: 123deg, var(--color-sky-blue) -36%, var(--color-teal) -12%, var(--color-azure) 21%, var(--color-mauve) 81%, var(--color-purple) 130%;
|
||||
--gradient-secondary: 123deg, rgba(129, 235, 254, 0.9) -36%, rgba(20, 191, 219, 0.9) -12%, rgba(41, 172, 237, 0.9) 21%, rgba(134, 17, 237, 0.9) 81%, rgba(184, 15, 239, 0.9) 130%;
|
||||
|
||||
/* Grayscale */
|
||||
--color-white: #FFFFFF;
|
||||
--color-black: #191919;
|
||||
--color-gray-20: #CCCCCC;
|
||||
--color-gray-40: #9F9F9F;
|
||||
--color-gray-60: #737373;
|
||||
--color-gray-80: #464646;
|
||||
--color-gray-100: #191919;
|
||||
--color-gray-120: #000000;
|
||||
|
||||
--color-platinum: #F0F0F0;
|
||||
--color-platinum-20: #FFFFFF;
|
||||
--color-platinum-40: #FCFCFC;
|
||||
--color-platinum-60: #F8F8F8;
|
||||
--color-platinum-80: #F4F4F4;
|
||||
--color-platinum-100: #F0F0F0;
|
||||
--color-platinum-120: #EEEEEE;
|
||||
|
||||
/* Typography */
|
||||
--font-display: "Montserrat", sans-serif;
|
||||
|
||||
/* Font Sizes - Desktop */
|
||||
--text-h1: 80px;
|
||||
--leading-h1: 120%;
|
||||
--font-weight-h1: 900;
|
||||
|
||||
--text-h2: 40px;
|
||||
--leading-h2: 120%;
|
||||
--font-weight-h2: 700;
|
||||
|
||||
--text-h3: 32px;
|
||||
--leading-h3: 120%;
|
||||
--font-weight-h3: 600;
|
||||
|
||||
--text-h4: 24px;
|
||||
--leading-h4: 120%;
|
||||
--font-weight-h4: 500;
|
||||
|
||||
--text-h5: 18px;
|
||||
--leading-h5: 120%;
|
||||
--font-weight-h5: 600;
|
||||
|
||||
--text-h6: 16px;
|
||||
--leading-h6: 120%;
|
||||
--font-weight-h6: 500;
|
||||
|
||||
--text-subheading: 20px;
|
||||
--leading-subheading: 150%;
|
||||
--font-weight-subheading: 300;
|
||||
|
||||
--text-body-bold: 16px;
|
||||
--leading-body-bold: 150%;
|
||||
--font-weight-body-bold: 700;
|
||||
|
||||
--text-body: 16px;
|
||||
--leading-body: 150%;
|
||||
--font-weight-body: 400;
|
||||
|
||||
--text-small-body-bold: 14px;
|
||||
--leading-small-body-bold: 150%;
|
||||
--font-weight-small-body-bold: 600;
|
||||
|
||||
--text-small-body: 14px;
|
||||
--leading-small-body: 150%;
|
||||
--font-weight-small-body: 400;
|
||||
|
||||
--text-caption: 14px;
|
||||
--leading-caption: 150%;
|
||||
--font-weight-caption: 300;
|
||||
|
||||
/* Font Sizes - Mobile */
|
||||
--text-mobile-h1: 32px;
|
||||
--leading-mobile-h1: 120%;
|
||||
--font-weight-mobile-h1: 900;
|
||||
|
||||
--text-mobile-h2: 28px;
|
||||
--leading-mobile-h2: 120%;
|
||||
--font-weight-mobile-h2: 700;
|
||||
|
||||
--text-mobile-h3: 22px;
|
||||
--leading-mobile-h3: 120%;
|
||||
--font-weight-mobile-h3: 600;
|
||||
|
||||
--text-mobile-h4: 18px;
|
||||
--leading-mobile-h4: 120%;
|
||||
--font-weight-mobile-h4: 500;
|
||||
|
||||
--text-mobile-h5: 16px;
|
||||
--leading-mobile-h5: 120%;
|
||||
--font-weight-mobile-h5: 600;
|
||||
|
||||
--text-mobile-h6: 14px;
|
||||
--leading-mobile-h6: 120%;
|
||||
--font-weight-mobile-h6: 500;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-0: 0px;
|
||||
--spacing-4: 4px;
|
||||
--spacing-8: 8px;
|
||||
--spacing-12: 12px;
|
||||
--spacing-16: 16px;
|
||||
--spacing-20: 20px;
|
||||
--spacing-24: 24px;
|
||||
--spacing-28: 28px;
|
||||
--spacing-32: 32px;
|
||||
--spacing-36: 36px;
|
||||
--spacing-40: 40px;
|
||||
--spacing-44: 44px;
|
||||
--spacing-48: 48px;
|
||||
--spacing-52: 52px;
|
||||
--spacing-56: 56px;
|
||||
--spacing-60: 60px;
|
||||
--spacing-64: 64px;
|
||||
--spacing-68: 68px;
|
||||
--spacing-72: 72px;
|
||||
--spacing-76: 76px;
|
||||
--spacing-80: 80px;
|
||||
--spacing-128: 128px;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-3xl: 0px 902px 253px 0px rgba(65, 69, 124, 0.00), 0px 577px 231px 0px rgba(65, 69, 124, 0.01), 0px 325px 195px 0px rgba(65, 69, 124, 0.05), 0px 144px 144px 0px rgba(65, 69, 124, 0.09), 0px 36px 79px 0px rgba(65, 69, 124, 0.10)
|
||||
|
||||
}
|
||||
87
assets/css/theme.css
Normal file
87
assets/css/theme.css
Normal file
@@ -0,0 +1,87 @@
|
||||
|
||||
@theme {
|
||||
/* Primary Colors (water-blue) */
|
||||
--color-primary-50: #E3F2FD;
|
||||
--color-primary-100: #C2E3FA;
|
||||
--color-primary-200: #84C8F6;
|
||||
--color-primary-300: #3DA7F0;
|
||||
--color-primary-400: #1395EC;
|
||||
--color-primary-500: #1186D5;
|
||||
--color-primary-600: #0D65A1;
|
||||
--color-primary-700: #0A5689;
|
||||
--color-primary-800: #0A4B76;
|
||||
--color-primary-900: #073250;
|
||||
|
||||
/* Secondary Colors (electric-purple) */
|
||||
--color-secondary-50: #F2E0FF;
|
||||
--color-secondary-100: #E3BDFF;
|
||||
--color-secondary-200: #C77AFF;
|
||||
--color-secondary-300: #A62EFF;
|
||||
--color-secondary-400: #9200FF;
|
||||
--color-secondary-500: #A327FF;
|
||||
--color-secondary-600: #6400AD;
|
||||
--color-secondary-700: #550094;
|
||||
--color-secondary-800: #490080;
|
||||
--color-secondary-900: #320057;
|
||||
|
||||
/* Neutral Colors (wedgewood) */
|
||||
--color-neutral-50: #F0F4F8;
|
||||
--color-neutral-100: #D9E3ED;
|
||||
--color-neutral-200: #B9CCDF;
|
||||
--color-neutral-300: #97B3CE;
|
||||
--color-neutral-400: #7499BE;
|
||||
--color-neutral-500: #507DAA;
|
||||
--color-neutral-600: #3F6388;
|
||||
--color-neutral-700: #314D68;
|
||||
--color-neutral-800: #253B50;
|
||||
--color-neutral-900: #1A2938;
|
||||
|
||||
/* Supporting Red Colors (rose-madder) */
|
||||
--color-supporting-red-50: #FCEDEE;
|
||||
--color-supporting-red-100: #F9D5D7;
|
||||
--color-supporting-red-200: #F3ABB0;
|
||||
--color-supporting-red-300: #ED8188;
|
||||
--color-supporting-red-400: #E75761;
|
||||
--color-supporting-red-500: #E12D39;
|
||||
--color-supporting-red-600: #B4242E;
|
||||
--color-supporting-red-700: #871B22;
|
||||
--color-supporting-red-800: #5A1217;
|
||||
--color-supporting-red-900: #2D090B;
|
||||
|
||||
/* Supporting Yellow Colors (school-bus-yellow) */
|
||||
--color-supporting-yellow-50: #FFFBEB;
|
||||
--color-supporting-yellow-100: #FEF3C7;
|
||||
--color-supporting-yellow-200: #FDE68A;
|
||||
--color-supporting-yellow-300: #FCD34D;
|
||||
--color-supporting-yellow-400: #FBBF24;
|
||||
--color-supporting-yellow-500: #F59E0B;
|
||||
--color-supporting-yellow-600: #D97706;
|
||||
--color-supporting-yellow-700: #B45309;
|
||||
--color-supporting-yellow-800: #92400E;
|
||||
--color-supporting-yellow-900: #78350F;
|
||||
|
||||
/* Supporting Green Colors (green-teal) */
|
||||
--color-supporting-green-50: #ECFDF5;
|
||||
--color-supporting-green-100: #D1FAE5;
|
||||
--color-supporting-green-200: #A7F3D0;
|
||||
--color-supporting-green-300: #6EE7B7;
|
||||
--color-supporting-green-400: #34D399;
|
||||
--color-supporting-green-500: #10B981;
|
||||
--color-supporting-green-600: #059669;
|
||||
--color-supporting-green-700: #047857;
|
||||
--color-supporting-green-800: #065F46;
|
||||
--color-supporting-green-900: #064E3B;
|
||||
|
||||
/* Font Family */
|
||||
--font-family-sans: 'Roboto', sans-serif;
|
||||
--font-family-serif: 'Merriweather', serif;
|
||||
--font-display: 'Montserrat', sans-serif;
|
||||
|
||||
/* Box Shadows */
|
||||
--shadow-base: 0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06);
|
||||
--shadow-lg: 0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1);
|
||||
--shadow-xl: 0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1);
|
||||
--shadow-2xl: 0px 25px 50px 0px rgba(0,0,0,0.25);
|
||||
--shadow-inner: inset 0px 2px 4px 0px rgba(0,0,0,0.06);
|
||||
}
|
||||
207
assets/js/admin-charts.js
Normal file
207
assets/js/admin-charts.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineController,
|
||||
Filler
|
||||
} from 'chart.js';
|
||||
import 'chartjs-adapter-moment';
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineController,
|
||||
Filler
|
||||
);
|
||||
|
||||
export class AdminCharts {
|
||||
constructor() {
|
||||
this.charts = {};
|
||||
this.defaultOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
size: 12,
|
||||
family: 'Inter, system-ui, sans-serif'
|
||||
},
|
||||
color: 'rgba(255, 255, 255, 0.9)'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: 'rgba(255, 255, 255, 0.9)',
|
||||
bodyColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: false,
|
||||
padding: 12,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
weight: 'bold'
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
callback: function(value) {
|
||||
return Number.isInteger(value) ? value : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
fill: true
|
||||
},
|
||||
point: {
|
||||
radius: 0,
|
||||
hoverRadius: 6,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: 'rgba(255, 255, 255, 0.9)'
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800,
|
||||
easing: 'easeInOutQuart'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
createUsersChart(canvasId, data) {
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return null;
|
||||
|
||||
const chartData = {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Users',
|
||||
data: data.values,
|
||||
borderColor: 'rgba(102, 126, 234, 1)',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
pointBackgroundColor: 'rgba(102, 126, 234, 1)',
|
||||
pointBorderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
pointHoverBackgroundColor: 'rgba(102, 126, 234, 1)',
|
||||
pointHoverBorderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}]
|
||||
};
|
||||
|
||||
if (this.charts[canvasId]) {
|
||||
this.charts[canvasId].destroy();
|
||||
}
|
||||
|
||||
this.charts[canvasId] = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: this.defaultOptions
|
||||
});
|
||||
|
||||
return this.charts[canvasId];
|
||||
}
|
||||
|
||||
createEventsChart(canvasId, data) {
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) return null;
|
||||
|
||||
const chartData = {
|
||||
labels: data.labels,
|
||||
datasets: [{
|
||||
label: 'Events',
|
||||
data: data.values,
|
||||
borderColor: 'rgba(16, 185, 129, 1)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
pointBackgroundColor: 'rgba(16, 185, 129, 1)',
|
||||
pointBorderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
pointHoverBackgroundColor: 'rgba(16, 185, 129, 1)',
|
||||
pointHoverBorderColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}]
|
||||
};
|
||||
|
||||
if (this.charts[canvasId]) {
|
||||
this.charts[canvasId].destroy();
|
||||
}
|
||||
|
||||
this.charts[canvasId] = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: this.defaultOptions
|
||||
});
|
||||
|
||||
return this.charts[canvasId];
|
||||
}
|
||||
|
||||
updateChart(canvasId, data) {
|
||||
const chart = this.charts[canvasId];
|
||||
if (!chart) return;
|
||||
|
||||
chart.data.labels = data.labels;
|
||||
chart.data.datasets[0].data = data.values;
|
||||
chart.update('active');
|
||||
}
|
||||
|
||||
destroyChart(canvasId) {
|
||||
if (this.charts[canvasId]) {
|
||||
this.charts[canvasId].destroy();
|
||||
delete this.charts[canvasId];
|
||||
}
|
||||
}
|
||||
|
||||
destroyAllCharts() {
|
||||
Object.keys(this.charts).forEach(canvasId => {
|
||||
this.destroyChart(canvasId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create global instance
|
||||
window.AdminCharts = new AdminCharts();
|
||||
176
assets/js/app.js
176
assets/js/app.js
@@ -13,40 +13,71 @@ import airdatepickerLocaleDe from "air-datepicker/locale/de";
|
||||
import airdatepickerLocaleEs from "air-datepicker/locale/es";
|
||||
import airdatepickerLocaleNl from "air-datepicker/locale/nl";
|
||||
import airdatepickerLocaleIt from "air-datepicker/locale/it";
|
||||
import airdatepickerLocaleHu from "air-datepicker/locale/hu";
|
||||
import "moment/locale/de";
|
||||
import "moment/locale/fr";
|
||||
import "moment/locale/es";
|
||||
import "moment/locale/nl";
|
||||
import "moment/locale/it";
|
||||
import "moment/locale/hu";
|
||||
import "moment/locale/lv";
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { Presenter } from "./presenter";
|
||||
import { Manager } from "./manager";
|
||||
import Split from "split-grid";
|
||||
import CustomHooks from "./hooks";
|
||||
import { TourGuideClient } from "@sjmc11/tourguidejs/src/Tour";
|
||||
import "./admin-charts.js";
|
||||
window.moment = moment;
|
||||
|
||||
const supportedLocales = ["en", "fr", "de", "es", "nl", "it"];
|
||||
// Get supported locales from backend configuration or fallback to default list
|
||||
const supportedLocales = window.claperConfig?.supportedLocales || [
|
||||
"en",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"nl",
|
||||
"it",
|
||||
"hu",
|
||||
"lv",
|
||||
];
|
||||
|
||||
const airdatePickrSupportedLocales = window.claperConfig?.supportedLocales || [
|
||||
"en",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"nl",
|
||||
"it",
|
||||
"hu",
|
||||
];
|
||||
|
||||
var locale =
|
||||
document.querySelector("html").getAttribute("lang") ||
|
||||
navigator.language.split("-")[0];
|
||||
|
||||
var airdatepickrLocale = locale;
|
||||
|
||||
if (!supportedLocales.includes(locale)) {
|
||||
locale = "en";
|
||||
}
|
||||
if (!airdatePickrSupportedLocales.includes(locale)) {
|
||||
airdatepickrLocale = "en";
|
||||
}
|
||||
|
||||
window.moment.locale("en");
|
||||
window.moment.locale(locale);
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
|
||||
let airdatepickerLocale = {
|
||||
let airdatePickrLocales = {
|
||||
en: airdatepickerLocaleEn,
|
||||
fr: airdatepickerLocaleFr,
|
||||
de: airdatepickerLocaleDe,
|
||||
es: airdatepickerLocaleEs,
|
||||
nl: airdatepickerLocaleNl,
|
||||
it: airdatepickerLocaleIt,
|
||||
hu: airdatepickerLocaleHu,
|
||||
};
|
||||
let csrfToken = document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
@@ -69,8 +100,8 @@ Hooks.EmbeddedBanner = {
|
||||
Hooks.TourGuide = {
|
||||
mounted() {
|
||||
this.triggerDiv = document.querySelector(this.el.dataset.btnTrigger);
|
||||
this.btnTrigger = this.triggerDiv.querySelector('.open');
|
||||
this.closeBtnTrigger = this.triggerDiv.querySelector('.close');
|
||||
this.btnTrigger = this.triggerDiv.querySelector(".open");
|
||||
this.closeBtnTrigger = this.triggerDiv.querySelector(".close");
|
||||
|
||||
this.tour = new TourGuideClient({
|
||||
nextLabel: this.el.dataset.nextLabel,
|
||||
@@ -105,7 +136,7 @@ Hooks.TourGuide = {
|
||||
destroyed() {
|
||||
this.btnTrigger.removeEventListener("click", () => {
|
||||
this.startTour();
|
||||
});
|
||||
});
|
||||
this.closeBtnTrigger.removeEventListener("click", () => {
|
||||
this.triggerDiv.classList.add("hidden");
|
||||
this.tour.finishTour(true, this.el.dataset.group);
|
||||
@@ -200,26 +231,36 @@ Hooks.Scroll = {
|
||||
Hooks.ScrollIntoDiv = {
|
||||
mounted() {
|
||||
let useParent = this.el.dataset.useParent === "true";
|
||||
this.scrollElement = this.el.dataset.useParent === "true" ? this.el.parentElement : this.el;
|
||||
this.scrollElement =
|
||||
this.el.dataset.useParent === "true" ? this.el.parentElement : this.el;
|
||||
this.checkIfAtBottom();
|
||||
this.scrollToBottom(true);
|
||||
this.handleEvent("scroll", () => this.scrollToBottom());
|
||||
this.scrollElement.addEventListener("scroll", () => this.checkIfAtBottom());
|
||||
},
|
||||
checkIfAtBottom() {
|
||||
this.isAtBottom = this.scrollElement.scrollHeight - this.scrollElement.scrollTop - this.scrollElement.clientHeight <= 30;
|
||||
this.isAtBottom =
|
||||
this.scrollElement.scrollHeight -
|
||||
this.scrollElement.scrollTop -
|
||||
this.scrollElement.clientHeight <=
|
||||
30;
|
||||
},
|
||||
scrollToBottom(force = false) {
|
||||
if (force || this.isAtBottom) {
|
||||
this.scrollElement.scrollTo({ top: this.scrollElement.scrollHeight, behavior: "smooth" });
|
||||
this.scrollElement.scrollTo({
|
||||
top: this.scrollElement.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
destroyed() {
|
||||
this.scrollElement.removeEventListener("scroll", () => this.checkIfAtBottom());
|
||||
}
|
||||
this.scrollElement.removeEventListener("scroll", () =>
|
||||
this.checkIfAtBottom(),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.NicknamePicker = {
|
||||
@@ -243,7 +284,7 @@ Hooks.NicknamePicker = {
|
||||
clicked(e) {
|
||||
let nickname = prompt(
|
||||
this.el.dataset.prompt,
|
||||
localStorage.getItem("nickname") || ""
|
||||
localStorage.getItem("nickname") || "",
|
||||
);
|
||||
|
||||
if (nickname) {
|
||||
@@ -265,6 +306,19 @@ Hooks.EmptyNickname = {
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.SearchableSelect = {
|
||||
mounted() {
|
||||
this.handleEvent("update_hidden_field", (payload) => {
|
||||
if (payload.id === this.el.id) {
|
||||
this.el.value = payload.value;
|
||||
// Trigger a change event to update the form
|
||||
const event = new Event('input', { bubbles: true });
|
||||
this.el.dispatchEvent(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PostForm = {
|
||||
onPress(e, submitBtn, TA) {
|
||||
if (e.key == "Enter" && !e.shiftKey) {
|
||||
@@ -353,7 +407,7 @@ Hooks.Pickr = {
|
||||
const utc = moment(date).utc().format("YYYY-MM-DDTHH:mm:ss");
|
||||
utcTime.value = utc;
|
||||
},
|
||||
locale: airdatepickerLocale[locale],
|
||||
locale: airdatePickrLocales[airdatepickrLocale],
|
||||
});
|
||||
},
|
||||
updated() {},
|
||||
@@ -392,7 +446,7 @@ Hooks.OpenPresenter = {
|
||||
window.open(
|
||||
this.el.dataset.url,
|
||||
"newwindow",
|
||||
"width=" + window.screen.width + ",height=" + window.screen.height
|
||||
"width=" + window.screen.width + ",height=" + window.screen.height,
|
||||
);
|
||||
},
|
||||
mounted() {
|
||||
@@ -417,7 +471,12 @@ Hooks.GlobalReacts = {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = svgContent;
|
||||
const svgElement = container.firstChild;
|
||||
svgElement.classList.add("react-animation", "absolute", "transform", "opacity-0");
|
||||
svgElement.classList.add(
|
||||
"react-animation",
|
||||
"absolute",
|
||||
"transform",
|
||||
"opacity-0",
|
||||
);
|
||||
svgElement.classList.add(...this.el.className.split(" "));
|
||||
this.el.appendChild(svgElement);
|
||||
}
|
||||
@@ -429,15 +488,17 @@ Hooks.GlobalReacts = {
|
||||
|
||||
preloadSVGs() {
|
||||
const svgTypes = ["heart", "hundred", "clap", "raisehand"];
|
||||
svgTypes.forEach(type => {
|
||||
svgTypes.forEach((type) => {
|
||||
fetch(`/images/icons/${type}.svg`)
|
||||
.then(response => response.text())
|
||||
.then(svgContent => {
|
||||
.then((response) => response.text())
|
||||
.then((svgContent) => {
|
||||
this.svgCache[type] = svgContent;
|
||||
})
|
||||
.catch(error => console.error(`Error loading SVG for ${type}:`, error));
|
||||
.catch((error) =>
|
||||
console.error(`Error loading SVG for ${type}:`, error),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
Hooks.JoinEvent = {
|
||||
mounted() {
|
||||
@@ -570,35 +631,61 @@ Hooks.Dropdown = {
|
||||
},
|
||||
};
|
||||
|
||||
let Uploaders = {};
|
||||
|
||||
Uploaders.S3 = function (entries, onViewError) {
|
||||
entries.forEach((entry) => {
|
||||
let formData = new FormData();
|
||||
let { url, fields } = entry.meta;
|
||||
Object.entries(fields).forEach(([key, val]) => formData.append(key, val));
|
||||
formData.append("file", entry.file);
|
||||
let xhr = new XMLHttpRequest();
|
||||
onViewError(() => xhr.abort());
|
||||
xhr.onload = () =>
|
||||
xhr.status === 204 ? entry.progress(100) : entry.error();
|
||||
xhr.onerror = () => entry.error();
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) {
|
||||
let percent = Math.round((event.loaded / event.total) * 100);
|
||||
if (percent < 100) {
|
||||
entry.progress(percent);
|
||||
}
|
||||
}
|
||||
Hooks.AdminChart = {
|
||||
mounted() {
|
||||
const chartType = this.el.dataset.chartType;
|
||||
const canvasId = this.el.querySelector('canvas').id;
|
||||
const data = JSON.parse(this.el.dataset.chartData);
|
||||
|
||||
if (chartType === 'users') {
|
||||
window.AdminCharts.createUsersChart(canvasId, data);
|
||||
} else if (chartType === 'events') {
|
||||
window.AdminCharts.createEventsChart(canvasId, data);
|
||||
}
|
||||
|
||||
this.handleEvent("update_chart", (newData) => {
|
||||
window.AdminCharts.updateChart(canvasId, newData);
|
||||
});
|
||||
|
||||
xhr.open("POST", url, true);
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
|
||||
updated() {
|
||||
const chartType = this.el.dataset.chartType;
|
||||
const canvasId = this.el.querySelector('canvas').id;
|
||||
const data = JSON.parse(this.el.dataset.chartData);
|
||||
|
||||
if (chartType === 'users') {
|
||||
window.AdminCharts.createUsersChart(canvasId, data);
|
||||
} else if (chartType === 'events') {
|
||||
window.AdminCharts.createEventsChart(canvasId, data);
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
const canvasId = this.el.querySelector('canvas').id;
|
||||
window.AdminCharts.destroyChart(canvasId);
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.CSVDownloader = {
|
||||
mounted() {
|
||||
this.handleEvent("download_csv", ({ filename, content }) => {
|
||||
const blob = new Blob([content], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Merge our custom hooks with the existing hooks
|
||||
Object.assign(Hooks, CustomHooks);
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
uploaders: Uploaders,
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
@@ -629,7 +716,6 @@ window.addEventListener("phx:page-loading-stop", (info) => {
|
||||
topbar.hide();
|
||||
});
|
||||
|
||||
|
||||
const onlineUserTemplate = function (user) {
|
||||
return `
|
||||
<div id="online-user">
|
||||
|
||||
197
assets/js/hooks.js
Normal file
197
assets/js/hooks.js
Normal file
@@ -0,0 +1,197 @@
|
||||
// LiveView hooks for client-side functionality
|
||||
|
||||
const Hooks = {
|
||||
// Hook for handling CSV downloads from LiveView
|
||||
CSVDownloader: {
|
||||
mounted() {
|
||||
this.handleEvent("download_csv", ({ filename, content }) => {
|
||||
// Create a Blob with the CSV content
|
||||
const blob = new Blob([content], { type: "text/csv" });
|
||||
|
||||
// Create a temporary URL for the Blob
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary link element
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", filename);
|
||||
|
||||
// Append the link to the document body
|
||||
document.body.appendChild(link);
|
||||
|
||||
// Trigger the download
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Hook for User Growth Chart
|
||||
UserGrowthChart: {
|
||||
mounted() {
|
||||
// Import Chart.js dynamically
|
||||
import("chart.js/auto").then(({ default: Chart }) => {
|
||||
const ctx = this.el.getContext("2d");
|
||||
const labels = JSON.parse(this.el.dataset.labels);
|
||||
const values = JSON.parse(this.el.dataset.values);
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: "New Users",
|
||||
data: values,
|
||||
borderColor: "#111827",
|
||||
backgroundColor: "rgba(17, 24, 39, 0.05)",
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
pointBackgroundColor: "transparent",
|
||||
pointBorderColor: "transparent"
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: "rgba(17, 24, 39, 0.9)",
|
||||
titleColor: "#fff",
|
||||
bodyColor: "#fff",
|
||||
borderColor: "#111827",
|
||||
borderWidth: 1,
|
||||
cornerRadius: 4,
|
||||
displayColors: false,
|
||||
padding: 8,
|
||||
titleFont: {
|
||||
size: 12
|
||||
},
|
||||
bodyFont: {
|
||||
size: 14,
|
||||
weight: 'bold'
|
||||
},
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.parsed.y + ' users';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Hook for Event Creation Chart
|
||||
EventCreationChart: {
|
||||
mounted() {
|
||||
// Import Chart.js dynamically
|
||||
import("chart.js/auto").then(({ default: Chart }) => {
|
||||
const ctx = this.el.getContext("2d");
|
||||
const labels = JSON.parse(this.el.dataset.labels);
|
||||
const values = JSON.parse(this.el.dataset.values);
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: "New Events",
|
||||
data: values,
|
||||
borderColor: "#111827",
|
||||
backgroundColor: "rgba(17, 24, 39, 0.05)",
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
pointBackgroundColor: "transparent",
|
||||
pointBorderColor: "transparent"
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: "rgba(17, 24, 39, 0.9)",
|
||||
titleColor: "#fff",
|
||||
bodyColor: "#fff",
|
||||
borderColor: "#111827",
|
||||
borderWidth: 1,
|
||||
cornerRadius: 4,
|
||||
displayColors: false,
|
||||
padding: 8,
|
||||
titleFont: {
|
||||
size: 12
|
||||
},
|
||||
bodyFont: {
|
||||
size: 14,
|
||||
weight: 'bold'
|
||||
},
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.parsed.y + ' events';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false
|
||||
},
|
||||
y: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default Hooks;
|
||||
2912
assets/package-lock.json
generated
2912
assets/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,20 @@
|
||||
"deploy": "NODE_ENV=production tailwindcss --postcss --minify --input=css/app.css --output=../priv/static/assets/app.css"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"alpinejs": "^3.13.8",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"esbuild": "^0.20.2",
|
||||
"daisyui": "^5.0.47",
|
||||
"esbuild": "^0.25.5",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-import": "^15.1.0",
|
||||
"tailwindcss": "^3.3.3"
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sjmc11/tourguidejs": "^0.0.16",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"air-datepicker": "^3.5.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"chart.js": "^4.4.0",
|
||||
"chartjs-adapter-moment": "^1.0.1",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"phoenix": "file:../deps/phoenix",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
const { colors: defaultColors } = require("tailwindcss/defaultTheme");
|
||||
|
||||
const colors = {
|
||||
...defaultColors,
|
||||
...{
|
||||
"water-blue": {
|
||||
50: "#E3F2FD",
|
||||
100: "#C2E3FA",
|
||||
200: "#84C8F6",
|
||||
300: "#3DA7F0",
|
||||
400: "#1395EC",
|
||||
500: "#1186D5",
|
||||
600: "#0D65A1",
|
||||
700: "#0A5689",
|
||||
800: "#0A4B76",
|
||||
900: "#073250",
|
||||
},
|
||||
"electric-purple": {
|
||||
50: "#F2E0FF",
|
||||
100: "#E3BDFF",
|
||||
200: "#C77AFF",
|
||||
300: "#A62EFF",
|
||||
400: "#9200FF",
|
||||
500: "#A327FF",
|
||||
600: "#6400AD",
|
||||
700: "#550094",
|
||||
800: "#490080",
|
||||
900: "#320057",
|
||||
},
|
||||
wedgewood: {
|
||||
50: "#F0F4F8",
|
||||
100: "#D9E3ED",
|
||||
200: "#B9CCDF",
|
||||
300: "#97B3CE",
|
||||
400: "#7499BE",
|
||||
500: "#507DAA",
|
||||
600: "#3F6388",
|
||||
700: "#314D68",
|
||||
800: "#253B50",
|
||||
900: "#1A2938",
|
||||
},
|
||||
"rose-madder": {
|
||||
50: "#FCEDEE",
|
||||
100: "#F9D5D7",
|
||||
200: "#F3ABB0",
|
||||
300: "#ED8188",
|
||||
400: "#E75761",
|
||||
500: "#E12D39",
|
||||
600: "#B4242E",
|
||||
700: "#871B22",
|
||||
800: "#5A1217",
|
||||
900: "#2D090B",
|
||||
},
|
||||
"school-bus-yellow": {
|
||||
50: "#FFFBEB",
|
||||
100: "#FEF3C7",
|
||||
200: "#FDE68A",
|
||||
300: "#FCD34D",
|
||||
400: "#FBBF24",
|
||||
500: "#F59E0B",
|
||||
600: "#D97706",
|
||||
700: "#B45309",
|
||||
800: "#92400E",
|
||||
900: "#78350F",
|
||||
},
|
||||
"green-teal": {
|
||||
50: "#ECFDF5",
|
||||
100: "#D1FAE5",
|
||||
200: "#A7F3D0",
|
||||
300: "#6EE7B7",
|
||||
400: "#34D399",
|
||||
500: "#10B981",
|
||||
600: "#059669",
|
||||
700: "#047857",
|
||||
800: "#065F46",
|
||||
900: "#064E3B",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
mode: "jit",
|
||||
content: ["./js/**/*.js", "../lib/*_web/**/*.*ex"],
|
||||
safelist: [
|
||||
"-top-1.5",
|
||||
"top-1",
|
||||
"left-3",
|
||||
"top-6",
|
||||
"h-5",
|
||||
"left-2.5",
|
||||
"top-3",
|
||||
"h-7",
|
||||
"bg-secondary-600",
|
||||
"text-white",
|
||||
"bg-white",
|
||||
"text-gray-600",
|
||||
],
|
||||
darkMode: "media",
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundSize: {
|
||||
"size-200": "200% 200%",
|
||||
},
|
||||
backgroundPosition: {
|
||||
"pos-0": "0% 0%",
|
||||
"pos-100": "100% 100%",
|
||||
},
|
||||
colors: {
|
||||
primary: colors["water-blue"],
|
||||
secondary: colors["electric-purple"],
|
||||
neutral: colors["wedgewood"],
|
||||
"supporting-red": colors["rose-madder"],
|
||||
"supporting-yellow": colors["school-bus-yellow"],
|
||||
"supporting-green": colors["green-teal"],
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Roboto", "sans-serif"],
|
||||
serif: ["Merriweather", "serif"],
|
||||
},
|
||||
boxShadow: {
|
||||
base: "0px 1px 3px 0px rgba(0,0,0,0.1), 0px 1px 2px 0px rgba(0,0,0,0.06)",
|
||||
lg: "0px 4px 6px 0px rgba(0,0,0,0.05), 0px 10px 15px 0px rgba(0,0,0,0.1)",
|
||||
md: "0px 4px 6px 0px rgba(0,0,0,0.1), 0px 2px 4px 0px rgba(0,0,0,0.06)",
|
||||
xl: "0px 10px 10px 0px rgba(0,0,0,0.04), 0px 20px 25px 0px rgba(0,0,0,0.1)",
|
||||
"2xl": "0px 25px 50px 0px rgba(0,0,0,0.25)",
|
||||
inner: "inset 0px 2px 4px 0px rgba(0,0,0,0.06)",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/container-queries")],
|
||||
};
|
||||
@@ -16,15 +16,15 @@
|
||||
value: claper.co
|
||||
- name: DATABASE_URL
|
||||
value: postgresql://claper:claper@10.0.0.6:6432/claper
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
- name: S3_ACCESS_KEY_ID
|
||||
value: XXX
|
||||
- name: AWS_PRES_BUCKET
|
||||
- name: S3_BUCKET
|
||||
value: XXX
|
||||
- name: POOL_SIZE
|
||||
value: "20"
|
||||
- name: AWS_REGION
|
||||
- name: S3_REGION
|
||||
value: eu-west-3
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
- name: S3_SECRET_ACCESS_KEY
|
||||
value: XXX
|
||||
- name: PRESENTATION_STORAGE
|
||||
value: s3
|
||||
|
||||
@@ -36,9 +36,26 @@ config :dart_sass,
|
||||
cd: Path.expand("../assets", __DIR__)
|
||||
]
|
||||
|
||||
config :tailwind,
|
||||
version: "4.1.10",
|
||||
default: [
|
||||
args: ~w(
|
||||
--input=assets/css/app.css
|
||||
--output=priv/static/assets/app.css
|
||||
),
|
||||
cd: Path.expand("..", __DIR__)
|
||||
],
|
||||
admin: [
|
||||
args: ~w(
|
||||
--input=assets/css/admin.css
|
||||
--output=priv/static/assets/admin.css
|
||||
),
|
||||
cd: Path.expand("..", __DIR__)
|
||||
]
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.12.18",
|
||||
version: "0.25.5",
|
||||
default: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
|
||||
@@ -13,19 +13,13 @@ config :claper, ClaperWeb.Endpoint,
|
||||
watchers: [
|
||||
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
|
||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
|
||||
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
||||
tailwind_admin: {Tailwind, :install_and_run, [:admin, ~w(--watch)]},
|
||||
sass: {
|
||||
DartSass,
|
||||
:install_and_run,
|
||||
[:default, ~w(--embed-source-map --source-map-urls=absolute --watch)]
|
||||
},
|
||||
npx: [
|
||||
"tailwindcss",
|
||||
"--input=css/app.css",
|
||||
"--output=../priv/static/assets/app.css",
|
||||
"--postcss",
|
||||
"--watch",
|
||||
cd: Path.expand("../assets", __DIR__)
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
# Watch static and templates for browser reloading.
|
||||
|
||||
@@ -86,9 +86,39 @@ smtp_tls = get_var_from_path_or_env(config_dir, "SMTP_TLS", "always")
|
||||
smtp_auth = get_var_from_path_or_env(config_dir, "SMTP_AUTH", "always")
|
||||
smtp_port = get_int_from_path_or_env(config_dir, "SMTP_PORT", 25)
|
||||
|
||||
aws_access_key_id = get_var_from_path_or_env(config_dir, "AWS_ACCESS_KEY_ID", nil)
|
||||
aws_secret_access_key = get_var_from_path_or_env(config_dir, "AWS_SECRET_ACCESS_KEY", nil)
|
||||
aws_region = get_var_from_path_or_env(config_dir, "AWS_REGION", nil)
|
||||
storage = get_var_from_path_or_env(config_dir, "PRESENTATION_STORAGE", "local")
|
||||
if storage not in ["local", "s3"], do: raise("Invalid PRESENTATION_STORAGE value #{storage}")
|
||||
|
||||
s3_access_key_id = get_var_from_path_or_env(config_dir, "S3_ACCESS_KEY_ID")
|
||||
s3_secret_access_key = get_var_from_path_or_env(config_dir, "S3_SECRET_ACCESS_KEY")
|
||||
s3_region = get_var_from_path_or_env(config_dir, "S3_REGION")
|
||||
s3_bucket = get_var_from_path_or_env(config_dir, "S3_BUCKET")
|
||||
|
||||
if storage == "s3" and
|
||||
not Enum.all?([s3_access_key_id, s3_secret_access_key, s3_region, s3_bucket]) do
|
||||
raise(
|
||||
"S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION and S3_BUCKET required when PRESENTATION_STORAGE=s3"
|
||||
)
|
||||
end
|
||||
|
||||
s3_scheme = get_var_from_path_or_env(config_dir, "S3_SCHEME")
|
||||
s3_host = get_var_from_path_or_env(config_dir, "S3_HOST")
|
||||
s3_port = get_var_from_path_or_env(config_dir, "S3_PORT")
|
||||
|
||||
if storage == "s3" do
|
||||
if !!s3_scheme and !s3_host, do: "S3_HOST required if S3_SCHEME is set"
|
||||
if !s3_scheme and !!s3_host, do: "S3_SCHEME required if S3_HOST is set"
|
||||
end
|
||||
|
||||
s3_public_url =
|
||||
get_var_from_path_or_env(
|
||||
config_dir,
|
||||
"S3_PUBLIC_URL",
|
||||
if(s3_scheme && s3_host,
|
||||
do: s3_scheme <> s3_host <> if(s3_port, do: ":#{s3_port}", else: ""),
|
||||
else: "https://#{s3_bucket}.s3.#{s3_region}.amazonaws.com"
|
||||
)
|
||||
)
|
||||
|
||||
same_site_cookie = get_var_from_path_or_env(config_dir, "SAME_SITE_COOKIE", "Lax")
|
||||
|
||||
@@ -128,6 +158,11 @@ allow_unlink_external_provider =
|
||||
|
||||
logout_redirect_url = get_var_from_path_or_env(config_dir, "LOGOUT_REDIRECT_URL", nil)
|
||||
|
||||
languages =
|
||||
get_var_from_path_or_env(config_dir, "LANGUAGES", "en,fr,es,it,de")
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|
||||
config :claper, :oidc,
|
||||
enabled: oidc_enabled,
|
||||
issuer: oidc_issuer,
|
||||
@@ -166,13 +201,15 @@ config :claper,
|
||||
enable_account_creation: enable_account_creation,
|
||||
email_confirmation: email_confirmation,
|
||||
allow_unlink_external_provider: allow_unlink_external_provider,
|
||||
logout_redirect_url: logout_redirect_url
|
||||
logout_redirect_url: logout_redirect_url,
|
||||
languages: languages
|
||||
|
||||
config :claper, :presentations,
|
||||
max_file_size: max_file_size,
|
||||
storage: get_var_from_path_or_env(config_dir, "PRESENTATION_STORAGE", "local"),
|
||||
aws_bucket: get_var_from_path_or_env(config_dir, "AWS_PRES_BUCKET", nil),
|
||||
resolution: get_var_from_path_or_env(config_dir, "GS_JPG_RESOLUTION", "300x300")
|
||||
storage: storage,
|
||||
s3_bucket: s3_bucket,
|
||||
resolution: get_var_from_path_or_env(config_dir, "GS_JPG_RESOLUTION", "300x300"),
|
||||
s3_public_url: s3_public_url
|
||||
|
||||
config :claper, :mail,
|
||||
from: get_var_from_path_or_env(config_dir, "MAIL_FROM", "noreply@claper.co"),
|
||||
@@ -221,9 +258,10 @@ case mail_transport do
|
||||
end
|
||||
|
||||
config :ex_aws,
|
||||
access_key_id: aws_access_key_id,
|
||||
secret_access_key: aws_secret_access_key,
|
||||
region: aws_region,
|
||||
normalize_path: false
|
||||
access_key_id: s3_access_key_id,
|
||||
secret_access_key: s3_secret_access_key,
|
||||
region: s3_region,
|
||||
normalize_path: false,
|
||||
s3: [scheme: s3_scheme, host: s3_host, port: s3_port]
|
||||
|
||||
config :swoosh, :api_client, Swoosh.ApiClient.Finch
|
||||
|
||||
14
dev.sh
14
dev.sh
@@ -1,14 +0,0 @@
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
args=("$@")
|
||||
|
||||
if [ "${args[0]}" == "start" ]; then
|
||||
mix phx.server
|
||||
elif [ "${args[0]}" == "iex" ]; then
|
||||
iex -S mix
|
||||
else
|
||||
mix "$@"
|
||||
fi
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- "claper-db:/var/lib/postgresql/data"
|
||||
healthcheck:
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
elixir_version=1.13.2
|
||||
erlang_version=24.0
|
||||
@@ -7,7 +7,7 @@ defmodule Claper.Accounts do
|
||||
alias Claper.Accounts
|
||||
alias Claper.Repo
|
||||
|
||||
alias Claper.Accounts.{User, UserToken, UserNotifier}
|
||||
alias Claper.Accounts.{User, UserToken, UserNotifier, Role}
|
||||
|
||||
@doc """
|
||||
Creates a user.
|
||||
@@ -20,6 +20,9 @@ defmodule Claper.Accounts do
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
def create_user(attrs) do
|
||||
# Get user role if not explicitly set
|
||||
attrs = maybe_set_default_role(attrs)
|
||||
|
||||
%User{}
|
||||
|> User.registration_changeset(attrs)
|
||||
|> Repo.insert(returning: [:uuid])
|
||||
@@ -55,18 +58,39 @@ defmodule Claper.Accounts do
|
||||
def get_user_by_email_or_create(email) when is_binary(email) do
|
||||
case get_user_by_email(email) do
|
||||
nil ->
|
||||
create_user(%{
|
||||
attrs = %{
|
||||
email: email,
|
||||
confirmed_at: DateTime.utc_now(),
|
||||
is_randomized_password: true,
|
||||
password: :crypto.strong_rand_bytes(32)
|
||||
})
|
||||
}
|
||||
|
||||
# Set default role if not explicitly set
|
||||
attrs = maybe_set_default_role(attrs)
|
||||
|
||||
create_user(attrs)
|
||||
|
||||
user ->
|
||||
{:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all users that are not deleted.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users()
|
||||
[%User{}, ...]
|
||||
|
||||
"""
|
||||
def list_users(preload \\ []) do
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> Repo.all()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
@@ -147,6 +171,9 @@ defmodule Claper.Accounts do
|
||||
|
||||
"""
|
||||
def register_user(attrs) do
|
||||
# Get user role if not explicitly set
|
||||
attrs = maybe_set_default_role(attrs)
|
||||
|
||||
%User{}
|
||||
|> User.registration_changeset(attrs)
|
||||
|> Repo.insert(returning: [:uuid])
|
||||
@@ -165,6 +192,37 @@ defmodule Claper.Accounts do
|
||||
User.registration_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking user changes for admin operations.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user(%User{} = user, attrs \\ %{}) do
|
||||
User.admin_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a user for admin operations.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user(user, %{field: new_value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user(user, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user(%User{} = user, attrs) do
|
||||
user
|
||||
|> User.admin_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
@@ -262,6 +320,23 @@ defmodule Claper.Accounts do
|
||||
end
|
||||
end
|
||||
|
||||
# Alternative version with different signature - keeping for compatibility
|
||||
def update_user_password(user, %{"current_password" => curr_pw} = attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(curr_pw)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets the user password.
|
||||
## Examples
|
||||
@@ -343,34 +418,6 @@ defmodule Claper.Accounts do
|
||||
User.password_changeset(user, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, "valid password", %{password: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid password", %{password: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, %{"current_password" => curr_pw} = attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(curr_pw)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
@@ -602,4 +649,271 @@ defmodule Claper.Accounts do
|
||||
{:error, changeset} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Role Management
|
||||
|
||||
@doc """
|
||||
Returns the list of roles.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_roles()
|
||||
[%Role{}, ...]
|
||||
|
||||
"""
|
||||
def list_roles do
|
||||
Repo.all(Role)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single role.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Role does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_role!(123)
|
||||
%Role{}
|
||||
|
||||
iex> get_role!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_role!(id), do: Repo.get!(Role, id)
|
||||
|
||||
@doc """
|
||||
Gets a role by name.
|
||||
|
||||
Returns nil if the Role does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_role_by_name("admin")
|
||||
%Role{}
|
||||
|
||||
iex> get_role_by_name("nonexistent")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_role_by_name(name) when is_binary(name) do
|
||||
Repo.get_by(Role, name: name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_role(%{field: value})
|
||||
{:ok, %Role{}}
|
||||
|
||||
iex> create_role(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_role(attrs \\ %{}) do
|
||||
%Role{}
|
||||
|> Role.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_role(role, %{field: new_value})
|
||||
{:ok, %Role{}}
|
||||
|
||||
iex> update_role(role, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_role(%Role{} = role, attrs) do
|
||||
role
|
||||
|> Role.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_role(role)
|
||||
{:ok, %Role{}}
|
||||
|
||||
iex> delete_role(role)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_role(%Role{} = role) do
|
||||
Repo.delete(role)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking role changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_role(role)
|
||||
%Ecto.Changeset{data: %Role{}}
|
||||
|
||||
"""
|
||||
def change_role(%Role{} = role, attrs \\ %{}) do
|
||||
Role.changeset(role, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Assigns a role to a user by role name or role struct.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> assign_role(user, "admin")
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> assign_role(user, %Role{id: 1})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> assign_role(user, "unknown")
|
||||
{:error, :role_not_found}
|
||||
"""
|
||||
def assign_role(%User{} = user, %Role{} = role) do
|
||||
user
|
||||
|> Ecto.Changeset.change(%{role_id: role.id})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def assign_role(%User{} = user, role_name) when is_binary(role_name) do
|
||||
case get_role_by_name(role_name) do
|
||||
nil ->
|
||||
{:error, :role_not_found}
|
||||
|
||||
role ->
|
||||
user
|
||||
|> Ecto.Changeset.change(%{role_id: role.id})
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the role of a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_role(user)
|
||||
%Role{}
|
||||
|
||||
iex> get_user_role(user_without_role)
|
||||
nil
|
||||
"""
|
||||
def get_user_role(%User{} = user) do
|
||||
user = user |> Repo.preload(:role)
|
||||
user.role
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists users by role name.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users_by_role("admin")
|
||||
[%User{}, ...]
|
||||
|
||||
iex> list_users_by_role("unknown")
|
||||
[]
|
||||
"""
|
||||
def list_users_by_role(role_name) when is_binary(role_name) do
|
||||
case get_role_by_name(role_name) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
role ->
|
||||
User
|
||||
|> where([u], u.role_id == ^role.id and is_nil(u.deleted_at))
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has a specific role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> user_has_role?(user, "admin")
|
||||
true
|
||||
|
||||
iex> user_has_role?(user, "unknown")
|
||||
false
|
||||
"""
|
||||
def user_has_role?(%User{} = user, role_name) when is_binary(role_name) do
|
||||
case get_user_role(user) do
|
||||
nil -> false
|
||||
role -> role.name == role_name
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Promotes a user to admin role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> promote_to_admin(user)
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> promote_to_admin(already_admin_user)
|
||||
{:ok, %User{}}
|
||||
"""
|
||||
def promote_to_admin(%User{} = user) do
|
||||
assign_role(user, "admin")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Demotes a user from admin role to regular user role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> demote_from_admin(admin_user)
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> demote_from_admin(already_user_user)
|
||||
{:ok, %User{}}
|
||||
"""
|
||||
def demote_from_admin(%User{} = user) do
|
||||
assign_role(user, "user")
|
||||
end
|
||||
|
||||
# Private helper to set default role if not already set
|
||||
defp maybe_set_default_role(attrs) do
|
||||
# Only set default role if role_id is not explicitly provided
|
||||
case Map.get(attrs, :role_id) || Map.get(attrs, "role_id") do
|
||||
nil -> set_default_user_role(attrs)
|
||||
_ -> attrs
|
||||
end
|
||||
end
|
||||
|
||||
defp set_default_user_role(attrs) do
|
||||
case get_role_by_name("user") do
|
||||
nil -> attrs
|
||||
user_role -> put_role_id(attrs, user_role.id)
|
||||
end
|
||||
end
|
||||
|
||||
defp put_role_id(attrs, role_id) do
|
||||
key = determine_role_key(attrs)
|
||||
Map.put(attrs, key, role_id)
|
||||
end
|
||||
|
||||
defp determine_role_key(attrs) do
|
||||
if has_string_keys?(attrs) do
|
||||
"role_id"
|
||||
else
|
||||
:role_id
|
||||
end
|
||||
end
|
||||
|
||||
defp has_string_keys?(attrs) do
|
||||
is_map(attrs) and map_size(attrs) > 0 and
|
||||
Enum.all?(Map.keys(attrs), &is_binary/1)
|
||||
end
|
||||
end
|
||||
|
||||
27
lib/claper/accounts/guardian.ex
Normal file
27
lib/claper/accounts/guardian.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Claper.Accounts.Guardian do
|
||||
@moduledoc """
|
||||
Implementation module for Guardian authentication.
|
||||
This module handles JWT token generation and validation for user authentication.
|
||||
"""
|
||||
|
||||
defmodule Plug do
|
||||
@moduledoc """
|
||||
Plug helpers for Guardian authentication in tests.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Sign in a user to a conn.
|
||||
|
||||
## Parameters
|
||||
- conn: The connection
|
||||
- user: The user to sign in
|
||||
|
||||
## Returns
|
||||
- Updated conn with user signed in
|
||||
"""
|
||||
def sign_in(conn, user) do
|
||||
# For tests, we'll just put the user in the conn assigns
|
||||
Elixir.Plug.Conn.assign(conn, :current_user, user)
|
||||
end
|
||||
end
|
||||
end
|
||||
119
lib/claper/accounts/oidc.ex
Normal file
119
lib/claper/accounts/oidc.ex
Normal file
@@ -0,0 +1,119 @@
|
||||
defmodule Claper.Accounts.Oidc do
|
||||
@moduledoc """
|
||||
The OIDC context for authentication and provider management.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Repo
|
||||
alias Claper.Accounts.Oidc.Provider
|
||||
|
||||
@doc """
|
||||
Returns the list of oidc_providers.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_providers()
|
||||
[%Provider{}, ...]
|
||||
|
||||
"""
|
||||
def list_providers do
|
||||
Repo.all(Provider)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single provider.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Provider does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_provider!(123)
|
||||
%Provider{}
|
||||
|
||||
iex> get_provider!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_provider!(id), do: Repo.get!(Provider, id)
|
||||
|
||||
@doc """
|
||||
Creates a provider.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_provider(%{field: value})
|
||||
{:ok, %Provider{}}
|
||||
|
||||
iex> create_provider(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_provider(attrs \\ %{}) do
|
||||
%Provider{}
|
||||
|> Provider.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a provider.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_provider(provider, %{field: new_value})
|
||||
{:ok, %Provider{}}
|
||||
|
||||
iex> update_provider(provider, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_provider(%Provider{} = provider, attrs) do
|
||||
provider
|
||||
|> Provider.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a provider.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_provider(provider)
|
||||
{:ok, %Provider{}}
|
||||
|
||||
iex> delete_provider(provider)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_provider(%Provider{} = provider) do
|
||||
Repo.delete(provider)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking provider changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_provider(provider)
|
||||
%Ecto.Changeset{data: %Provider{}}
|
||||
|
||||
"""
|
||||
def change_provider(%Provider{} = provider, attrs \\ %{}) do
|
||||
Provider.changeset(provider, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Search providers by name or issuer.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> search_providers("%example%")
|
||||
[%Provider{}, ...]
|
||||
|
||||
"""
|
||||
def search_providers(search_term) do
|
||||
from(p in Provider,
|
||||
where: ilike(p.name, ^search_term) or ilike(p.issuer, ^search_term)
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
57
lib/claper/accounts/oidc/provider.ex
Normal file
57
lib/claper/accounts/oidc/provider.ex
Normal file
@@ -0,0 +1,57 @@
|
||||
defmodule Claper.Accounts.Oidc.Provider do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
name: String.t(),
|
||||
issuer: String.t(),
|
||||
client_id: String.t(),
|
||||
client_secret: String.t(),
|
||||
redirect_uri: String.t(),
|
||||
scope: String.t(),
|
||||
active: boolean(),
|
||||
response_type: String.t(),
|
||||
response_mode: String.t(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "oidc_providers" do
|
||||
field :name, :string
|
||||
field :issuer, :string
|
||||
field :client_id, :string
|
||||
field :client_secret, :string
|
||||
field :redirect_uri, :string
|
||||
field :scope, :string, default: "openid email profile"
|
||||
field :active, :boolean, default: true
|
||||
field :response_type, :string, default: "code"
|
||||
field :response_mode, :string, default: "query"
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
A changeset for creating or updating an OIDC provider.
|
||||
"""
|
||||
def changeset(provider, attrs) do
|
||||
provider
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:issuer,
|
||||
:client_id,
|
||||
:client_secret,
|
||||
:redirect_uri,
|
||||
:scope,
|
||||
:active,
|
||||
:response_type,
|
||||
:response_mode
|
||||
])
|
||||
|> validate_required([:name, :issuer, :client_id, :client_secret, :redirect_uri])
|
||||
|> validate_format(:issuer, ~r/^https?:\/\//, message: "must start with http:// or https://")
|
||||
|> validate_format(:redirect_uri, ~r/^https?:\/\//,
|
||||
message: "must start with http:// or https://"
|
||||
)
|
||||
|> unique_constraint(:name)
|
||||
end
|
||||
end
|
||||
31
lib/claper/accounts/role.ex
Normal file
31
lib/claper/accounts/role.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Claper.Accounts.Role do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
name: String.t(),
|
||||
permissions: map(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "roles" do
|
||||
field :name, :string
|
||||
field :permissions, :map, default: %{}
|
||||
|
||||
has_many :users, Claper.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for creating or updating a role.
|
||||
"""
|
||||
def changeset(role, attrs) do
|
||||
role
|
||||
|> cast(attrs, [:name, :permissions])
|
||||
|> validate_required([:name])
|
||||
|> unique_constraint(:name)
|
||||
end
|
||||
end
|
||||
@@ -13,6 +13,8 @@ defmodule Claper.Accounts.User do
|
||||
confirmed_at: NaiveDateTime.t() | nil,
|
||||
locale: String.t() | nil,
|
||||
events: [Claper.Events.Event.t()] | nil,
|
||||
role: Claper.Accounts.Role.t() | nil,
|
||||
role_id: integer() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t(),
|
||||
deleted_at: NaiveDateTime.t() | nil
|
||||
@@ -30,15 +32,18 @@ defmodule Claper.Accounts.User do
|
||||
|
||||
has_many :events, Claper.Events.Event
|
||||
has_one :lti_user, Lti13.Users.User
|
||||
belongs_to :role, Claper.Accounts.Role
|
||||
has_many :quiz_responses, Claper.Quizzes.QuizResponse
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password])
|
||||
|> cast(attrs, [:email, :confirmed_at, :password, :is_randomized_password, :role_id])
|
||||
|> validate_email()
|
||||
|> validate_password(opts)
|
||||
|> foreign_key_constraint(:role_id)
|
||||
end
|
||||
|
||||
def preferences_changeset(user, attrs) do
|
||||
@@ -46,6 +51,17 @@ defmodule Claper.Accounts.User do
|
||||
|> cast(attrs, [:locale])
|
||||
end
|
||||
|
||||
@doc """
|
||||
A changeset for admin operations on users.
|
||||
"""
|
||||
def admin_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :confirmed_at, :password, :role_id])
|
||||
|> validate_email()
|
||||
|> validate_admin_password(opts)
|
||||
|> foreign_key_constraint(:role_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A changeset for marking a user as deleted.
|
||||
"""
|
||||
@@ -70,6 +86,19 @@ defmodule Claper.Accounts.User do
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp validate_admin_password(changeset, opts) do
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
# Only validate password if it's provided
|
||||
if password && password != "" do
|
||||
changeset
|
||||
|> validate_length(:password, min: 6, max: 72)
|
||||
|> maybe_hash_password(opts)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
@@ -52,7 +52,7 @@ defmodule Claper.Accounts.UserNotifier do
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
Claper.Workers.Mailers.new_update_email(user.id, url) |> Oban.insert()
|
||||
Claper.Workers.Mailers.new_update_email(user.email, url) |> Oban.insert()
|
||||
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
|
||||
635
lib/claper/admin.ex
Normal file
635
lib/claper/admin.ex
Normal file
@@ -0,0 +1,635 @@
|
||||
defmodule Claper.Admin do
|
||||
@moduledoc """
|
||||
The Admin context.
|
||||
Provides functions for admin dashboard statistics and paginated lists of resources.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Repo
|
||||
alias Claper.Accounts.User
|
||||
alias Claper.Events.Event
|
||||
alias Claper.Accounts.Oidc.Provider
|
||||
|
||||
@doc """
|
||||
Gets dashboard statistics.
|
||||
|
||||
Returns a map with counts of users, events, and upcoming events.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_dashboard_stats()
|
||||
%{users_count: 10, events_count: 20, upcoming_events: 5}
|
||||
|
||||
"""
|
||||
def get_dashboard_stats do
|
||||
users_count =
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
events_count =
|
||||
Event
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
_now = NaiveDateTime.utc_now()
|
||||
|
||||
upcoming_events =
|
||||
Event
|
||||
|> where([e], is_nil(e.expired_at))
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
%{
|
||||
users_count: users_count,
|
||||
events_count: events_count,
|
||||
upcoming_events: upcoming_events
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets users over time for analytics charts.
|
||||
|
||||
Returns user registration data grouped by time period.
|
||||
|
||||
## Parameters
|
||||
- period: :day, :week, :month (default: :day)
|
||||
- days_back: number of days to look back (default: 30)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_users_over_time(:day, 7)
|
||||
%{
|
||||
labels: ["2025-01-10", "2025-01-11", ...],
|
||||
values: [2, 5, 1, 3, ...]
|
||||
}
|
||||
"""
|
||||
def get_users_over_time(period \\ :day, days_back \\ 30) do
|
||||
end_date = NaiveDateTime.utc_now()
|
||||
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
|
||||
|
||||
# Generate all dates in the range
|
||||
date_range = generate_date_range(start_date, end_date, period)
|
||||
|
||||
# Get actual user counts per period using raw SQL to avoid parameter conflicts
|
||||
period_sql_value = period_sql(period)
|
||||
|
||||
sql = """
|
||||
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND inserted_at >= $2
|
||||
AND inserted_at <= $3
|
||||
GROUP BY DATE_TRUNC($1, inserted_at)
|
||||
ORDER BY period
|
||||
"""
|
||||
|
||||
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
|
||||
|
||||
user_counts =
|
||||
result.rows
|
||||
|> Enum.map(fn [period, count] ->
|
||||
normalized_period = NaiveDateTime.truncate(period, :second)
|
||||
{normalized_period, count}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
# Format data for charts
|
||||
labels = Enum.map(date_range, &format_date_label(&1, period))
|
||||
|
||||
values =
|
||||
Enum.map(date_range, fn date ->
|
||||
Map.get(user_counts, truncate_date(date, period), 0)
|
||||
end)
|
||||
|
||||
%{
|
||||
labels: labels,
|
||||
values: values
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets events over time for analytics charts.
|
||||
|
||||
Returns event creation data grouped by time period.
|
||||
|
||||
## Parameters
|
||||
- period: :day, :week, :month (default: :day)
|
||||
- days_back: number of days to look back (default: 30)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_events_over_time(:day, 7)
|
||||
%{
|
||||
labels: ["2025-01-10", "2025-01-11", ...],
|
||||
values: [1, 3, 0, 2, ...]
|
||||
}
|
||||
"""
|
||||
def get_events_over_time(period \\ :day, days_back \\ 30) do
|
||||
end_date = NaiveDateTime.utc_now()
|
||||
start_date = NaiveDateTime.add(end_date, -(days_back * 24 * 60 * 60), :second)
|
||||
|
||||
# Generate all dates in the range
|
||||
date_range = generate_date_range(start_date, end_date, period)
|
||||
|
||||
# Get actual event counts per period using raw SQL to avoid parameter conflicts
|
||||
period_sql_value = period_sql(period)
|
||||
|
||||
sql = """
|
||||
SELECT DATE_TRUNC($1, inserted_at) as period, COUNT(id) as count
|
||||
FROM events
|
||||
WHERE inserted_at >= $2
|
||||
AND inserted_at <= $3
|
||||
GROUP BY DATE_TRUNC($1, inserted_at)
|
||||
ORDER BY period
|
||||
"""
|
||||
|
||||
result = Repo.query!(sql, [period_sql_value, start_date, end_date])
|
||||
|
||||
event_counts =
|
||||
result.rows
|
||||
|> Enum.map(fn [period, count] ->
|
||||
# Normalize the timestamp by removing microseconds
|
||||
normalized_period = NaiveDateTime.truncate(period, :second)
|
||||
{normalized_period, count}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
# Format data for charts
|
||||
labels = Enum.map(date_range, &format_date_label(&1, period))
|
||||
|
||||
values =
|
||||
Enum.map(date_range, fn date ->
|
||||
Map.get(event_counts, truncate_date(date, period), 0)
|
||||
end)
|
||||
|
||||
%{
|
||||
labels: labels,
|
||||
values: values
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets growth metrics for dashboard statistics.
|
||||
|
||||
Returns percentage growth for users and events compared to previous period.
|
||||
"""
|
||||
def get_growth_metrics do
|
||||
now = NaiveDateTime.utc_now()
|
||||
thirty_days_ago = NaiveDateTime.add(now, -(30 * 24 * 60 * 60), :second)
|
||||
sixty_days_ago = NaiveDateTime.add(now, -(60 * 24 * 60 * 60), :second)
|
||||
|
||||
# Current period (last 30 days)
|
||||
current_users =
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> where([u], u.inserted_at >= ^thirty_days_ago and u.inserted_at <= ^now)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
current_events =
|
||||
Event
|
||||
|> where([e], e.inserted_at >= ^thirty_days_ago and e.inserted_at <= ^now)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
# Previous period (30-60 days ago)
|
||||
previous_users =
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> where([u], u.inserted_at >= ^sixty_days_ago and u.inserted_at < ^thirty_days_ago)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
previous_events =
|
||||
Event
|
||||
|> where([e], e.inserted_at >= ^sixty_days_ago and e.inserted_at < ^thirty_days_ago)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
|
||||
%{
|
||||
users_growth: calculate_growth_percentage(current_users, previous_users),
|
||||
events_growth: calculate_growth_percentage(current_events, previous_events)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets recent activity stats for dashboard.
|
||||
|
||||
Returns counts of recent activities.
|
||||
"""
|
||||
def get_activity_stats do
|
||||
now = NaiveDateTime.utc_now()
|
||||
twenty_four_hours_ago = NaiveDateTime.add(now, -(24 * 60 * 60), :second)
|
||||
seven_days_ago = NaiveDateTime.add(now, -(7 * 24 * 60 * 60), :second)
|
||||
|
||||
%{
|
||||
users_today:
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> where([u], u.inserted_at >= ^twenty_four_hours_ago)
|
||||
|> Repo.aggregate(:count, :id),
|
||||
events_today:
|
||||
Event
|
||||
|> where([e], e.inserted_at >= ^twenty_four_hours_ago)
|
||||
|> Repo.aggregate(:count, :id),
|
||||
users_this_week:
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> where([u], u.inserted_at >= ^seven_days_ago)
|
||||
|> Repo.aggregate(:count, :id),
|
||||
events_this_week:
|
||||
Event
|
||||
|> where([e], e.inserted_at >= ^seven_days_ago)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
}
|
||||
end
|
||||
|
||||
# Private helper functions
|
||||
|
||||
defp generate_date_range(start_date, end_date, period) do
|
||||
start_date
|
||||
|> NaiveDateTime.to_date()
|
||||
|> Date.range(NaiveDateTime.to_date(end_date))
|
||||
|> Enum.to_list()
|
||||
|> case do
|
||||
dates when period == :day ->
|
||||
dates
|
||||
|
||||
dates when period == :week ->
|
||||
dates |> Enum.chunk_every(7) |> Enum.map(&List.first/1)
|
||||
|
||||
dates when period == :month ->
|
||||
dates |> Enum.group_by(&Date.beginning_of_month/1) |> Map.keys()
|
||||
end
|
||||
end
|
||||
|
||||
defp period_sql(period) do
|
||||
case period do
|
||||
:day -> "day"
|
||||
:week -> "week"
|
||||
:month -> "month"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_date_label(date, period) do
|
||||
case period do
|
||||
:day -> Date.to_string(date)
|
||||
:week -> "Week of #{Date.to_string(date)}"
|
||||
:month -> "#{Date.to_string(date) |> String.slice(0..6)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp truncate_date(date, period) do
|
||||
naive_date = NaiveDateTime.new!(date, ~T[00:00:00])
|
||||
|
||||
case period do
|
||||
:day ->
|
||||
NaiveDateTime.truncate(naive_date, :second)
|
||||
|
||||
:week ->
|
||||
days_to_subtract = Date.day_of_week(date) - 1
|
||||
|
||||
date
|
||||
|> Date.add(-days_to_subtract)
|
||||
|> NaiveDateTime.new!(~T[00:00:00])
|
||||
|> NaiveDateTime.truncate(:second)
|
||||
|
||||
:month ->
|
||||
date
|
||||
|> Date.beginning_of_month()
|
||||
|> NaiveDateTime.new!(~T[00:00:00])
|
||||
|> NaiveDateTime.truncate(:second)
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_growth_percentage(current, previous) do
|
||||
cond do
|
||||
previous == 0 and current > 0 ->
|
||||
100.0
|
||||
|
||||
previous == 0 and current == 0 ->
|
||||
0.0
|
||||
|
||||
previous > 0 ->
|
||||
:erlang.float_to_binary(((current - previous) / previous * 100) |> Float.round(1),
|
||||
decimals: 1
|
||||
)
|
||||
|
||||
true ->
|
||||
0.0
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of users.
|
||||
|
||||
## Options
|
||||
|
||||
* `:page` - The page number (default: 1)
|
||||
* `:per_page` - The number of users per page (default: 20)
|
||||
* `:search` - Search term for filtering users by email
|
||||
* `:role` - Filter users by role name
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users_paginated(%{page: 1, per_page: 10})
|
||||
%{entries: [%User{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
|
||||
|
||||
"""
|
||||
def list_users_paginated(params \\ %{}) do
|
||||
page = Map.get(params, "page", 1)
|
||||
per_page = Map.get(params, "per_page", 20)
|
||||
search = Map.get(params, "search", "")
|
||||
role = Map.get(params, "role", "")
|
||||
|
||||
query =
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> preload(:role)
|
||||
|
||||
query =
|
||||
if search != "" do
|
||||
query |> where([u], ilike(u.email, ^"%#{search}%"))
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if role != "" do
|
||||
query |> join(:inner, [u], r in assoc(u, :role), on: r.name == ^role)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query = query |> order_by([u], desc: u.inserted_at)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: per_page)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of events.
|
||||
|
||||
## Options
|
||||
|
||||
* `:page` - The page number (default: 1)
|
||||
* `:per_page` - The number of events per page (default: 20)
|
||||
* `:search` - Search term for filtering events by name
|
||||
* `:status` - Filter events by status (upcoming, past)
|
||||
* `:start_date` - Filter events by start date
|
||||
* `:end_date` - Filter events by end date
|
||||
* `:creator_id` - Filter events by creator ID
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_events_paginated(%{page: 1, per_page: 10})
|
||||
%{entries: [%Event{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
|
||||
|
||||
"""
|
||||
def list_events_paginated(params \\ %{}) do
|
||||
page = Map.get(params, "page", 1)
|
||||
per_page = Map.get(params, "per_page", 20)
|
||||
search = Map.get(params, "search", "")
|
||||
status = Map.get(params, "status", "")
|
||||
start_date = Map.get(params, "start_date", nil)
|
||||
end_date = Map.get(params, "end_date", nil)
|
||||
creator_id = Map.get(params, "creator_id", nil)
|
||||
|
||||
query =
|
||||
Event
|
||||
|> preload(:user)
|
||||
|
||||
query =
|
||||
if search != "" do
|
||||
query |> where([e], ilike(e.name, ^"%#{search}%"))
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
case status do
|
||||
"upcoming" ->
|
||||
now = NaiveDateTime.utc_now()
|
||||
query |> where([e], e.started_at > ^now)
|
||||
|
||||
"past" ->
|
||||
now = NaiveDateTime.utc_now()
|
||||
query |> where([e], e.started_at <= ^now)
|
||||
|
||||
_ ->
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if start_date do
|
||||
query |> where([e], e.started_at >= ^start_date)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if end_date do
|
||||
query |> where([e], e.started_at <= ^end_date)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if creator_id do
|
||||
query |> where([e], e.user_id == ^creator_id)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query = query |> order_by([e], desc: e.started_at)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: per_page)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of OIDC providers.
|
||||
|
||||
## Options
|
||||
|
||||
* `:page` - The page number (default: 1)
|
||||
* `:per_page` - The number of providers per page (default: 20)
|
||||
* `:search` - Search term for filtering providers by name
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_oidc_providers_paginated(%{page: 1, per_page: 10})
|
||||
%{entries: [%Provider{}, ...], page_number: 1, page_size: 10, total_entries: 20, total_pages: 2}
|
||||
|
||||
"""
|
||||
def list_oidc_providers_paginated(params \\ %{}) do
|
||||
page = Map.get(params, "page", 1)
|
||||
per_page = Map.get(params, "per_page", 20)
|
||||
search = Map.get(params, "search", "")
|
||||
|
||||
query = Provider
|
||||
|
||||
query =
|
||||
if search != "" do
|
||||
query |> where([p], ilike(p.name, ^"%#{search}%"))
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query = query |> order_by([p], p.name)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: per_page)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a complete list of OIDC providers for export purposes.
|
||||
|
||||
Unlike the paginated version, this returns all providers matching the search criteria.
|
||||
|
||||
## Options
|
||||
|
||||
* `:search` - Search term for filtering providers by name
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_all_oidc_providers(%{search: "Google"})
|
||||
[%Provider{}, ...]
|
||||
|
||||
"""
|
||||
def list_all_oidc_providers(params \\ %{}) do
|
||||
search = Map.get(params, "search", "")
|
||||
|
||||
query = Provider
|
||||
|
||||
query =
|
||||
if search != "" do
|
||||
query |> where([p], ilike(p.name, ^"%#{search}%"))
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query = query |> order_by([p], p.name)
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a complete list of events for export purposes.
|
||||
|
||||
Unlike the paginated version, this returns all events matching the search criteria.
|
||||
|
||||
## Options
|
||||
|
||||
* `:search` - Search term for filtering events by name
|
||||
* `:status` - Filter events by status (upcoming, past)
|
||||
* `:start_date` - Filter events by start date
|
||||
* `:end_date` - Filter events by end date
|
||||
* `:creator_id` - Filter events by creator ID
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_all_events(%{search: "Conference"})
|
||||
[%Event{}, ...]
|
||||
|
||||
"""
|
||||
def list_all_events(params \\ %{}) do
|
||||
Event
|
||||
|> join(:left, [e], u in assoc(e, :user))
|
||||
|> preload([e, u], user: u)
|
||||
|> apply_event_search_filter(Map.get(params, "search", ""))
|
||||
|> apply_event_status_filter(Map.get(params, "status", ""))
|
||||
|> apply_event_start_date_filter(Map.get(params, "start_date", nil))
|
||||
|> apply_event_end_date_filter(Map.get(params, "end_date", nil))
|
||||
|> apply_event_creator_filter(Map.get(params, "creator_id", nil))
|
||||
|> order_by([e], desc: e.started_at)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn event -> Map.put(event, :user_email, event.user.email) end)
|
||||
end
|
||||
|
||||
defp apply_event_search_filter(query, ""), do: query
|
||||
|
||||
defp apply_event_search_filter(query, search) do
|
||||
search_term = "%#{search}%"
|
||||
|
||||
query
|
||||
|> where(
|
||||
[e, u],
|
||||
ilike(e.name, ^search_term) or ilike(e.code, ^search_term) or
|
||||
ilike(u.email, ^search_term)
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_event_status_filter(query, "upcoming") do
|
||||
now = NaiveDateTime.utc_now()
|
||||
query |> where([e], e.started_at > ^now)
|
||||
end
|
||||
|
||||
defp apply_event_status_filter(query, "past") do
|
||||
now = NaiveDateTime.utc_now()
|
||||
query |> where([e], e.started_at <= ^now)
|
||||
end
|
||||
|
||||
defp apply_event_status_filter(query, _), do: query
|
||||
|
||||
defp apply_event_start_date_filter(query, nil), do: query
|
||||
|
||||
defp apply_event_start_date_filter(query, start_date) do
|
||||
query |> where([e], e.started_at >= ^start_date)
|
||||
end
|
||||
|
||||
defp apply_event_end_date_filter(query, nil), do: query
|
||||
|
||||
defp apply_event_end_date_filter(query, end_date) do
|
||||
query |> where([e], e.started_at <= ^end_date)
|
||||
end
|
||||
|
||||
defp apply_event_creator_filter(query, nil), do: query
|
||||
|
||||
defp apply_event_creator_filter(query, creator_id) do
|
||||
query |> where([e], e.user_id == ^creator_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a complete list of users for export purposes.
|
||||
|
||||
Unlike the paginated version, this returns all users matching the search criteria.
|
||||
|
||||
## Options
|
||||
|
||||
* `:search` - Search term for filtering users by email or name
|
||||
* `:role` - Filter users by role ID
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_all_users(%{search: "admin"})
|
||||
[%User{}, ...]
|
||||
|
||||
"""
|
||||
def list_all_users(params \\ %{}) do
|
||||
search = Map.get(params, "search", "")
|
||||
role = Map.get(params, "role", "")
|
||||
|
||||
query =
|
||||
User
|
||||
|> where([u], is_nil(u.deleted_at))
|
||||
|> preload(:role)
|
||||
|
||||
query =
|
||||
if search != "" do
|
||||
query |> where([u], ilike(u.email, ^"%#{search}%") or ilike(u.name, ^"%#{search}%"))
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query =
|
||||
if role != "" do
|
||||
query |> where([u], u.role_id == ^role)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
query = query |> order_by([u], u.email)
|
||||
|
||||
# Add a virtual field for role_name to make it accessible in CSV export
|
||||
Repo.all(query)
|
||||
|> Enum.map(fn user ->
|
||||
role_name = if user.role, do: user.role.name, else: "none"
|
||||
|
||||
user
|
||||
|> Map.put(:role_name, role_name)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule Claper.Embeds.Embed do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import ClaperWeb.Gettext
|
||||
use Gettext, backend: ClaperWeb.Gettext
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
@@ -61,7 +61,7 @@ defmodule Claper.Embeds.Embed do
|
||||
|> validate_format(:content, ~r/^https?:\/\/.+$/,
|
||||
message: gettext("Please enter a valid link starting with http:// or https://")
|
||||
)
|
||||
|> validate_format(:content, ~r/youtu\.be/,
|
||||
|> validate_format(:content, ~r/(youtu\.be)|(youtube\.com)/,
|
||||
message: gettext("Please enter a valid %{provider} link", provider: "YouTube")
|
||||
)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ defmodule Claper.Events do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Repo
|
||||
|
||||
alias Claper.{Accounts, Presentations, Repo}
|
||||
alias Claper.Events.{Event, ActivityLeader}
|
||||
|
||||
@default_page_size 5
|
||||
@@ -22,7 +22,7 @@ defmodule Claper.Events do
|
||||
|
||||
"""
|
||||
def list_events(user_id, preload \\ []) do
|
||||
from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.inserted_at])
|
||||
from(e in Event, where: e.user_id == ^user_id, order_by: [desc: e.id])
|
||||
|> Repo.all()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
@@ -43,7 +43,7 @@ defmodule Claper.Events do
|
||||
query =
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id,
|
||||
order_by: [desc: e.inserted_at]
|
||||
order_by: [desc: e.id]
|
||||
)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
|
||||
@@ -61,7 +61,7 @@ defmodule Claper.Events do
|
||||
def list_not_expired_events(user_id, preload \\ []) do
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and is_nil(e.expired_at),
|
||||
order_by: [desc: e.inserted_at]
|
||||
order_by: [desc: e.id]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(preload)
|
||||
@@ -83,7 +83,7 @@ defmodule Claper.Events do
|
||||
query =
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and is_nil(e.expired_at),
|
||||
order_by: [desc: e.inserted_at]
|
||||
order_by: [desc: e.id]
|
||||
)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
|
||||
@@ -140,12 +140,12 @@ defmodule Claper.Events do
|
||||
"""
|
||||
def list_managed_events_by(email, preload \\ []) do
|
||||
from(a in ActivityLeader,
|
||||
join: u in Claper.Accounts.User,
|
||||
join: u in Accounts.User,
|
||||
on: u.email == a.email,
|
||||
join: e in Event,
|
||||
on: e.id == a.event_id,
|
||||
where: a.email == ^email,
|
||||
order_by: [desc: e.expired_at],
|
||||
order_by: [desc: e.expired_at, desc: e.id],
|
||||
select: e
|
||||
)
|
||||
|> Repo.all()
|
||||
@@ -167,12 +167,12 @@ defmodule Claper.Events do
|
||||
|
||||
query =
|
||||
from(a in ActivityLeader,
|
||||
join: u in Claper.Accounts.User,
|
||||
join: u in Accounts.User,
|
||||
on: u.email == a.email,
|
||||
join: e in Event,
|
||||
on: e.id == a.event_id,
|
||||
where: a.email == ^email,
|
||||
order_by: [desc: e.expired_at],
|
||||
order_by: [desc: e.expired_at, desc: e.id],
|
||||
select: e
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ defmodule Claper.Events do
|
||||
|
||||
def count_managed_events_by(email) do
|
||||
from(a in ActivityLeader,
|
||||
join: u in Claper.Accounts.User,
|
||||
join: u in Accounts.User,
|
||||
on: u.email == a.email,
|
||||
join: e in Event,
|
||||
on: e.id == a.event_id,
|
||||
@@ -193,8 +193,7 @@ defmodule Claper.Events do
|
||||
|
||||
def count_expired_events(user_id) do
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and not is_nil(e.expired_at),
|
||||
order_by: [desc: e.expired_at]
|
||||
where: e.user_id == ^user_id and not is_nil(e.expired_at)
|
||||
)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
@@ -207,28 +206,42 @@ defmodule Claper.Events do
|
||||
from(e in Event,
|
||||
where:
|
||||
e.user_id == ^user_id and e.inserted_at <= ^DateTime.utc_now() and
|
||||
e.inserted_at >= ^last_month,
|
||||
order_by: [desc: e.id]
|
||||
e.inserted_at >= ^last_month
|
||||
)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single event.
|
||||
Gets a single event by serial ID or UUID.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Event does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_event!(123)
|
||||
%Event{}
|
||||
|
||||
iex> get_event!("123e4567-e89b-12d3-a456-426614174000")
|
||||
%Event{}
|
||||
|
||||
iex> get_event!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
iex> get_event!("123e4567-e89b-12d3-a456-4266141740111")
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_event!(id, preload \\ []),
|
||||
do: Repo.get_by!(Event, uuid: id) |> Repo.preload(preload)
|
||||
def get_event!(id_or_uuid, preload \\ [])
|
||||
|
||||
def get_event!(
|
||||
<<_::bytes-8, "-", _::bytes-4, "-", _::bytes-4, "-", _::bytes-4, "-", _::bytes-12>> =
|
||||
uuid,
|
||||
preload
|
||||
),
|
||||
do: Repo.get_by!(Event, uuid: uuid) |> Repo.preload(preload)
|
||||
|
||||
def get_event!(id, preload),
|
||||
do: Repo.get!(Event, id) |> Repo.preload(preload)
|
||||
|
||||
@doc """
|
||||
Gets a single managed event.
|
||||
@@ -244,17 +257,18 @@ defmodule Claper.Events do
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_managed_event!(current_user, id, preload \\ []) do
|
||||
event = Repo.get_by!(Event, uuid: id)
|
||||
|
||||
is_leader =
|
||||
Claper.Events.leaded_by?(current_user.email, event) || event.user_id == current_user.id
|
||||
|
||||
if is_leader do
|
||||
event |> Repo.preload(preload)
|
||||
else
|
||||
raise Ecto.NoResultsError
|
||||
end
|
||||
def get_managed_event!(user, uuid, preload \\ []) do
|
||||
from(
|
||||
e in Event,
|
||||
join: u in Accounts.User,
|
||||
on: e.user_id == u.id,
|
||||
left_join: a in ActivityLeader,
|
||||
on: e.id == a.event_id,
|
||||
where: e.uuid == ^uuid and (u.id == ^user.id or a.email == ^user.email),
|
||||
select: e
|
||||
)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -304,43 +318,22 @@ defmodule Claper.Events do
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a single event with the same code excluding a specific event.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_different_event_with_code("Hello", 123)
|
||||
%Event{}
|
||||
|
||||
|
||||
"""
|
||||
def get_different_event_with_code(nil, _event_id), do: nil
|
||||
|
||||
def get_different_event_with_code(code, event_id) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Check if a user is a facilitator of a specific event.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> leaded_by?("email@example.com", 123)
|
||||
iex> led_by?("email@example.com", 123)
|
||||
true
|
||||
|
||||
|
||||
"""
|
||||
def leaded_by?(email, event) do
|
||||
def led_by?(email, event) do
|
||||
from(a in ActivityLeader,
|
||||
join: u in Claper.Accounts.User,
|
||||
join: u in Accounts.User,
|
||||
on: u.email == a.email,
|
||||
join: e in Event,
|
||||
on: e.id == a.event_id,
|
||||
where: a.email == ^email and e.id == ^event.id,
|
||||
order_by: [desc: e.expired_at]
|
||||
where: a.email == ^email and e.id == ^event.id
|
||||
)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
@@ -363,7 +356,10 @@ defmodule Claper.Events do
|
||||
|> validate_unique_event()
|
||||
|> case do
|
||||
{:ok, event} ->
|
||||
Repo.insert(event, returning: [:uuid])
|
||||
with {:ok, event} <- Repo.insert(event, returning: [:uuid]) do
|
||||
broadcast_all_users({:created, event})
|
||||
{:ok, event}
|
||||
end
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, %{changeset | action: :insert}}
|
||||
@@ -384,6 +380,15 @@ defmodule Claper.Events do
|
||||
end
|
||||
end
|
||||
|
||||
defp get_different_event_with_code(nil, _event_id), do: nil
|
||||
|
||||
defp get_different_event_with_code(code, event_id) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
from(e in Event, where: e.code == ^code and e.id != ^event_id and e.expired_at > ^now)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an event.
|
||||
|
||||
@@ -402,7 +407,25 @@ defmodule Claper.Events do
|
||||
|> validate_unique_event()
|
||||
|> case do
|
||||
{:ok, event} ->
|
||||
Repo.update(event, returning: [:uuid])
|
||||
with {:ok, event} <- Repo.update(event, returning: [:uuid]) do
|
||||
broadcast_all_users({:updated, event})
|
||||
|
||||
deleted_leaders =
|
||||
attrs
|
||||
|> Map.get("leaders", %{})
|
||||
|> Map.values()
|
||||
|> Enum.filter(fn
|
||||
%{"delete" => "true"} -> true
|
||||
_ -> false
|
||||
end)
|
||||
|
||||
for %{"email" => leader_email} <- deleted_leaders do
|
||||
leader = Accounts.get_user_by_email(leader_email)
|
||||
broadcast_user_events(leader.id, {:updated, event})
|
||||
end
|
||||
|
||||
{:ok, event}
|
||||
end
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, %{changeset | action: :update}}
|
||||
@@ -424,7 +447,9 @@ defmodule Claper.Events do
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, event} ->
|
||||
broadcast({:ok, event, event.uuid}, :event_terminated)
|
||||
broadcast_all_users({:updated, event})
|
||||
broadcast_event(event.uuid, {:event_terminated, event.uuid})
|
||||
{:ok, event}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, %{changeset | action: :update}}
|
||||
@@ -517,7 +542,10 @@ defmodule Claper.Events do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Duplicate an event
|
||||
Duplicates an event.
|
||||
|
||||
Raises `Ecto.NoResultsError` for invalid `user_id`-`event_uuid` combinations
|
||||
and returns an error tuple if any part of the transaction fails.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -527,159 +555,201 @@ defmodule Claper.Events do
|
||||
iex> duplicate(user_id, event_uuid)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
iex> duplicate(another_user_id, event_uuid)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def duplicate_event(user_id, event_uuid) do
|
||||
case Ecto.Multi.new()
|
||||
|> Ecto.Multi.run(:original_event, fn _repo, _changes ->
|
||||
{:ok,
|
||||
get_user_event!(user_id, event_uuid,
|
||||
presentation_file: [
|
||||
polls: [:poll_opts],
|
||||
forms: [],
|
||||
embeds: [],
|
||||
quizzes: [:quiz_questions, quiz_questions: :quiz_question_opts],
|
||||
presentation_state: []
|
||||
]
|
||||
)}
|
||||
end)
|
||||
|> Ecto.Multi.run(:new_event, fn _repo, %{original_event: original_event} ->
|
||||
new_code =
|
||||
for _ <- 1..5,
|
||||
into: "",
|
||||
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
|
||||
original =
|
||||
get_user_event!(user_id, event_uuid,
|
||||
presentation_file: [
|
||||
presentation_state: [],
|
||||
polls: [:poll_opts],
|
||||
forms: [],
|
||||
embeds: [],
|
||||
quizzes: [quiz_questions: [:quiz_question_opts]]
|
||||
]
|
||||
)
|
||||
|
||||
attrs =
|
||||
Map.from_struct(original_event)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at])
|
||||
|> Map.put(:leaders, [])
|
||||
|> Map.put(:code, "#{new_code}")
|
||||
|> Map.put(:name, "#{original_event.name} (Copy)")
|
||||
multi =
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.run(:event, fn _repo, changes -> duplicate_event_attrs(original, changes) end)
|
||||
|> Ecto.Multi.run(:presentation_file, fn _repo, changes ->
|
||||
duplicate_presentation_file(original, changes)
|
||||
end)
|
||||
|> Ecto.Multi.run(:presentation_state, fn _repo, changes ->
|
||||
duplicate_presentation_state(original, changes)
|
||||
end)
|
||||
|> Ecto.Multi.run(:polls, fn _repo, changes -> duplicate_polls(original, changes) end)
|
||||
|> Ecto.Multi.run(:forms, fn _repo, changes -> duplicate_forms(original, changes) end)
|
||||
|> Ecto.Multi.run(:embeds, fn _repo, changes -> duplicate_embeds(original, changes) end)
|
||||
|> Ecto.Multi.run(:quizzes, fn _repo, changes -> duplicate_quizzes(original, changes) end)
|
||||
|
||||
create_event(attrs)
|
||||
end)
|
||||
|> Ecto.Multi.run(:new_presentation_file, fn _repo,
|
||||
%{
|
||||
original_event: original_event,
|
||||
new_event: new_event
|
||||
} ->
|
||||
attrs =
|
||||
Map.from_struct(original_event.presentation_file)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|
||||
|> Map.put(:event_id, new_event.id)
|
||||
|
||||
Claper.Presentations.create_presentation_file(attrs)
|
||||
end)
|
||||
|> Ecto.Multi.run(:new_presentation_state, fn _repo,
|
||||
%{
|
||||
original_event: original_event,
|
||||
new_presentation_file:
|
||||
new_presentation_file
|
||||
} ->
|
||||
attrs =
|
||||
Map.from_struct(original_event.presentation_file.presentation_state)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, new_presentation_file.id)
|
||||
|> Map.put(:position, 0)
|
||||
|> Map.put(:banned, [])
|
||||
|
||||
Claper.Presentations.create_presentation_state(attrs)
|
||||
end)
|
||||
|> Ecto.Multi.run(:polls, fn _repo,
|
||||
%{
|
||||
new_presentation_file: new_presentation_file,
|
||||
original_event: original_event
|
||||
} ->
|
||||
{:ok,
|
||||
Enum.map(original_event.presentation_file.polls, fn poll ->
|
||||
poll_attrs =
|
||||
Map.from_struct(poll)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, new_presentation_file.id)
|
||||
|> Map.put(
|
||||
:poll_opts,
|
||||
Enum.map(poll.poll_opts, fn opt ->
|
||||
Map.from_struct(opt)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
end)
|
||||
)
|
||||
|
||||
{:ok, new_poll} = Claper.Polls.create_poll(poll_attrs)
|
||||
new_poll
|
||||
end)}
|
||||
end)
|
||||
|> Ecto.Multi.run(:forms, fn _repo,
|
||||
%{
|
||||
new_presentation_file: new_presentation_file,
|
||||
original_event: original_event
|
||||
} ->
|
||||
{:ok,
|
||||
Enum.map(original_event.presentation_file.forms, fn form ->
|
||||
form_attrs =
|
||||
Map.from_struct(form)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, new_presentation_file.id)
|
||||
|> Map.put(
|
||||
:fields,
|
||||
Enum.map(form.fields, &Map.from_struct(&1))
|
||||
)
|
||||
|
||||
{:ok, new_form} = Claper.Forms.create_form(form_attrs)
|
||||
new_form
|
||||
end)}
|
||||
end)
|
||||
|> Ecto.Multi.run(:embeds, fn _repo,
|
||||
%{
|
||||
new_presentation_file: new_presentation_file,
|
||||
original_event: original_event
|
||||
} ->
|
||||
{:ok,
|
||||
Enum.map(original_event.presentation_file.embeds, fn embed ->
|
||||
embed_attrs =
|
||||
Map.from_struct(embed)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, new_presentation_file.id)
|
||||
|
||||
{:ok, new_embed} = Claper.Embeds.create_embed(embed_attrs)
|
||||
new_embed
|
||||
end)}
|
||||
end)
|
||||
|> Ecto.Multi.run(:quizzes, fn _repo,
|
||||
%{
|
||||
new_presentation_file: new_presentation_file,
|
||||
original_event: original_event
|
||||
} ->
|
||||
{:ok,
|
||||
Enum.map(original_event.presentation_file.quizzes, fn quiz ->
|
||||
quiz_attrs =
|
||||
Map.from_struct(quiz)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, new_presentation_file.id)
|
||||
|> Map.put(
|
||||
:quiz_questions,
|
||||
Enum.map(quiz.quiz_questions, fn question ->
|
||||
Map.from_struct(question)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(
|
||||
:quiz_question_opts,
|
||||
Enum.map(question.quiz_question_opts, fn opt ->
|
||||
Map.from_struct(opt)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:response_count, 0)
|
||||
end)
|
||||
)
|
||||
end)
|
||||
)
|
||||
|
||||
{:ok, new_quiz} = Claper.Quizzes.create_quiz(quiz_attrs)
|
||||
new_quiz
|
||||
end)}
|
||||
end)
|
||||
|> Repo.transaction() do
|
||||
{:ok, %{new_event: new_event}} -> {:ok, new_event}
|
||||
{:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value}
|
||||
case Repo.transaction(multi) do
|
||||
{:ok, %{event: event}} -> {:ok, event}
|
||||
{:error, _operation, value, _changes} -> {:error, value}
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_event_attrs(original, _changes) do
|
||||
code =
|
||||
for _ <- 1..5,
|
||||
into: "",
|
||||
do: <<Enum.random(~c"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")>>
|
||||
|
||||
attrs =
|
||||
Map.from_struct(original)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_file, :expired_at])
|
||||
|> Map.put(:leaders, [])
|
||||
|> Map.put(:code, "#{code}")
|
||||
|> Map.put(:name, "#{original.name} (Copy)")
|
||||
|
||||
create_event(attrs)
|
||||
end
|
||||
|
||||
defp duplicate_presentation_file(original, changes) do
|
||||
case get_in(original.presentation_file) do
|
||||
%Presentations.PresentationFile{} = presentation_file ->
|
||||
attrs =
|
||||
Map.from_struct(presentation_file)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at, :presentation_state])
|
||||
|> Map.put(:event_id, changes.event.id)
|
||||
|
||||
Presentations.create_presentation_file(attrs)
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_presentation_state(original, changes) do
|
||||
case get_in(original.presentation_file.presentation_state) do
|
||||
%Presentations.PresentationState{} = presentation_state ->
|
||||
attrs =
|
||||
Map.from_struct(presentation_state)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|
||||
|> Map.put(:position, 0)
|
||||
|> Map.put(:banned, [])
|
||||
|
||||
Presentations.create_presentation_state(attrs)
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_polls(original, changes) do
|
||||
case get_in(original.presentation_file.polls) do
|
||||
polls when is_list(polls) ->
|
||||
polls =
|
||||
for poll <- polls do
|
||||
attrs =
|
||||
Map.from_struct(poll)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|
||||
|> Map.put(
|
||||
:poll_opts,
|
||||
Enum.map(poll.poll_opts, fn opt ->
|
||||
Map.from_struct(opt)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at, :vote_count])
|
||||
end)
|
||||
)
|
||||
|
||||
{:ok, poll} = Claper.Polls.create_poll(attrs)
|
||||
poll
|
||||
end
|
||||
|
||||
{:ok, polls}
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_forms(original, changes) do
|
||||
case get_in(original.presentation_file.forms) do
|
||||
forms when is_list(forms) ->
|
||||
forms =
|
||||
for form <- forms do
|
||||
attrs =
|
||||
Map.from_struct(form)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|
||||
|> Map.put(
|
||||
:fields,
|
||||
Enum.map(form.fields, &Map.from_struct(&1))
|
||||
)
|
||||
|
||||
{:ok, form} = Claper.Forms.create_form(attrs)
|
||||
form
|
||||
end
|
||||
|
||||
{:ok, forms}
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_embeds(original, changes) do
|
||||
case get_in(original.presentation_file.embeds) do
|
||||
embeds when is_list(embeds) ->
|
||||
embeds =
|
||||
for embed <- embeds do
|
||||
attrs =
|
||||
Map.from_struct(embed)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|
||||
|
||||
{:ok, embed} = Claper.Embeds.create_embed(attrs)
|
||||
embed
|
||||
end
|
||||
|
||||
{:ok, embeds}
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_quizzes(original, changes) do
|
||||
case get_in(original.presentation_file.quizzes) do
|
||||
quizzes when is_list(quizzes) ->
|
||||
quizzes =
|
||||
for quiz <- quizzes do
|
||||
attrs =
|
||||
Map.from_struct(quiz)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, changes.presentation_file.id)
|
||||
|> Map.put(:quiz_questions, Enum.map(quiz.quiz_questions, &map_quiz_question/1))
|
||||
|
||||
{:ok, quiz} = Claper.Quizzes.create_quiz(attrs)
|
||||
quiz
|
||||
end
|
||||
|
||||
{:ok, quizzes}
|
||||
|
||||
_ ->
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp map_quiz_question(question) do
|
||||
Map.from_struct(question)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(
|
||||
:quiz_question_opts,
|
||||
Enum.map(question.quiz_question_opts, &map_quiz_question_opt/1)
|
||||
)
|
||||
end
|
||||
|
||||
defp map_quiz_question_opt(opt) do
|
||||
Map.from_struct(opt)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:response_count, 0)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a event.
|
||||
|
||||
@@ -693,7 +763,20 @@ defmodule Claper.Events do
|
||||
|
||||
"""
|
||||
def delete_event(%Event{} = event) do
|
||||
Repo.delete(event)
|
||||
leaders =
|
||||
for %{email: email} <- get_activity_leaders_for_event(event.id) do
|
||||
Accounts.get_user_by_email(email)
|
||||
end
|
||||
|
||||
with {:ok, event} <- Repo.delete(event) do
|
||||
broadcast_user_events(event.user_id, {:deleted, event})
|
||||
|
||||
for leader <- leaders do
|
||||
broadcast_user_events(leader.id, {:deleted, event})
|
||||
end
|
||||
|
||||
{:ok, event}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -709,8 +792,6 @@ defmodule Claper.Events do
|
||||
Event.changeset(event, attrs)
|
||||
end
|
||||
|
||||
alias Claper.Events.ActivityLeader
|
||||
|
||||
@doc """
|
||||
Creates a activity leader.
|
||||
|
||||
@@ -756,7 +837,7 @@ defmodule Claper.Events do
|
||||
"""
|
||||
def get_activity_leaders_for_event(event_id) do
|
||||
from(a in ActivityLeader,
|
||||
left_join: u in Claper.Accounts.User,
|
||||
left_join: u in Accounts.User,
|
||||
on: u.email == a.email,
|
||||
where: a.event_id == ^event_id,
|
||||
select: %{a | user_id: u.id}
|
||||
@@ -777,13 +858,47 @@ defmodule Claper.Events do
|
||||
ActivityLeader.changeset(activity_leader, attrs)
|
||||
end
|
||||
|
||||
defp broadcast({:ok, e, event_uuid}, event) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{event_uuid}",
|
||||
{event, event_uuid}
|
||||
)
|
||||
@doc """
|
||||
Subscribes to an event's public `Phoenix.PubSub` topic.
|
||||
|
||||
{:ok, e}
|
||||
The broadcasted messages match the pattern:
|
||||
|
||||
* {:terminated, event_uuid}
|
||||
|
||||
"""
|
||||
def subscribe_event(event_uuid) when is_binary(event_uuid) do
|
||||
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_uuid}")
|
||||
end
|
||||
|
||||
defp broadcast_event(event_uuid, message) when is_binary(event_uuid) do
|
||||
Phoenix.PubSub.broadcast(Claper.PubSub, "event:#{event_uuid}", message)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Subscribes to a user's events private `Phoenix.PubSub` topic.
|
||||
|
||||
The broadcasted messages match the pattern:
|
||||
|
||||
* {:created, %Event{}}
|
||||
* {:updated, %Event{}}
|
||||
* {:deleted, %Event{}}
|
||||
|
||||
"""
|
||||
def subscribe_user_events(user_id) when is_integer(user_id) do
|
||||
Phoenix.PubSub.subscribe(Claper.PubSub, "user:#{user_id}:events")
|
||||
end
|
||||
|
||||
def broadcast_user_events(user_id, message) when is_integer(user_id) do
|
||||
Phoenix.PubSub.broadcast(Claper.PubSub, "user:#{user_id}:events", message)
|
||||
end
|
||||
|
||||
defp broadcast_all_users({_type, %Event{} = event} = message, _opts \\ []) do
|
||||
event = Repo.preload(event, [:leaders])
|
||||
broadcast_user_events(event.user_id, message)
|
||||
|
||||
for %{email: leader_email} <- event.leaders do
|
||||
leader = Accounts.get_user_by_email(leader_email)
|
||||
broadcast_user_events(leader.id, message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,11 +45,12 @@ defmodule Claper.Events.Event do
|
||||
:code,
|
||||
:started_at,
|
||||
:expired_at,
|
||||
:audience_peak
|
||||
:audience_peak,
|
||||
:user_id
|
||||
])
|
||||
|> cast_assoc(:presentation_file)
|
||||
|> cast_assoc(:leaders)
|
||||
|> validate_required([:code, :name])
|
||||
|> validate_required([:name, :code, :started_at])
|
||||
end
|
||||
|
||||
def create_changeset(event, attrs) do
|
||||
@@ -57,7 +58,7 @@ defmodule Claper.Events.Event do
|
||||
|> cast(attrs, [:name, :code, :user_id, :started_at, :expired_at])
|
||||
|> cast_assoc(:presentation_file)
|
||||
|> cast_assoc(:leaders)
|
||||
|> validate_required([:code, :started_at])
|
||||
|> validate_required([:name, :code, :started_at, :user_id])
|
||||
|> validate_length(:code, min: 5, max: 10)
|
||||
|> validate_length(:name, min: 5, max: 50)
|
||||
|> downcase_code
|
||||
@@ -74,10 +75,10 @@ defmodule Claper.Events.Event do
|
||||
|
||||
def update_changeset(event, attrs) do
|
||||
event
|
||||
|> cast(attrs, [:name, :code, :started_at, :expired_at, :audience_peak])
|
||||
|> cast(attrs, [:name, :code, :started_at, :expired_at, :audience_peak, :user_id])
|
||||
|> cast_assoc(:presentation_file)
|
||||
|> cast_assoc(:leaders)
|
||||
|> validate_required([:code, :started_at])
|
||||
|> validate_required([:name, :code, :started_at, :user_id])
|
||||
|> validate_length(:code, min: 5, max: 10)
|
||||
|> validate_length(:name, min: 5, max: 50)
|
||||
|> downcase_code
|
||||
@@ -90,8 +91,8 @@ defmodule Claper.Events.Event do
|
||||
change(event, expired_at: expiry)
|
||||
end
|
||||
|
||||
def subscribe(event_id) do
|
||||
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_id}")
|
||||
def subscribe(event_uuid) do
|
||||
Phoenix.PubSub.subscribe(Claper.PubSub, "event:#{event_uuid}")
|
||||
end
|
||||
|
||||
def started?(event) do
|
||||
|
||||
@@ -4,19 +4,21 @@ defmodule Claper.Forms.Field do
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
name: String.t(),
|
||||
type: String.t()
|
||||
type: String.t(),
|
||||
required: boolean()
|
||||
}
|
||||
|
||||
@primary_key false
|
||||
embedded_schema do
|
||||
field :name, :string
|
||||
field :type, :string
|
||||
field :required, :boolean, default: true
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(form, attrs \\ %{}) do
|
||||
form
|
||||
|> cast(attrs, [:name, :type])
|
||||
|> cast(attrs, [:name, :type, :required])
|
||||
|> validate_required([:name, :type])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,6 @@ defmodule Claper.Forms.FormSubmit do
|
||||
def changeset(form_submit, attrs) do
|
||||
form_submit
|
||||
|> cast(attrs, [:attendee_identifier, :user_id, :form_id, :response])
|
||||
|> validate_required([:form_id])
|
||||
|> validate_required([:form_id, :user_id, :response])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,7 +27,11 @@ defmodule Claper.Polls.Poll do
|
||||
field :show_results, :boolean
|
||||
|
||||
belongs_to :presentation_file, Claper.Presentations.PresentationFile
|
||||
has_many :poll_opts, Claper.Polls.PollOpt, on_replace: :delete
|
||||
|
||||
has_many :poll_opts, Claper.Polls.PollOpt,
|
||||
preload_order: [asc: :id],
|
||||
on_replace: :delete
|
||||
|
||||
has_many :poll_votes, Claper.Polls.PollVote, on_replace: :delete
|
||||
|
||||
timestamps()
|
||||
|
||||
@@ -31,6 +31,65 @@ defmodule Claper.Presentations do
|
||||
def get_presentation_files_by_hash(hash) when is_nil(hash),
|
||||
do: []
|
||||
|
||||
@doc """
|
||||
Returns a list of JPG slide URLs for a given presentation.
|
||||
|
||||
When a `Claper.Presentations.PresentationFile{}` struct is provided, the
|
||||
function builds the list of URLs programmatically from the `hash` and
|
||||
`length` fields.
|
||||
|
||||
When an integer or binary `hash` is provided, it queries the database for the
|
||||
associated presentation file and builds the list of URLs programmatically
|
||||
from that.
|
||||
|
||||
When `nil` is provided or when no presentation file is found for the given
|
||||
`hash`, it returns an empty list.
|
||||
"""
|
||||
def get_slide_urls(hash_or_presentation_file)
|
||||
|
||||
def get_slide_urls(nil), do: []
|
||||
|
||||
def get_slide_urls(hash) when is_integer(hash), do: get_slide_urls(to_string(hash))
|
||||
|
||||
def get_slide_urls(hash) when is_binary(hash) do
|
||||
case Repo.get_by(PresentationFile, hash: hash) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
presentation ->
|
||||
get_slide_urls(hash, presentation.length)
|
||||
end
|
||||
end
|
||||
|
||||
def get_slide_urls(%PresentationFile{} = presentation) do
|
||||
get_slide_urls(presentation.hash, presentation.length)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a list of JPG slide URLs for a given presentation `hash` and
|
||||
`length`. See also `get_slide_urls/1`.
|
||||
"""
|
||||
def get_slide_urls(hash, length) when is_binary(hash) and is_integer(length) do
|
||||
config = Application.get_env(:claper, :presentations)
|
||||
|
||||
case Keyword.fetch!(config, :storage) do
|
||||
"local" ->
|
||||
for index <- 1..length do
|
||||
"/uploads/#{hash}/#{index}.jpg"
|
||||
end
|
||||
|
||||
"s3" ->
|
||||
base_url = Keyword.fetch!(config, :s3_public_url)
|
||||
|
||||
for index <- 1..length do
|
||||
base_url <> "/presentations/#{hash}/#{index}.jpg"
|
||||
end
|
||||
|
||||
storage ->
|
||||
raise "Unrecognised presentations storage value #{storage}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a presentation_files.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ defmodule Claper.Presentations.PresentationState do
|
||||
message_reaction_enabled: boolean() | nil,
|
||||
banned: [String.t()] | nil,
|
||||
show_only_pinned: boolean() | nil,
|
||||
show_attendee_count: boolean() | nil,
|
||||
presentation_file_id: integer() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
@@ -28,6 +29,7 @@ defmodule Claper.Presentations.PresentationState do
|
||||
field :message_reaction_enabled, :boolean, default: true
|
||||
field :banned, {:array, :string}, default: []
|
||||
field :show_only_pinned, :boolean, default: false
|
||||
field :show_attendee_count, :boolean, default: true
|
||||
|
||||
belongs_to :presentation_file, Claper.Presentations.PresentationFile
|
||||
|
||||
@@ -47,6 +49,7 @@ defmodule Claper.Presentations.PresentationState do
|
||||
:chat_enabled,
|
||||
:anonymous_chat_enabled,
|
||||
:show_only_pinned,
|
||||
:show_attendee_count,
|
||||
:message_reaction_enabled
|
||||
])
|
||||
|> validate_required([])
|
||||
|
||||
@@ -2,6 +2,8 @@ defmodule Claper.Quizzes do
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Repo
|
||||
|
||||
alias Claper.Accounts.User
|
||||
|
||||
alias Claper.Quizzes.Quiz
|
||||
alias Claper.Quizzes.QuizQuestion
|
||||
alias Claper.Quizzes.QuizQuestionOpt
|
||||
@@ -265,28 +267,62 @@ defmodule Claper.Quizzes do
|
||||
{:ok, quiz}
|
||||
|
||||
"""
|
||||
def submit_quiz(user_id, quiz_opts, quiz_id)
|
||||
when is_number(user_id) and is_list(quiz_opts) do
|
||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
|
||||
Ecto.Multi.update(
|
||||
multi,
|
||||
{:update_quiz_opt, opt.id},
|
||||
|
||||
# Pattern match on user, from user we create QuizResponse struct
|
||||
# def submit_quiz(user_id, quiz_opts, quiz_id)
|
||||
# when is_number(user_id) and is_list(quiz_opts) do
|
||||
# quiz_opts = Enum.with_index(quiz_opts)
|
||||
|
||||
# case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn {opt, index}, multi ->
|
||||
# unique_key = "#{opt.id}_#{user_id + index}"
|
||||
|
||||
# multi
|
||||
# |> Ecto.Multi.update(
|
||||
# "update_quiz_opt_#{unique_key}",
|
||||
# QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
|
||||
# )
|
||||
# |> Ecto.Multi.insert(
|
||||
# "insert_quiz_response_#{unique_key}",
|
||||
# QuizResponse.changeset(%QuizResponse{}, %{
|
||||
# user_id: user_id,
|
||||
# quiz_question_opt_id: opt.id,
|
||||
# quiz_question_id: opt.quiz_question_id,
|
||||
# quiz_id: quiz_id
|
||||
# })
|
||||
# )
|
||||
# end)
|
||||
# |> Repo.transact() do
|
||||
# {:ok, _} ->
|
||||
# quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
# Lti13.QuizScoreReporter.report_quiz_score(quiz, user_id)
|
||||
# {:ok, quiz}
|
||||
# end
|
||||
# end
|
||||
|
||||
def submit_quiz(%User{} = user, quiz_opts, quiz_id) do
|
||||
quiz_opts = Enum.with_index(quiz_opts)
|
||||
|
||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn {opt, index}, multi ->
|
||||
unique_key = "#{opt.id}_#{user.id + index}"
|
||||
|
||||
multi
|
||||
|> Ecto.Multi.update(
|
||||
"update_quiz_opt_#{unique_key}",
|
||||
QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
|
||||
)
|
||||
|> Ecto.Multi.insert(
|
||||
{:insert_quiz_response, opt.id},
|
||||
QuizResponse.changeset(%QuizResponse{}, %{
|
||||
user_id: user_id,
|
||||
"insert_quiz_response_#{unique_key}",
|
||||
Ecto.build_assoc(user, :quiz_responses, %{
|
||||
quiz_question_opt_id: opt.id,
|
||||
quiz_question_id: opt.quiz_question_id,
|
||||
quiz_id: quiz_id
|
||||
})
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction() do
|
||||
|> Repo.transact() do
|
||||
{:ok, _} ->
|
||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
Lti13.QuizScoreReporter.report_quiz_score(quiz, user_id)
|
||||
Lti13.QuizScoreReporter.report_quiz_score(quiz, user.id)
|
||||
{:ok, quiz}
|
||||
end
|
||||
end
|
||||
@@ -294,8 +330,8 @@ defmodule Claper.Quizzes do
|
||||
def submit_quiz(attendee_identifier, quiz_opts, quiz_id)
|
||||
when is_binary(attendee_identifier) and is_list(quiz_opts) do
|
||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
|
||||
Ecto.Multi.update(
|
||||
multi,
|
||||
multi
|
||||
|> Ecto.Multi.update(
|
||||
{:update_quiz_opt, opt.id},
|
||||
QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
|
||||
)
|
||||
@@ -309,7 +345,7 @@ defmodule Claper.Quizzes do
|
||||
})
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction() do
|
||||
|> Repo.transact() do
|
||||
{:ok, _} ->
|
||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
{:ok, quiz}
|
||||
|
||||
@@ -2,6 +2,22 @@ defmodule Claper.Quizzes.Quiz do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
title: String.t(),
|
||||
position: integer() | nil,
|
||||
enabled: boolean(),
|
||||
show_results: boolean(),
|
||||
allow_anonymous: boolean(),
|
||||
lti_line_item_url: String.t() | nil,
|
||||
lti_resource: Lti13.Resources.Resource.t() | nil,
|
||||
quiz_responses: [Claper.Quizzes.QuizResponse.t()] | nil,
|
||||
quiz_questions: [Claper.Quizzes.QuizQuestion.t()] | nil,
|
||||
presentation_file: Claper.Presentations.PresentationFile.t(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "quizzes" do
|
||||
field :title, :string
|
||||
field :position, :integer, default: 0
|
||||
|
||||
@@ -2,7 +2,17 @@ defmodule Claper.Quizzes.QuizQuestion do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
import ClaperWeb.Gettext
|
||||
use Gettext, backend: ClaperWeb.Gettext
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
content: String.t(),
|
||||
type: String.t(),
|
||||
quiz: Claper.Quizzes.Quiz.t() | nil,
|
||||
quiz_question_opts: [Claper.Quizzes.QuizQuestionOpt.t()] | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "quiz_questions" do
|
||||
field :content, :string
|
||||
|
||||
@@ -2,6 +2,17 @@ defmodule Claper.Quizzes.QuizQuestionOpt do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
content: String.t(),
|
||||
is_correct: boolean(),
|
||||
response_count: integer(),
|
||||
percentage: float() | nil,
|
||||
quiz_question: Claper.Quizzes.QuizQuestion.t() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "quiz_question_opts" do
|
||||
field :content, :string
|
||||
field :is_correct, :boolean, default: false
|
||||
|
||||
@@ -2,6 +2,17 @@ defmodule Claper.Quizzes.QuizResponse do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
attendee_identifier: String.t() | nil,
|
||||
quiz: Claper.Quizzes.Quiz.t() | nil,
|
||||
quiz_question: Claper.Quizzes.QuizQuestion.t() | nil,
|
||||
quiz_question_opt: Claper.Quizzes.QuizQuestionOpt.t() | nil,
|
||||
user: Claper.Accounts.User.t() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "quiz_responses" do
|
||||
field :attendee_identifier, :string
|
||||
|
||||
@@ -20,8 +31,7 @@ defmodule Claper.Quizzes.QuizResponse do
|
||||
:attendee_identifier,
|
||||
:quiz_id,
|
||||
:quiz_question_id,
|
||||
:quiz_question_opt_id,
|
||||
:user_id
|
||||
:quiz_question_opt_id
|
||||
])
|
||||
|> validate_required([:quiz_id, :quiz_question_id, :quiz_question_opt_id])
|
||||
end
|
||||
|
||||
@@ -21,7 +21,8 @@ defmodule Claper.Release do
|
||||
for repo <- repos() do
|
||||
{:ok, _, _} =
|
||||
Ecto.Migrator.with_repo(repo, fn _repo ->
|
||||
Code.eval_file("priv/repo/seeds.exs")
|
||||
seed_script = Application.app_dir(@app, "priv/repo/seeds.exs")
|
||||
Code.eval_file(seed_script)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Claper.Tasks.Converter do
|
||||
We use a hash to identify the presentation. A new hash is generated when the conversion is finished and the presentation is being uploaded.
|
||||
"""
|
||||
|
||||
alias ExAws.S3
|
||||
alias Claper.Events
|
||||
alias Porcelain.Result
|
||||
|
||||
@doc """
|
||||
@@ -19,11 +19,7 @@ defmodule Claper.Tasks.Converter do
|
||||
"status" => "progress"
|
||||
})
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"events:#{user_id}",
|
||||
{:presentation_file_process_done, presentation}
|
||||
)
|
||||
Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation})
|
||||
|
||||
path =
|
||||
Path.join([
|
||||
@@ -53,11 +49,11 @@ defmodule Claper.Tasks.Converter do
|
||||
)
|
||||
else
|
||||
stream =
|
||||
ExAws.S3.list_objects(get_aws_bucket(), prefix: "presentations/#{hash}")
|
||||
ExAws.S3.list_objects(get_s3_bucket(), prefix: "presentations/#{hash}")
|
||||
|> ExAws.stream!()
|
||||
|> Stream.map(& &1.key)
|
||||
|
||||
ExAws.S3.delete_all_objects(get_aws_bucket(), stream) |> ExAws.request()
|
||||
ExAws.S3.delete_all_objects(get_s3_bucket(), stream) |> ExAws.request()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -137,9 +133,9 @@ defmodule Claper.Tasks.Converter do
|
||||
IO.puts("Uploads #{f} to presentations/#{new_hash}/#{Path.basename(f)}")
|
||||
|
||||
f
|
||||
|> S3.Upload.stream_file()
|
||||
|> S3.upload(
|
||||
get_aws_bucket(),
|
||||
|> ExAws.S3.Upload.stream_file()
|
||||
|> ExAws.S3.upload(
|
||||
get_s3_bucket(),
|
||||
"presentations/#{new_hash}/#{Path.basename(f)}",
|
||||
acl: "public-read"
|
||||
)
|
||||
@@ -167,11 +163,7 @@ defmodule Claper.Tasks.Converter do
|
||||
}) do
|
||||
if get_presentation_storage() != "local", do: File.rm_rf!(path)
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"events:#{user_id}",
|
||||
{:presentation_file_process_done, presentation}
|
||||
)
|
||||
Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -182,11 +174,7 @@ defmodule Claper.Tasks.Converter do
|
||||
}) do
|
||||
File.rm_rf!(path)
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"events:#{user_id}",
|
||||
{:presentation_file_process_done, presentation}
|
||||
)
|
||||
Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -198,8 +186,8 @@ defmodule Claper.Tasks.Converter do
|
||||
Application.get_env(:claper, :storage_dir)
|
||||
end
|
||||
|
||||
defp get_aws_bucket do
|
||||
Application.get_env(:claper, :presentations) |> Keyword.get(:aws_bucket)
|
||||
defp get_s3_bucket do
|
||||
Application.get_env(:claper, :presentations) |> Keyword.get(:s3_bucket)
|
||||
end
|
||||
|
||||
defp get_resolution do
|
||||
|
||||
@@ -6,19 +6,25 @@ defmodule Claper.Workers.Mailers do
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"type" => type, "user_id" => user_id, "url" => url}})
|
||||
when type in ["confirm", "reset", "update_email"] do
|
||||
when type in ["confirm", "reset"] do
|
||||
user = Claper.Accounts.get_user!(user_id)
|
||||
|
||||
email =
|
||||
case type do
|
||||
"confirm" -> UserNotifier.confirm(user, url)
|
||||
"reset" -> UserNotifier.reset(user, url)
|
||||
"update_email" -> UserNotifier.update_email(user, url)
|
||||
end
|
||||
|
||||
Mailer.deliver(email)
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{
|
||||
args: %{"type" => "update_email", "new_email" => new_email, "url" => url}
|
||||
}) do
|
||||
email = UserNotifier.update_email(new_email, url)
|
||||
Mailer.deliver(email)
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{args: %{"type" => "magic", "email" => email, "url" => url}}) do
|
||||
email = UserNotifier.magic(email, url)
|
||||
Mailer.deliver(email)
|
||||
@@ -50,8 +56,8 @@ defmodule Claper.Workers.Mailers do
|
||||
new(%{type: "reset", user_id: user_id, url: url})
|
||||
end
|
||||
|
||||
def new_update_email(user_id, url) do
|
||||
new(%{type: "update_email", user_id: user_id, url: url})
|
||||
def new_update_email(new_email, url) do
|
||||
new(%{type: "update_email", new_email: new_email, url: url})
|
||||
end
|
||||
|
||||
def new_magic_link(email, url) do
|
||||
|
||||
@@ -24,7 +24,7 @@ defmodule ClaperWeb do
|
||||
use Phoenix.Controller, namespace: ClaperWeb
|
||||
|
||||
import Plug.Conn
|
||||
import ClaperWeb.Gettext
|
||||
use Gettext, backend: ClaperWeb.Gettext
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
@@ -87,7 +87,7 @@ defmodule ClaperWeb do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
import Phoenix.View
|
||||
import ClaperWeb.Gettext
|
||||
use Gettext, backend: ClaperWeb.Gettext
|
||||
end
|
||||
end
|
||||
|
||||
@@ -99,7 +99,7 @@ defmodule ClaperWeb do
|
||||
use PhoenixHTMLHelpers
|
||||
|
||||
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
|
||||
import Phoenix.LiveView.Helpers
|
||||
import Phoenix.Component
|
||||
import ClaperWeb.LiveHelpers
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@@ -107,7 +107,7 @@ defmodule ClaperWeb do
|
||||
import Phoenix.View
|
||||
|
||||
import ClaperWeb.ErrorHelpers
|
||||
import ClaperWeb.Gettext
|
||||
use Gettext, backend: ClaperWeb.Gettext
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
|
||||
@@ -197,7 +197,7 @@ defmodule ClaperWeb.StatController do
|
||||
# Private functions
|
||||
|
||||
defp authorize_event_access(user, event) do
|
||||
if Events.leaded_by?(user.email, event) || event.user_id == user.id do
|
||||
if Events.led_by?(user.email, event) || event.user_id == user.id do
|
||||
:ok
|
||||
else
|
||||
:unauthorized
|
||||
|
||||
@@ -103,7 +103,7 @@ defmodule ClaperWeb.UserOidcAuth do
|
||||
Application.get_env(:claper, ClaperWeb.Endpoint)[:base_url]
|
||||
end
|
||||
|
||||
defp opts(pkce_verifier \\ nil) do
|
||||
defp opts(pkce_verifier) do
|
||||
url = base_url()
|
||||
|
||||
base_opts = %{
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule ClaperWeb.Gettext do
|
||||
By using [Gettext](https://hexdocs.pm/gettext),
|
||||
your module gains a set of macros for translations, for example:
|
||||
|
||||
import ClaperWeb.Gettext
|
||||
use Gettext, backend: ClaperWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
@@ -20,5 +20,5 @@ defmodule ClaperWeb.Gettext do
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :claper
|
||||
use Gettext.Backend, otp_app: :claper
|
||||
end
|
||||
|
||||
169
lib/claper_web/helpers/csv_exporter.ex
Normal file
169
lib/claper_web/helpers/csv_exporter.ex
Normal file
@@ -0,0 +1,169 @@
|
||||
defmodule ClaperWeb.Helpers.CSVExporter do
|
||||
@moduledoc """
|
||||
Helper module for exporting data to CSV format.
|
||||
|
||||
This module provides functions to convert collections of data
|
||||
into CSV format for download in the admin panel.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Converts a list of records to CSV format.
|
||||
|
||||
## Parameters
|
||||
- records: List of records/maps to convert
|
||||
- headers: List of column headers
|
||||
- fields: List of fields to include in the CSV
|
||||
|
||||
## Returns
|
||||
- CSV formatted string
|
||||
"""
|
||||
def to_csv(records, headers, fields) do
|
||||
records
|
||||
|> build_rows(fields)
|
||||
|> add_headers(headers)
|
||||
|> CSV.encode()
|
||||
|> Enum.to_list()
|
||||
|> Enum.join("")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a timestamped filename for CSV exports.
|
||||
|
||||
## Parameters
|
||||
- prefix: Prefix for the filename (e.g., "users", "events")
|
||||
|
||||
## Returns
|
||||
- String filename with timestamp
|
||||
"""
|
||||
def generate_filename(prefix) do
|
||||
date = DateTime.utc_now() |> Calendar.strftime("%Y%m%d_%H%M%S")
|
||||
"#{prefix}_export_#{date}.csv"
|
||||
end
|
||||
|
||||
# Private helper functions
|
||||
|
||||
defp build_rows(records, fields) do
|
||||
Enum.map(records, fn record ->
|
||||
Enum.map(fields, fn field ->
|
||||
format_field_value(Map.get(record, field))
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp add_headers(rows, headers) do
|
||||
[headers | rows]
|
||||
end
|
||||
|
||||
defp format_field_value(value) when is_boolean(value) do
|
||||
if value, do: "Yes", else: "No"
|
||||
end
|
||||
|
||||
defp format_field_value(%DateTime{} = dt) do
|
||||
Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
|
||||
end
|
||||
|
||||
defp format_field_value(%NaiveDateTime{} = dt) do
|
||||
Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
|
||||
end
|
||||
|
||||
defp format_field_value(nil), do: ""
|
||||
|
||||
defp format_field_value(value), do: to_string(value)
|
||||
|
||||
@doc """
|
||||
Exports a list of users to CSV format.
|
||||
|
||||
## Parameters
|
||||
- users: List of User structs to export
|
||||
|
||||
## Returns
|
||||
- CSV formatted string
|
||||
"""
|
||||
def export_users_to_csv(users) do
|
||||
headers = ["Email", "Name", "Role", "Created At"]
|
||||
|
||||
# Transform users to include role name
|
||||
users_with_role =
|
||||
Enum.map(users, fn user ->
|
||||
role_name = if user.role, do: user.role.name, else: ""
|
||||
|
||||
%{
|
||||
email: user.email,
|
||||
# Users don't have a name field currently
|
||||
name: "",
|
||||
role: role_name,
|
||||
inserted_at: user.inserted_at
|
||||
}
|
||||
end)
|
||||
|
||||
fields = [:email, :name, :role, :inserted_at]
|
||||
|
||||
to_csv(users_with_role, headers, fields)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Exports a list of events to CSV format.
|
||||
|
||||
## Parameters
|
||||
- events: List of Event structs to export
|
||||
|
||||
## Returns
|
||||
- CSV formatted string
|
||||
"""
|
||||
def export_events_to_csv(events) do
|
||||
headers = [
|
||||
"Name",
|
||||
"Description",
|
||||
"Start Date",
|
||||
"End Date",
|
||||
"Status"
|
||||
]
|
||||
|
||||
# Transform events to include description and status
|
||||
events_transformed =
|
||||
Enum.map(events, fn event ->
|
||||
status =
|
||||
cond do
|
||||
event.expired_at &&
|
||||
NaiveDateTime.compare(event.expired_at, NaiveDateTime.utc_now()) == :lt ->
|
||||
"completed"
|
||||
|
||||
event.started_at &&
|
||||
NaiveDateTime.compare(event.started_at, NaiveDateTime.utc_now()) == :gt ->
|
||||
"scheduled"
|
||||
|
||||
true ->
|
||||
"active"
|
||||
end
|
||||
|
||||
%{
|
||||
name: event.name,
|
||||
# Events don't have a description field currently
|
||||
description: "",
|
||||
start_date: event.started_at,
|
||||
end_date: event.expired_at,
|
||||
status: status
|
||||
}
|
||||
end)
|
||||
|
||||
fields = [:name, :description, :start_date, :end_date, :status]
|
||||
|
||||
to_csv(events_transformed, headers, fields)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Exports a list of OIDC providers to CSV format.
|
||||
|
||||
## Parameters
|
||||
- providers: List of Provider structs to export
|
||||
|
||||
## Returns
|
||||
- CSV formatted string
|
||||
"""
|
||||
def export_oidc_providers_to_csv(providers) do
|
||||
headers = ["Name", "Issuer", "Client ID", "Active"]
|
||||
fields = [:name, :issuer, :client_id, :active]
|
||||
|
||||
to_csv(providers, headers, fields)
|
||||
end
|
||||
end
|
||||
109
lib/claper_web/live/admin_live/dashboard_live.ex
Normal file
109
lib/claper_web/live/admin_live/dashboard_live.ex
Normal file
@@ -0,0 +1,109 @@
|
||||
defmodule ClaperWeb.AdminLive.DashboardLive do
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Admin
|
||||
alias Claper.Events.Event
|
||||
alias Claper.Repo
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
if connected?(socket) do
|
||||
# Set up periodic updates every 30 seconds
|
||||
:timer.send_interval(30_000, self(), :update_charts)
|
||||
end
|
||||
|
||||
with %{"locale" => locale} <- session do
|
||||
Gettext.put_locale(ClaperWeb.Gettext, locale)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Dashboard"))
|
||||
|> assign(:selected_period, :day)
|
||||
|> assign(:days_back, 30)
|
||||
|> load_dashboard_data()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change_period", %{"period" => period}, socket) do
|
||||
period_atom = String.to_atom(period)
|
||||
|
||||
days_back =
|
||||
case period_atom do
|
||||
:day -> 30
|
||||
# 12 weeks
|
||||
:week -> 84
|
||||
# 12 months
|
||||
:month -> 365
|
||||
_ -> 30
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:selected_period, period_atom)
|
||||
|> assign(:days_back, days_back)
|
||||
|> load_chart_data()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("refresh_data", _params, socket) do
|
||||
socket = load_dashboard_data(socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:update_charts, socket) do
|
||||
socket = load_chart_data(socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp load_dashboard_data(socket) do
|
||||
stats = Admin.get_dashboard_stats()
|
||||
growth_metrics = Admin.get_growth_metrics()
|
||||
activity_stats = Admin.get_activity_stats()
|
||||
|
||||
# Get recent events for the dashboard
|
||||
recent_events =
|
||||
Event
|
||||
|> order_by([e], desc: e.started_at)
|
||||
|> limit(5)
|
||||
|> preload(:user)
|
||||
|> Repo.all()
|
||||
|
||||
# Transform stats to match template expectations
|
||||
transformed_stats = %{
|
||||
total_users: stats.users_count,
|
||||
total_events: stats.events_count,
|
||||
active_events: stats.upcoming_events
|
||||
}
|
||||
|
||||
socket
|
||||
|> assign(:stats, transformed_stats)
|
||||
|> assign(:growth_metrics, growth_metrics)
|
||||
|> assign(:activity_stats, activity_stats)
|
||||
|> assign(:recent_events, recent_events)
|
||||
|> load_chart_data()
|
||||
end
|
||||
|
||||
defp load_chart_data(socket) do
|
||||
period = socket.assigns.selected_period
|
||||
days_back = socket.assigns.days_back
|
||||
|
||||
users_chart_data = Admin.get_users_over_time(period, days_back)
|
||||
events_chart_data = Admin.get_events_over_time(period, days_back)
|
||||
|
||||
socket
|
||||
|> assign(:users_chart_data, users_chart_data)
|
||||
|> assign(:events_chart_data, events_chart_data)
|
||||
end
|
||||
end
|
||||
212
lib/claper_web/live/admin_live/dashboard_live.html.heex
Normal file
212
lib/claper_web/live/admin_live/dashboard_live.html.heex
Normal file
@@ -0,0 +1,212 @@
|
||||
<!-- Header -->
|
||||
<div class="mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("Dashboard")}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div>
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats w-full stats-horizontal shadow mb-12">
|
||||
<div class="stat bg-base-100">
|
||||
<div class="stat-title">{gettext("Total Users")}</div>
|
||||
<div class="stat-value">{@stats.total_users}</div>
|
||||
<div class="stat-desc">
|
||||
<span class={
|
||||
if @growth_metrics.users_growth > 0 do
|
||||
"text-success"
|
||||
else
|
||||
"text-error"
|
||||
end
|
||||
}>
|
||||
<%= if @growth_metrics.users_growth >= 0 do %>
|
||||
↗︎
|
||||
<% else %>
|
||||
↘︎
|
||||
<% end %>
|
||||
{@growth_metrics.users_growth}%
|
||||
</span>
|
||||
{gettext("vs last month")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100">
|
||||
<div class="stat-title">{gettext("Total Events")}</div>
|
||||
<div class="stat-value">{@stats.total_events}</div>
|
||||
<div class="stat-desc">
|
||||
<span class={
|
||||
if @growth_metrics.events_growth > 0 do
|
||||
"text-success"
|
||||
else
|
||||
"text-error"
|
||||
end
|
||||
}>
|
||||
<%= if @growth_metrics.events_growth >= 0 do %>
|
||||
↗︎ +
|
||||
<% else %>
|
||||
↘︎
|
||||
<% end %>
|
||||
{@growth_metrics.events_growth}%
|
||||
</span>
|
||||
{gettext("vs last month")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100">
|
||||
<div class="stat-title">{gettext("Active Events")}</div>
|
||||
<div class="stat-value">{@stats.active_events}</div>
|
||||
<div class="stat-desc">{gettext("Currently running")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-12">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{gettext("User Growth")}</h2>
|
||||
<p class="text-sm text-base-content/60">{gettext("Last 30 days")}</p>
|
||||
<div class="h-72 relative mt-4">
|
||||
<canvas
|
||||
id="userGrowthChart"
|
||||
phx-hook="UserGrowthChart"
|
||||
data-labels={Jason.encode!(@users_chart_data.labels)}
|
||||
data-values={Jason.encode!(@users_chart_data.values)}
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{gettext("Event Creation")}</h2>
|
||||
<p class="text-sm text-base-content/60">{gettext("Last 30 days")}</p>
|
||||
<div class="h-72 relative mt-4">
|
||||
<canvas
|
||||
id="eventCreationChart"
|
||||
phx-hook="EventCreationChart"
|
||||
data-labels={Jason.encode!(@events_chart_data.labels)}
|
||||
data-values={Jason.encode!(@events_chart_data.values)}
|
||||
>
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Events Table -->
|
||||
<div class="mt-12">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">{gettext("Recent Events")}</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{gettext("Name")}</th>
|
||||
<th>{gettext("Code")}</th>
|
||||
<th>{gettext("Owner")}</th>
|
||||
<th>{gettext("Start Date")}</th>
|
||||
<th>{gettext("Audience Peak")}</th>
|
||||
<th>{gettext("Status")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for event <- @recent_events do %>
|
||||
<% now = NaiveDateTime.utc_now() %>
|
||||
<% {status_text, status_class} =
|
||||
cond do
|
||||
is_nil(event.expired_at) == false ->
|
||||
{gettext("Completed"), "badge-ghost"}
|
||||
|
||||
NaiveDateTime.compare(event.started_at, now) == :gt ->
|
||||
{gettext("Scheduled"), "badge-info"}
|
||||
|
||||
true ->
|
||||
{gettext("Active"), "badge-success"}
|
||||
end %>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-bold">{event.name}</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-sm uppercase">{event.code}</code>
|
||||
</td>
|
||||
<td>{event.user.email}</td>
|
||||
<td>{Calendar.strftime(event.started_at, "%b %d, %Y")}</td>
|
||||
<td>
|
||||
<div class="badge badge-outline">{event.audience_peak}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class={"badge #{status_class}"}>
|
||||
{status_text}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<.link
|
||||
navigate={~p"/admin/events/#{event}"}
|
||||
class="btn btn-link btn-xs"
|
||||
title={gettext("View event")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/events/#{event}/edit"}
|
||||
class="btn btn-link btn-xs text-primary"
|
||||
title={gettext("Edit event")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<%= if Enum.empty?(@recent_events) do %>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
<div class="py-8 text-base-content/60">
|
||||
{gettext("No events found")}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
175
lib/claper_web/live/admin_live/event_live.ex
Normal file
175
lib/claper_web/live/admin_live/event_live.ex
Normal file
@@ -0,0 +1,175 @@
|
||||
defmodule ClaperWeb.AdminLive.EventLive do
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.Admin
|
||||
alias Claper.Events.Event
|
||||
alias ClaperWeb.Helpers.CSVExporter
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
with %{"locale" => locale} <- session do
|
||||
Gettext.put_locale(ClaperWeb.Gettext, locale)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Events"))
|
||||
|> assign(:events, list_events())
|
||||
|> assign(:search, "")
|
||||
|> assign(:current_sort, %{field: :na, order: :asc})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Events")
|
||||
|> assign(:event, nil)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("New event"))
|
||||
|> assign(:event, %Event{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("Edit event"))
|
||||
|> assign(:event, Claper.Events.get_event!(id, [:user]))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("Event details"))
|
||||
|> assign(:event, Claper.Events.get_event!(id, [:user]))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
event = Claper.Events.get_event!(id)
|
||||
{:ok, _} = Claper.Events.delete_event(event)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Event deleted successfully"))
|
||||
|> assign(:events, list_events())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"search" => search}, socket) do
|
||||
events = search_events(search)
|
||||
{:noreply, socket |> assign(:search, search) |> assign(:events, events)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field}, socket) do
|
||||
field = String.to_existing_atom(field)
|
||||
current_sort = socket.assigns.current_sort
|
||||
|
||||
direction =
|
||||
if current_sort.field == field && current_sort.order == :asc, do: :desc, else: :asc
|
||||
|
||||
events = sort_events(socket.assigns.events, field, direction)
|
||||
current_sort = %{field: field, order: direction}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:events, events)
|
||||
|> assign(:current_sort, current_sort)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:export_csv_requested, _params}, socket) do
|
||||
filename = CSVExporter.generate_filename("events")
|
||||
csv_content = CSVExporter.export_events_to_csv(socket.assigns.events)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Events exported successfully"))
|
||||
|> push_event("download_csv", %{filename: filename, content: csv_content})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:table_action, action, event, _event_id}, socket) do
|
||||
case action do
|
||||
:view ->
|
||||
{:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}")}
|
||||
|
||||
:edit ->
|
||||
{:noreply, push_navigate(socket, to: ~p"/admin/events/#{event}/edit")}
|
||||
|
||||
:delete ->
|
||||
{:ok, _} = Claper.Events.delete_event(event)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Event deleted successfully"))
|
||||
|> assign(:events, list_events())}
|
||||
end
|
||||
end
|
||||
|
||||
defp list_events do
|
||||
Admin.list_all_events()
|
||||
end
|
||||
|
||||
defp search_events(search) when search == "", do: list_events()
|
||||
|
||||
defp search_events(search) do
|
||||
Admin.list_all_events(%{"search" => search})
|
||||
end
|
||||
|
||||
defp sort_events(events, field, order) do
|
||||
Enum.sort_by(events, &Map.get(&1, field), order)
|
||||
end
|
||||
|
||||
def sort_indicator(assigns) do
|
||||
~H"""
|
||||
<%= if @current_sort.field == @field do %>
|
||||
<%= if @current_sort.order == :asc do %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
end
|
||||
367
lib/claper_web/live/admin_live/event_live.html.heex
Normal file
367
lib/claper_web/live/admin_live/event_live.html.heex
Normal file
@@ -0,0 +1,367 @@
|
||||
<%= case @live_action do %>
|
||||
<% :index -> %>
|
||||
<div phx-hook="CSVDownloader" id="event-list">
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("Events")}</h1>
|
||||
<div class="flex space-x-3">
|
||||
<%!-- <.link navigate={~p"/admin/events/new"} class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
class="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{gettext("New event")}
|
||||
</.link> --%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="py-4">
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-6">
|
||||
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
|
||||
<label class="input">
|
||||
<svg
|
||||
class="h-[1em] opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
value={@search}
|
||||
placeholder={gettext("Search events...")}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- events Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="name"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Name")} {sort_indicator(assigns |> Map.put(:field, :name))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="code"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Code")} {sort_indicator(assigns |> Map.put(:field, :code))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
{gettext("Owner")}
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="started_at"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Started At")} {sort_indicator(
|
||||
assigns
|
||||
|> Map.put(:field, :started_at)
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="expired_at"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Expired At")} {sort_indicator(
|
||||
assigns
|
||||
|> Map.put(:field, :expired_at)
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="audience_peak"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Audience Peak")} {sort_indicator(
|
||||
assigns
|
||||
|> Map.put(:field, :audience_peak)
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= if Enum.empty?(@events) do %>
|
||||
<tr>
|
||||
<td colspan="7" class="text-center">
|
||||
{gettext("No events found")}
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= for event <- @events do %>
|
||||
<tr id={"event-#{event.id}"}>
|
||||
<td class="font-medium">
|
||||
{event.name}
|
||||
</td>
|
||||
<td class="uppercase">
|
||||
{event.code}
|
||||
</td>
|
||||
<td>
|
||||
{if event.user, do: event.user.email, else: gettext("No owner")}
|
||||
</td>
|
||||
<td>
|
||||
{if event.started_at,
|
||||
do: Calendar.strftime(event.started_at, "%Y-%m-%d %H:%M"),
|
||||
else: gettext("Not started")}
|
||||
</td>
|
||||
<td>
|
||||
{if event.expired_at,
|
||||
do: Calendar.strftime(event.expired_at, "%Y-%m-%d %H:%M"),
|
||||
else: gettext("Not expired")}
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-outline">
|
||||
{event.audience_peak || 0}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<.link
|
||||
navigate={~p"/admin/events/#{event}"}
|
||||
class="btn btn-link btn-sm"
|
||||
title={gettext("View event")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/events/#{event}/edit"}
|
||||
class="btn btn-link btn-sm text-primary"
|
||||
title={gettext("Edit event")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={event.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete this event?")
|
||||
}
|
||||
class="btn btn-link btn-sm text-error"
|
||||
title={gettext("Delete event")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :show -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("Event details")}</h1>
|
||||
<div class="flex gap-3">
|
||||
<.link navigate={~p"/admin/events"} class="btn btn-outline btn-sm">
|
||||
{gettext("Back to events")}
|
||||
</.link>
|
||||
<.link navigate={~p"/admin/events/#{@event}/edit"} class="btn btn-primary btn-sm">
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{@event.name}</h2>
|
||||
<p class="text-base-content/70 uppercase">#{@event.code}</p>
|
||||
<div class="divider"></div>
|
||||
<dl class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Name")}</dt>
|
||||
<dd class="col-span-2">{@event.name}</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Owner")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{if @event.user, do: @event.user.email, else: gettext("No owner")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Started At")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{if @event.started_at,
|
||||
do: Calendar.strftime(@event.started_at, "%Y-%m-%d %H:%M"),
|
||||
else: gettext("Not started")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Expired At")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{if @event.expired_at,
|
||||
do: Calendar.strftime(@event.expired_at, "%Y-%m-%d %H:%M"),
|
||||
else: gettext("Not expired")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Audience Peak")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{@event.audience_peak || 0} {gettext("attendees")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Created At")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@event.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Last Updated")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@event.updated_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :new -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold">{gettext("New event")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.EventLive.FormComponent}
|
||||
id="event-form"
|
||||
title={gettext("New event")}
|
||||
action={:new}
|
||||
event={@event}
|
||||
navigate={~p"/admin/events"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :edit -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold">{gettext("Edit event")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.EventLive.FormComponent}
|
||||
id={"event-form-#{@event.id}"}
|
||||
title={gettext("Edit event")}
|
||||
action={:edit}
|
||||
event={@event}
|
||||
navigate={~p"/admin/events"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
160
lib/claper_web/live/admin_live/event_live/form_component.ex
Normal file
160
lib/claper_web/live/admin_live/event_live/form_component.ex
Normal file
@@ -0,0 +1,160 @@
|
||||
defmodule ClaperWeb.AdminLive.EventLive.FormComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
alias Claper.Events
|
||||
alias Claper.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.form for={@form} id="event-form" phx-target={@myself} phx-change="validate" phx-submit="save">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="event-name"
|
||||
form={@form}
|
||||
field={:name}
|
||||
type="text"
|
||||
label={gettext("Name")}
|
||||
placeholder={gettext("Enter event name")}
|
||||
required={true}
|
||||
width_class="sm:col-span-6"
|
||||
description={gettext("A unique name for this event")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="event-code"
|
||||
form={@form}
|
||||
field={:code}
|
||||
type="text"
|
||||
label={gettext("Code")}
|
||||
placeholder={gettext("Enter event code")}
|
||||
required={true}
|
||||
width_class="sm:col-span-3"
|
||||
field_class="uppercase"
|
||||
description={gettext("A unique code for participants to join this event")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="event-started-at"
|
||||
form={@form}
|
||||
field={:started_at}
|
||||
type="datetime"
|
||||
label={gettext("Started At")}
|
||||
required={true}
|
||||
width_class="sm:col-span-3"
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="event-expired-at"
|
||||
form={@form}
|
||||
field={:expired_at}
|
||||
type="datetime"
|
||||
label={gettext("Expired At")}
|
||||
required={false}
|
||||
width_class="sm:col-span-3"
|
||||
description={gettext("When this event expires (optional)")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.SearchableSelectComponent}
|
||||
id="event-user-id"
|
||||
form={@form}
|
||||
field={:user_id}
|
||||
label={gettext("Assigned User")}
|
||||
options={@user_options}
|
||||
placeholder={gettext("Search for a user...")}
|
||||
required={true}
|
||||
width_class="sm:col-span-6"
|
||||
description={gettext("The user who owns this event (required)")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
|
||||
{if @action == :new, do: gettext("Create Event"), else: gettext("Update Event")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{event: event} = assigns, socket) do
|
||||
changeset = Events.change_event(event)
|
||||
|
||||
user_options =
|
||||
Accounts.list_users()
|
||||
|> Enum.map(&{"#{&1.email}", &1.id})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:user_options, user_options)
|
||||
|> assign_form(changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"event" => event_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.event
|
||||
|> Events.change_event(event_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"event" => event_params}, socket) do
|
||||
save_event(socket, socket.assigns.action, event_params)
|
||||
end
|
||||
|
||||
def handle_event("cancel", _params, socket) do
|
||||
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
|
||||
end
|
||||
|
||||
defp save_event(socket, :edit, event_params) do
|
||||
case Events.update_event(socket.assigns.event, event_params) do
|
||||
{:ok, event} ->
|
||||
notify_parent({:saved, event})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Event updated successfully"))
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_event(socket, :new, event_params) do
|
||||
case Events.create_event(event_params) do
|
||||
{:ok, event} ->
|
||||
notify_parent({:saved, event})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Event created successfully"))
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
assign(socket, :form, to_form(changeset))
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
184
lib/claper_web/live/admin_live/form_field_component.ex
Normal file
184
lib/claper_web/live/admin_live/form_field_component.ex
Normal file
@@ -0,0 +1,184 @@
|
||||
defmodule ClaperWeb.AdminLive.FormFieldComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{@label}</span>
|
||||
</label>
|
||||
<%= case @type do %>
|
||||
<% "text" -> %>
|
||||
{text_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input w-full " <> @field_class,
|
||||
placeholder: @placeholder,
|
||||
required: @required
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% "email" -> %>
|
||||
{email_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input w-full",
|
||||
placeholder: @placeholder,
|
||||
required: @required
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% "password" -> %>
|
||||
<div class="relative">
|
||||
{password_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input w-full pr-10",
|
||||
placeholder: @placeholder,
|
||||
required: @required,
|
||||
id: "password-field-#{@field}"
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-base-content/50 hover:text-base-content"
|
||||
phx-click={toggle_password_visibility("password-field-#{@field}")}
|
||||
>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<% "textarea" -> %>
|
||||
{textarea(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input w-full",
|
||||
placeholder: @placeholder,
|
||||
required: @required,
|
||||
rows: @rows
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% "select" -> %>
|
||||
{select(
|
||||
@form,
|
||||
@field,
|
||||
@select_options,
|
||||
[
|
||||
class: "select w-full",
|
||||
prompt: @prompt || gettext("Select an option"),
|
||||
required: @required
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% "checkbox" -> %>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start">
|
||||
{checkbox(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "checkbox checkbox-primary",
|
||||
checked:
|
||||
Phoenix.HTML.Form.input_value(@form, @field) == true ||
|
||||
Phoenix.HTML.Form.input_value(@form, @field) == "true"
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<span class="label-text ml-2">{@checkbox_label || @label}</span>
|
||||
</label>
|
||||
</div>
|
||||
<% "date" -> %>
|
||||
{date_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input input-bordered w-full",
|
||||
required: @required
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% "datetime" -> %>
|
||||
{datetime_local_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input input-bordered w-full",
|
||||
required: @required
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% "file" -> %>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="btn btn-outline btn-sm">
|
||||
<span>{gettext("Choose file")}</span>
|
||||
{file_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "sr-only",
|
||||
required: @required,
|
||||
phx_change: "file_selected",
|
||||
phx_target: @myself
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
</label>
|
||||
<span class="text-sm text-base-content/70" id={"file-name-#{@field}"}>
|
||||
{if @selected_file, do: @selected_file, else: gettext("No file chosen")}
|
||||
</span>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
{text_input(
|
||||
@form,
|
||||
@field,
|
||||
[
|
||||
class: "input input-bordered w-full",
|
||||
placeholder: @placeholder,
|
||||
required: @required
|
||||
] ++ @extra_attrs
|
||||
)}
|
||||
<% end %>
|
||||
|
||||
<label class="label">
|
||||
{error_tag(@form, @field)}
|
||||
<%= if @description do %>
|
||||
<span class="label-text-alt">{@description}</span>
|
||||
<% end %>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, selected_file: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:placeholder, fn -> "" end)
|
||||
|> assign_new(:required, fn -> false end)
|
||||
|> assign_new(:description, fn -> nil end)
|
||||
|> assign_new(:width_class, fn -> nil end)
|
||||
|> assign_new(:field_class, fn -> "" end)
|
||||
|> assign_new(:checkbox_label, fn -> nil end)
|
||||
|> assign_new(:prompt, fn -> nil end)
|
||||
|> assign_new(:select_options, fn -> [] end)
|
||||
|> assign_new(:rows, fn -> 3 end)
|
||||
|> assign_new(:extra_attrs, fn -> [] end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("file_selected", %{"_target" => [_field_name]}, socket) do
|
||||
{:noreply, assign(socket, selected_file: gettext("File selected"))}
|
||||
end
|
||||
|
||||
defp toggle_password_visibility(field_id) do
|
||||
%Phoenix.LiveView.JS{}
|
||||
|> Phoenix.LiveView.JS.dispatch("toggle-password", to: "##{field_id}")
|
||||
end
|
||||
end
|
||||
196
lib/claper_web/live/admin_live/modal_component.ex
Normal file
196
lib/claper_web/live/admin_live/modal_component.ex
Normal file
@@ -0,0 +1,196 @@
|
||||
defmodule ClaperWeb.AdminLive.ModalComponent do
|
||||
use ClaperWeb, :live_component
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
class="fixed z-50 inset-0 overflow-y-auto"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
phx-remove={hide_modal(@id)}
|
||||
style={if @show, do: "", else: "display: none;"}
|
||||
>
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<!-- Background overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
phx-click="hide"
|
||||
phx-target={@myself}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Trick browser into centering modal contents -->
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<!-- Modal panel -->
|
||||
<div class={[
|
||||
"inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle",
|
||||
@size_class
|
||||
]}>
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<%= if @icon do %>
|
||||
<div class={[
|
||||
"mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full sm:mx-0 sm:h-10 sm:w-10",
|
||||
@icon_bg_class
|
||||
]}>
|
||||
<i class={"fas #{@icon} #{@icon_text_class}"}></i>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class={[
|
||||
"mt-3 text-center sm:mt-0 sm:text-left",
|
||||
if(@icon, do: "sm:ml-4", else: "w-full")
|
||||
]}>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" id={"#{@id}-title"}>
|
||||
{@title}
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<%= if @description do %>
|
||||
<p class="text-sm text-gray-500">
|
||||
{@description}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= if @content do %>
|
||||
<div class="mt-4">
|
||||
{render_slot(@content)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<%= if @confirm_action do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="confirm"
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm",
|
||||
@confirm_class
|
||||
]}
|
||||
>
|
||||
{@confirm_action}
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @cancel_action do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="hide"
|
||||
phx-target={@myself}
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
{@cancel_action}
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @custom_actions do %>
|
||||
{render_slot(@custom_actions)}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, show: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show, fn -> false end)
|
||||
|> assign_new(:icon, fn -> nil end)
|
||||
|> assign_new(:icon_bg_class, fn -> "bg-red-100" end)
|
||||
|> assign_new(:icon_text_class, fn -> "text-red-600" end)
|
||||
|> assign_new(:description, fn -> nil end)
|
||||
|> assign_new(:content, fn -> [] end)
|
||||
|> assign_new(:confirm_action, fn -> nil end)
|
||||
|> assign_new(:confirm_class, fn -> "bg-red-600 hover:bg-red-700 focus:ring-red-500" end)
|
||||
|> assign_new(:cancel_action, fn -> "Cancel" end)
|
||||
|> assign_new(:custom_actions, fn -> [] end)
|
||||
|> assign_new(:size_class, fn -> "sm:max-w-lg sm:w-full" end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("hide", _params, socket) do
|
||||
send(self(), {:modal_cancelled, socket.assigns.id})
|
||||
{:noreply, assign(socket, show: false)}
|
||||
end
|
||||
|
||||
def handle_event("confirm", _params, socket) do
|
||||
send(self(), {:modal_confirmed, socket.assigns.id})
|
||||
{:noreply, assign(socket, show: false)}
|
||||
end
|
||||
|
||||
# Public API for controlling the modal
|
||||
def show_modal(js \\ %JS{}, modal_id) do
|
||||
js
|
||||
|> JS.show(to: "##{modal_id}")
|
||||
|> JS.add_class("animate-fade-in", to: "##{modal_id}")
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}, modal_id) do
|
||||
js
|
||||
|> JS.add_class("animate-fade-out", to: "##{modal_id}")
|
||||
|> JS.hide(to: "##{modal_id}", transition: "animate-fade-out", time: 200)
|
||||
end
|
||||
|
||||
# Preset configurations for common modal types
|
||||
def delete_modal_config(title, description) do
|
||||
%{
|
||||
icon: "fa-exclamation-triangle",
|
||||
icon_bg_class: "bg-red-100",
|
||||
icon_text_class: "text-red-600",
|
||||
title: title,
|
||||
description: description,
|
||||
confirm_action: "Delete",
|
||||
confirm_class: "bg-red-600 hover:bg-red-700 focus:ring-red-500",
|
||||
cancel_action: "Cancel"
|
||||
}
|
||||
end
|
||||
|
||||
def warning_modal_config(title, description) do
|
||||
%{
|
||||
icon: "fa-exclamation-triangle",
|
||||
icon_bg_class: "bg-yellow-100",
|
||||
icon_text_class: "text-yellow-600",
|
||||
title: title,
|
||||
description: description,
|
||||
confirm_action: "Continue",
|
||||
confirm_class: "bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500",
|
||||
cancel_action: "Cancel"
|
||||
}
|
||||
end
|
||||
|
||||
def info_modal_config(title, description) do
|
||||
%{
|
||||
icon: "fa-info-circle",
|
||||
icon_bg_class: "bg-blue-100",
|
||||
icon_text_class: "text-blue-600",
|
||||
title: title,
|
||||
description: description,
|
||||
confirm_action: "OK",
|
||||
confirm_class: "bg-blue-600 hover:bg-blue-700 focus:ring-blue-500",
|
||||
cancel_action: nil
|
||||
}
|
||||
end
|
||||
end
|
||||
155
lib/claper_web/live/admin_live/oidc_provider_live.ex
Normal file
155
lib/claper_web/live/admin_live/oidc_provider_live.ex
Normal file
@@ -0,0 +1,155 @@
|
||||
defmodule ClaperWeb.AdminLive.OidcProviderLive do
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.Accounts.Oidc
|
||||
alias Claper.Accounts.Oidc.Provider
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
with %{"locale" => locale} <- session do
|
||||
Gettext.put_locale(ClaperWeb.Gettext, locale)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("OIDC Providers"))
|
||||
|> assign(:providers, list_providers())
|
||||
|> assign(:search, "")
|
||||
|> assign(:current_sort, %{field: :na, order: :asc})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "OIDC Providers")
|
||||
|> assign(:provider, nil)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("Provider details"))
|
||||
|> assign(:provider, Oidc.get_provider!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("New provider"))
|
||||
|> assign(:provider, %Provider{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("Edit provider"))
|
||||
|> assign(:provider, Oidc.get_provider!(id))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
provider = Oidc.get_provider!(id)
|
||||
{:ok, _} = Oidc.delete_provider(provider)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Provider deleted successfully"))
|
||||
|> assign(:providers, list_providers())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"search" => search}, socket) do
|
||||
providers = search_providers(search)
|
||||
{:noreply, socket |> assign(:search, search) |> assign(:providers, providers)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field}, socket) do
|
||||
%{current_sort: %{field: current_field, order: current_order}} = socket.assigns
|
||||
|
||||
{field, order} =
|
||||
if current_field == String.to_existing_atom(field) do
|
||||
{current_field, if(current_order == :asc, do: :desc, else: :asc)}
|
||||
else
|
||||
{String.to_existing_atom(field), :asc}
|
||||
end
|
||||
|
||||
providers = sort_providers(socket.assigns.providers, field, order)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:providers, providers)
|
||||
|> assign(:current_sort, %{field: field, order: order})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{ClaperWeb.AdminLive.OidcProviderLive.FormComponent, {:saved, _provider}},
|
||||
socket
|
||||
) do
|
||||
{:noreply, assign(socket, :providers, list_providers())}
|
||||
end
|
||||
|
||||
defp list_providers do
|
||||
Oidc.list_providers()
|
||||
end
|
||||
|
||||
defp search_providers(search) when search == "", do: list_providers()
|
||||
|
||||
defp search_providers(search) do
|
||||
search_term = "%#{search}%"
|
||||
Oidc.search_providers(search_term)
|
||||
end
|
||||
|
||||
defp sort_providers(providers, field, order) do
|
||||
Enum.sort_by(providers, &Map.get(&1, field), order)
|
||||
end
|
||||
|
||||
def sort_indicator(assigns) do
|
||||
~H"""
|
||||
<%= if @current_sort.field == @field do %>
|
||||
<%= if @current_sort.order == :asc do %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
end
|
||||
358
lib/claper_web/live/admin_live/oidc_provider_live.html.heex
Normal file
358
lib/claper_web/live/admin_live/oidc_provider_live.html.heex
Normal file
@@ -0,0 +1,358 @@
|
||||
<%= case @live_action do %>
|
||||
<% :index -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("OIDC Providers")}</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.link navigate={~p"/admin/oidc_providers/new"} class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
class="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{gettext("New provider")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="py-4">
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-6">
|
||||
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
|
||||
<label class="input">
|
||||
<svg
|
||||
class="h-[1em] opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
value={@search}
|
||||
placeholder={gettext("Search providers...")}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Providers Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="name"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Name")} {sort_indicator(assigns |> Map.put(:field, :name))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="issuer"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Issuer")} {sort_indicator(assigns |> Map.put(:field, :issuer))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="active"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Status")} {sort_indicator(assigns |> Map.put(:field, :active))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="inserted_at"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Created")} {sort_indicator(
|
||||
assigns
|
||||
|> Map.put(:field, :inserted_at)
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= if Enum.empty?(@providers) do %>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">
|
||||
{gettext("No providers found")}
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= for provider <- @providers do %>
|
||||
<tr id={"provider-#{provider.id}"}>
|
||||
<td class="font-medium">
|
||||
{provider.name}
|
||||
</td>
|
||||
<td>
|
||||
{provider.issuer}
|
||||
</td>
|
||||
<td>
|
||||
<%= if provider.active do %>
|
||||
<span class="badge badge-success">
|
||||
{gettext("Active")}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">
|
||||
{gettext("Inactive")}
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
{Calendar.strftime(provider.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<.link
|
||||
navigate={~p"/admin/oidc_providers/#{provider}"}
|
||||
class="btn btn-link btn-sm"
|
||||
title={gettext("View provider")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/oidc_providers/#{provider}/edit"}
|
||||
class="btn btn-link btn-sm text-primary"
|
||||
title={gettext("Edit provider")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={provider.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete this provider?")
|
||||
}
|
||||
class="btn btn-link btn-sm text-error"
|
||||
title={gettext("Delete provider")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :show -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("OIDC Provider details")}</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.link navigate={~p"/admin/oidc_providers"} class="btn btn-outline btn-sm">
|
||||
{gettext("Back to providers")}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/oidc_providers/#{@provider}/edit"}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{@provider.name}</h2>
|
||||
<p class="text-base-content/70">{gettext("OIDC Provider")}</p>
|
||||
<div class="divider"></div>
|
||||
<dl class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Name")}</dt>
|
||||
<dd class="col-span-2">{@provider.name}</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Issuer")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{@provider.issuer}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Client ID")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{@provider.client_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Response Type")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{@provider.response_type}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Scope")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{@provider.scope}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Status")}</dt>
|
||||
<dd class="col-span-2">
|
||||
<%= if @provider.active do %>
|
||||
<span class="badge badge-success">
|
||||
{gettext("Active")}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">
|
||||
{gettext("Inactive")}
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Created At")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@provider.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Last Updated")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@provider.updated_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :new -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold">{gettext("New provider")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.OidcProviderLive.FormComponent}
|
||||
id="provider-form"
|
||||
title={gettext("New provider")}
|
||||
action={:new}
|
||||
provider={@provider}
|
||||
navigate={~p"/admin/oidc_providers"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :edit -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold">{gettext("Edit OIDC Provider")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.OidcProviderLive.FormComponent}
|
||||
id={"provider-form-#{@provider.id}"}
|
||||
title={gettext("Edit provider")}
|
||||
action={:edit}
|
||||
provider={@provider}
|
||||
navigate={~p"/admin/oidc_providers"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,222 @@
|
||||
defmodule ClaperWeb.AdminLive.OidcProviderLive.FormComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
alias Claper.Accounts.Oidc
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.form
|
||||
for={@form}
|
||||
id="provider-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="name-field"
|
||||
form={@form}
|
||||
field={:name}
|
||||
type="text"
|
||||
label={gettext("Name")}
|
||||
placeholder={gettext("Enter provider name")}
|
||||
required={true}
|
||||
width_class="sm:col-span-6"
|
||||
description={gettext("A unique name to identify this OIDC provider")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="issuer-field"
|
||||
form={@form}
|
||||
field={:issuer}
|
||||
type="text"
|
||||
label={gettext("Issuer URL")}
|
||||
placeholder={gettext("https://example.com")}
|
||||
required={true}
|
||||
width_class="sm:col-span-6"
|
||||
description={gettext("The OIDC issuer URL (must start with http:// or https://)")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="client_id-field"
|
||||
form={@form}
|
||||
field={:client_id}
|
||||
type="text"
|
||||
label={gettext("Client ID")}
|
||||
placeholder={gettext("Enter client ID")}
|
||||
required={true}
|
||||
width_class="sm:col-span-3"
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="client_secret-field"
|
||||
form={@form}
|
||||
field={:client_secret}
|
||||
type="text"
|
||||
label={gettext("Client Secret")}
|
||||
placeholder={gettext("Enter client secret")}
|
||||
required={true}
|
||||
width_class="sm:col-span-3"
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="redirect_uri-field"
|
||||
form={@form}
|
||||
field={:redirect_uri}
|
||||
type="text"
|
||||
label={gettext("Redirect URI")}
|
||||
placeholder={gettext("https://yourapp.com/auth/callback")}
|
||||
required={true}
|
||||
width_class="sm:col-span-6"
|
||||
description={
|
||||
gettext("The callback URL for your application (must start with http:// or https://)")
|
||||
}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="scope-field"
|
||||
form={@form}
|
||||
field={:scope}
|
||||
type="text"
|
||||
label={gettext("Scope")}
|
||||
placeholder={gettext("openid email profile")}
|
||||
width_class="sm:col-span-3"
|
||||
description={gettext("OIDC scopes to request (defaults to 'openid email profile')")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="response_type-field"
|
||||
form={@form}
|
||||
field={:response_type}
|
||||
type="select"
|
||||
label={gettext("Response Type")}
|
||||
select_options={[
|
||||
{gettext("Authorization Code"), "code"},
|
||||
{gettext("Implicit"), "token"},
|
||||
{gettext("Hybrid"), "code token"}
|
||||
]}
|
||||
width_class="sm:col-span-3"
|
||||
description={gettext("OAuth 2.0 response type (defaults to 'code')")}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="response_mode-field"
|
||||
form={@form}
|
||||
field={:response_mode}
|
||||
type="select"
|
||||
label={gettext("Response Mode")}
|
||||
select_options={[
|
||||
{gettext("Query"), "query"},
|
||||
{gettext("Fragment"), "fragment"},
|
||||
{gettext("Form Post"), "form_post"}
|
||||
]}
|
||||
width_class="sm:col-span-3"
|
||||
description={
|
||||
gettext("How the authorization response should be returned (defaults to 'query')")
|
||||
}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="active-field"
|
||||
form={@form}
|
||||
field={:active}
|
||||
type="checkbox"
|
||||
label={gettext("Active")}
|
||||
checkbox_label={gettext("Enable this OIDC provider")}
|
||||
width_class="sm:col-span-3"
|
||||
description={
|
||||
gettext("Whether this provider is currently active and available for authentication")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
|
||||
{if @action == :new, do: gettext("Create Provider"), else: gettext("Update Provider")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{provider: provider} = assigns, socket) do
|
||||
changeset = Oidc.change_provider(provider)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_form(changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"provider" => provider_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.provider
|
||||
|> Oidc.change_provider(provider_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"provider" => provider_params}, socket) do
|
||||
save_provider(socket, socket.assigns.action, provider_params)
|
||||
end
|
||||
|
||||
def handle_event("cancel", _params, socket) do
|
||||
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
|
||||
end
|
||||
|
||||
defp save_provider(socket, :edit, provider_params) do
|
||||
case Oidc.update_provider(socket.assigns.provider, provider_params) do
|
||||
{:ok, provider} ->
|
||||
notify_parent({:saved, provider})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Provider updated successfully"))
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_provider(socket, :new, provider_params) do
|
||||
case Oidc.create_provider(provider_params) do
|
||||
{:ok, provider} ->
|
||||
notify_parent({:saved, provider})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Provider created successfully"))
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
assign(socket, :form, to_form(changeset))
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
175
lib/claper_web/live/admin_live/search_filter_component.ex
Normal file
175
lib/claper_web/live/admin_live/search_filter_component.ex
Normal file
@@ -0,0 +1,175 @@
|
||||
defmodule ClaperWeb.AdminLive.SearchFilterComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="bg-white px-4 py-5 border-b border-gray-200 sm:px-6">
|
||||
<div class="-ml-4 -mt-2 flex items-center justify-between flex-wrap sm:flex-nowrap">
|
||||
<div class="ml-4 mt-2">
|
||||
<form phx-submit="search" phx-target={@myself} class="flex items-center">
|
||||
<div class="relative rounded-md shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={@search_value || ""}
|
||||
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder={@search_placeholder || "Search..."}
|
||||
phx-debounce="300"
|
||||
phx-change="search_change"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if @filters && length(@filters) > 0 do %>
|
||||
<div class="ml-3 flex space-x-2">
|
||||
<%= for filter <- @filters do %>
|
||||
<select
|
||||
name={filter.name}
|
||||
class="block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
phx-change="filter_change"
|
||||
phx-target={@myself}
|
||||
phx-value-filter={filter.name}
|
||||
>
|
||||
<option
|
||||
disabled={!@filter_values[filter.name]}
|
||||
selected={!@filter_values[filter.name]}
|
||||
>
|
||||
{filter.label}
|
||||
</option>
|
||||
<%= for {label, value} <- filter.options do %>
|
||||
<option value={value} selected={@filter_values[filter.name] == value}>
|
||||
{label}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
|
||||
<%= if @show_clear and (@search_value || has_active_filters?(@filter_values)) do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="clear_all"
|
||||
phx-target={@myself}
|
||||
class="ml-2 inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<% end %>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ml-4 mt-2 flex-shrink-0">
|
||||
<%= if @export_csv_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="export_csv"
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i class="fas fa-file-csv mr-2"></i> Export CSV
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @new_path do %>
|
||||
<.link
|
||||
navigate={@new_path}
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
{@new_label || "New"}
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<%= if @custom_actions do %>
|
||||
{render_slot(@custom_actions)}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, search_value: "", filter_values: %{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:search_placeholder, fn -> "Search..." end)
|
||||
|> assign_new(:filters, fn -> [] end)
|
||||
|> assign_new(:filter_values, fn -> %{} end)
|
||||
|> assign_new(:search_value, fn -> "" end)
|
||||
|> assign_new(:show_clear, fn -> true end)
|
||||
|> assign_new(:export_csv_enabled, fn -> false end)
|
||||
|> assign_new(:new_path, fn -> nil end)
|
||||
|> assign_new(:new_label, fn -> "New" end)
|
||||
|> assign_new(:custom_actions, fn -> [] end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"search" => search_value}, socket) do
|
||||
send(
|
||||
self(),
|
||||
{:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}}
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, search_value: search_value)}
|
||||
end
|
||||
|
||||
def handle_event("search_change", %{"search" => search_value}, socket) do
|
||||
send(
|
||||
self(),
|
||||
{:search_filter_changed, %{search: search_value, filters: socket.assigns.filter_values}}
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, search_value: search_value)}
|
||||
end
|
||||
|
||||
def handle_event("filter_change", %{"filter" => filter_name, "value" => filter_value}, socket) do
|
||||
filter_values = Map.put(socket.assigns.filter_values, filter_name, filter_value)
|
||||
|
||||
send(
|
||||
self(),
|
||||
{:search_filter_changed, %{search: socket.assigns.search_value, filters: filter_values}}
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, filter_values: filter_values)}
|
||||
end
|
||||
|
||||
def handle_event("clear_all", _params, socket) do
|
||||
send(self(), {:search_filter_changed, %{search: "", filters: %{}}})
|
||||
{:noreply, assign(socket, search_value: "", filter_values: %{})}
|
||||
end
|
||||
|
||||
def handle_event("export_csv", _params, socket) do
|
||||
send(
|
||||
self(),
|
||||
{:export_csv_requested,
|
||||
%{search: socket.assigns.search_value, filters: socket.assigns.filter_values}}
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp has_active_filters?(filter_values) do
|
||||
Enum.any?(filter_values, fn {_key, value} -> value != nil and value != "" end)
|
||||
end
|
||||
end
|
||||
170
lib/claper_web/live/admin_live/searchable_select_component.ex
Normal file
170
lib/claper_web/live/admin_live/searchable_select_component.ex
Normal file
@@ -0,0 +1,170 @@
|
||||
defmodule ClaperWeb.AdminLive.SearchableSelectComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class={if @width_class, do: @width_class, else: "sm:col-span-6"}>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{@label}</span>
|
||||
</label>
|
||||
|
||||
<div class="dropdown dropdown-bottom w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={@search_term}
|
||||
placeholder={@placeholder}
|
||||
class="input input-bordered w-full"
|
||||
phx-keyup="search"
|
||||
phx-focus="open_dropdown"
|
||||
phx-target={@myself}
|
||||
phx-debounce="300"
|
||||
autocomplete="off"
|
||||
tabindex="0"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name={"#{@form.name}[#{@field}]"}
|
||||
value={@selected_value || ""}
|
||||
id={"#{@form.name}_#{@field}"}
|
||||
/>
|
||||
|
||||
<%= if @show_dropdown and length(@filtered_options) > 0 do %>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-full p-2 shadow"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<%= for {label, value} <- @filtered_options do %>
|
||||
<li class="w-full">
|
||||
<a
|
||||
class={if to_string(value) == to_string(@selected_value), do: "active", else: ""}
|
||||
phx-click="select_option"
|
||||
phx-value-value={value}
|
||||
phx-value-label={label}
|
||||
phx-target={@myself}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<label class="label">
|
||||
{error_tag(@form, @field)}
|
||||
<%= if @description do %>
|
||||
<span class="label-text-alt">{@description}</span>
|
||||
<% end %>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:show_dropdown, false)
|
||||
|> assign(:search_term, "")
|
||||
|> assign(:filtered_options, [])
|
||||
|> assign(:selected_value, nil)
|
||||
|> assign(:display_value, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:placeholder, fn -> gettext("Select...") end)
|
||||
|> assign_new(:required, fn -> false end)
|
||||
|> assign_new(:description, fn -> nil end)
|
||||
|> assign_new(:width_class, fn -> nil end)
|
||||
|> assign_new(:options, fn -> [] end)
|
||||
|> update_filtered_options()
|
||||
|> update_display_value()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"value" => search_term}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:search_term, search_term)
|
||||
|> assign(:show_dropdown, true)
|
||||
|> update_filtered_options()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("select_option", %{"value" => value, "label" => label}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:selected_value, value)
|
||||
|> assign(:display_value, label)
|
||||
|> assign(:show_dropdown, false)
|
||||
|> assign(:search_term, label)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("open_dropdown", _params, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:show_dropdown, true)
|
||||
|> update_filtered_options()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :show_dropdown, false)}
|
||||
end
|
||||
|
||||
defp update_filtered_options(socket) do
|
||||
search_term = String.downcase(socket.assigns[:search_term] || "")
|
||||
|
||||
filtered =
|
||||
if search_term == "" do
|
||||
socket.assigns.options
|
||||
else
|
||||
Enum.filter(socket.assigns.options, fn {label, _value} ->
|
||||
String.contains?(String.downcase(label), search_term)
|
||||
end)
|
||||
end
|
||||
|
||||
assign(socket, :filtered_options, filtered)
|
||||
end
|
||||
|
||||
defp update_display_value(socket) do
|
||||
current_value = get_field_value(socket.assigns.form, socket.assigns.field)
|
||||
display_value = find_display_value(current_value, socket.assigns.options)
|
||||
|
||||
socket
|
||||
|> assign(:selected_value, current_value)
|
||||
|> assign(:display_value, display_value)
|
||||
|> assign(:search_term, display_value)
|
||||
end
|
||||
|
||||
defp find_display_value(nil, _options), do: ""
|
||||
|
||||
defp find_display_value(current_value, options) do
|
||||
case Enum.find(options, fn {_label, value} ->
|
||||
to_string(value) == to_string(current_value)
|
||||
end) do
|
||||
{label, _value} -> label
|
||||
nil -> ""
|
||||
end
|
||||
end
|
||||
|
||||
defp get_field_value(form, field) do
|
||||
Map.get(form.data, field) || Map.get(form.params, to_string(field))
|
||||
end
|
||||
end
|
||||
242
lib/claper_web/live/admin_live/table_actions_component.ex
Normal file
242
lib/claper_web/live/admin_live/table_actions_component.ex
Normal file
@@ -0,0 +1,242 @@
|
||||
defmodule ClaperWeb.AdminLive.TableActionsComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= if @view_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="view_item"
|
||||
phx-target={@myself}
|
||||
class="text-indigo-600 hover:text-indigo-900 transition-colors duration-200"
|
||||
title="View"
|
||||
>
|
||||
<i class="fas fa-eye"></i>
|
||||
<span class="sr-only">View</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @edit_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="edit_item"
|
||||
phx-target={@myself}
|
||||
class="text-indigo-600 hover:text-indigo-900 transition-colors duration-200"
|
||||
title="Edit"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
<span class="sr-only">Edit</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @delete_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_item"
|
||||
phx-target={@myself}
|
||||
class="text-red-600 hover:text-red-900 transition-colors duration-200"
|
||||
title="Delete"
|
||||
data-confirm={
|
||||
@delete_confirm_message ||
|
||||
"Are you sure you want to delete this item? This action cannot be undone."
|
||||
}
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<span class="sr-only">Delete</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @duplicate_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="duplicate_item"
|
||||
phx-target={@myself}
|
||||
class="text-green-600 hover:text-green-900 transition-colors duration-200"
|
||||
title="Duplicate"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
<span class="sr-only">Duplicate</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @archive_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="archive_item"
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"transition-colors duration-200",
|
||||
if(@item_archived,
|
||||
do: "text-orange-600 hover:text-orange-900",
|
||||
else: "text-gray-600 hover:text-gray-900"
|
||||
)
|
||||
]}
|
||||
title={if @item_archived, do: "Unarchive", else: "Archive"}
|
||||
>
|
||||
<i class={if @item_archived, do: "fas fa-box-open", else: "fas fa-archive"}></i>
|
||||
<span class="sr-only">{if @item_archived, do: "Unarchive", else: "Archive"}</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @toggle_enabled do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_item"
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"transition-colors duration-200",
|
||||
if(@item_active,
|
||||
do: "text-green-600 hover:text-green-900",
|
||||
else: "text-gray-600 hover:text-gray-900"
|
||||
)
|
||||
]}
|
||||
title={
|
||||
if @item_active,
|
||||
do: @toggle_active_title || "Deactivate",
|
||||
else: @toggle_inactive_title || "Activate"
|
||||
}
|
||||
>
|
||||
<i class={if @item_active, do: "fas fa-toggle-on", else: "fas fa-toggle-off"}></i>
|
||||
<span class="sr-only">
|
||||
{if @item_active,
|
||||
do: @toggle_active_title || "Deactivate",
|
||||
else: @toggle_inactive_title || "Activate"}
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @dropdown_actions && length(@dropdown_actions) > 0 do %>
|
||||
<div class="relative" phx-click-away="close_dropdown" phx-target={@myself}>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
class="text-gray-600 hover:text-gray-900 transition-colors duration-200"
|
||||
title="More actions"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
<span class="sr-only">More actions</span>
|
||||
</button>
|
||||
|
||||
<%= if @dropdown_open do %>
|
||||
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 border border-gray-200">
|
||||
<div class="py-1">
|
||||
<%= for action <- @dropdown_actions do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="dropdown_action"
|
||||
phx-value-action={action.key}
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"block w-full text-left px-4 py-2 text-sm transition-colors duration-200",
|
||||
case action.type do
|
||||
"danger" -> "text-red-700 hover:bg-red-50"
|
||||
"warning" -> "text-orange-700 hover:bg-orange-50"
|
||||
_ -> "text-gray-700 hover:bg-gray-50"
|
||||
end
|
||||
]}
|
||||
data-confirm={action[:confirm]}
|
||||
>
|
||||
<%= if action[:icon] do %>
|
||||
<i class={"#{action.icon} mr-2"}></i>
|
||||
<% end %>
|
||||
{action.label}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @custom_actions do %>
|
||||
{render_slot(@custom_actions)}
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, dropdown_open: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:view_enabled, fn -> false end)
|
||||
|> assign_new(:edit_enabled, fn -> true end)
|
||||
|> assign_new(:delete_enabled, fn -> true end)
|
||||
|> assign_new(:duplicate_enabled, fn -> false end)
|
||||
|> assign_new(:archive_enabled, fn -> false end)
|
||||
|> assign_new(:toggle_enabled, fn -> false end)
|
||||
|> assign_new(:item_archived, fn -> false end)
|
||||
|> assign_new(:item_active, fn -> true end)
|
||||
|> assign_new(:delete_confirm_message, fn -> nil end)
|
||||
|> assign_new(:toggle_active_title, fn -> nil end)
|
||||
|> assign_new(:toggle_inactive_title, fn -> nil end)
|
||||
|> assign_new(:dropdown_actions, fn -> [] end)
|
||||
|> assign_new(:custom_actions, fn -> [] end)
|
||||
|> assign_new(:dropdown_open, fn -> false end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("view_item", _params, socket) do
|
||||
send(self(), {:table_action, :view, socket.assigns.item, socket.assigns.item_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("edit_item", _params, socket) do
|
||||
send(self(), {:table_action, :edit, socket.assigns.item, socket.assigns.item_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("delete_item", _params, socket) do
|
||||
send(self(), {:table_action, :delete, socket.assigns.item, socket.assigns.item_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("duplicate_item", _params, socket) do
|
||||
send(self(), {:table_action, :duplicate, socket.assigns.item, socket.assigns.item_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("archive_item", _params, socket) do
|
||||
action = if socket.assigns.item_archived, do: :unarchive, else: :archive
|
||||
send(self(), {:table_action, action, socket.assigns.item, socket.assigns.item_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle_item", _params, socket) do
|
||||
action = if socket.assigns.item_active, do: :deactivate, else: :activate
|
||||
send(self(), {:table_action, action, socket.assigns.item, socket.assigns.item_id})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, dropdown_open: !socket.assigns.dropdown_open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, dropdown_open: false)}
|
||||
end
|
||||
|
||||
def handle_event("dropdown_action", %{"action" => action_key}, socket) do
|
||||
action = Enum.find(socket.assigns.dropdown_actions, &(&1.key == action_key))
|
||||
|
||||
if action do
|
||||
send(
|
||||
self(),
|
||||
{:table_action, String.to_atom(action_key), socket.assigns.item, socket.assigns.item_id}
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, dropdown_open: false)}
|
||||
end
|
||||
end
|
||||
282
lib/claper_web/live/admin_live/table_component.ex
Normal file
282
lib/claper_web/live/admin_live/table_component.ex
Normal file
@@ -0,0 +1,282 @@
|
||||
defmodule ClaperWeb.AdminLive.TableComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<%= for {header, _index} <- Enum.with_index(@headers) do %>
|
||||
<th
|
||||
scope="col"
|
||||
class={[
|
||||
"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider",
|
||||
if(@sortable && header.sortable, do: "cursor-pointer hover:bg-gray-100", else: "")
|
||||
]}
|
||||
phx-click={if @sortable && header.sortable, do: "sort", else: nil}
|
||||
phx-value-field={if @sortable && header.sortable, do: header.field, else: nil}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div class="flex items-center">
|
||||
{if is_binary(header), do: header, else: header.label}
|
||||
<%= if @sortable && header.sortable do %>
|
||||
<%= case @sort_config do %>
|
||||
<% %{field: field, direction: :asc} when field == header.field -> %>
|
||||
<i class="fas fa-sort-up ml-2 text-indigo-500"></i>
|
||||
<% %{field: field, direction: :desc} when field == header.field -> %>
|
||||
<i class="fas fa-sort-down ml-2 text-indigo-500"></i>
|
||||
<% _ -> %>
|
||||
<i class="fas fa-sort ml-2 text-gray-400"></i>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<%= if length(@rows) > 0 do %>
|
||||
<%= for {row, row_index} <- Enum.with_index(@rows) do %>
|
||||
<tr
|
||||
class={[
|
||||
"hover:bg-gray-50",
|
||||
if(@row_click_enabled, do: "cursor-pointer", else: "")
|
||||
]}
|
||||
phx-click={if @row_click_enabled, do: "row_clicked", else: nil}
|
||||
phx-value-row-index={row_index}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<%= for {cell_content, _cell_index} <- Enum.with_index(get_row_cells(row, @headers, @row_func)) do %>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= case cell_content do %>
|
||||
<% {:safe, content} -> %>
|
||||
{raw(content)}
|
||||
<% content when is_binary(content) -> %>
|
||||
{content}
|
||||
<% content -> %>
|
||||
{to_string(content)}
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<tr>
|
||||
<td colspan={length(@headers)} class="px-6 py-4 text-center text-sm text-gray-500">
|
||||
<div class="flex flex-col items-center py-8">
|
||||
<%= if @empty_icon do %>
|
||||
<i class={"#{@empty_icon} text-gray-300 text-4xl mb-4"}></i>
|
||||
<% end %>
|
||||
<p class="text-lg font-medium text-gray-900 mb-2">
|
||||
{@empty_title || "No items found"}
|
||||
</p>
|
||||
<p class="text-gray-500">
|
||||
{@empty_message || "There are no items to display."}
|
||||
</p>
|
||||
<%= if @empty_action do %>
|
||||
<div class="mt-4">
|
||||
{render_slot(@empty_action)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%= if @pagination do %>
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<%= if @pagination.page_number > 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number - 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
|
||||
Previous
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= if @pagination.page_number < @pagination.total_pages do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number + 1}
|
||||
phx-target={@myself}
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-300 bg-gray-50 cursor-not-allowed">
|
||||
Next
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing
|
||||
<span class="font-medium">
|
||||
{(@pagination.page_number - 1) * @pagination.page_size + 1}
|
||||
</span>
|
||||
to
|
||||
<span class="font-medium">
|
||||
{min(@pagination.page_number * @pagination.page_size, @pagination.total_entries)}
|
||||
</span>
|
||||
of <span class="font-medium">{@pagination.total_entries}</span>
|
||||
results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<nav
|
||||
class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<%= if @pagination.page_number > 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number - 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
|
||||
<span class="sr-only">Previous</span>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%= for page <- get_page_range(@pagination) do %>
|
||||
<%= if page == @pagination.page_number do %>
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-indigo-500 bg-indigo-50 text-sm font-medium text-indigo-600">
|
||||
{page}
|
||||
</span>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={page}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if @pagination.page_number < @pagination.total_pages do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="paginate"
|
||||
phx-value-page={@pagination.page_number + 1}
|
||||
phx-target={@myself}
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<% else %>
|
||||
<span class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-gray-50 text-sm font-medium text-gray-300 cursor-not-allowed">
|
||||
<span class="sr-only">Next</span>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, sort_config: %{field: nil, direction: :asc})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:sortable, fn -> false end)
|
||||
|> assign_new(:sort_config, fn -> %{field: nil, direction: :asc} end)
|
||||
|> assign_new(:row_click_enabled, fn -> false end)
|
||||
|> assign_new(:empty_title, fn -> nil end)
|
||||
|> assign_new(:empty_message, fn -> nil end)
|
||||
|> assign_new(:empty_icon, fn -> nil end)
|
||||
|> assign_new(:empty_action, fn -> [] end)
|
||||
|> assign_new(:pagination, fn -> nil end)
|
||||
|> assign_new(:row_func, fn -> nil end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field}, socket) do
|
||||
current_sort = socket.assigns.sort_config
|
||||
|
||||
new_direction =
|
||||
if current_sort.field == field and current_sort.direction == :asc do
|
||||
:desc
|
||||
else
|
||||
:asc
|
||||
end
|
||||
|
||||
sort_config = %{field: field, direction: new_direction}
|
||||
|
||||
send(self(), {:table_sort_changed, sort_config})
|
||||
{:noreply, assign(socket, sort_config: sort_config)}
|
||||
end
|
||||
|
||||
def handle_event("paginate", %{"page" => page}, socket) do
|
||||
page_number = String.to_integer(page)
|
||||
send(self(), {:table_page_changed, page_number})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("row_clicked", %{"row-index" => row_index}, socket) do
|
||||
index = String.to_integer(row_index)
|
||||
row = Enum.at(socket.assigns.rows, index)
|
||||
send(self(), {:table_row_clicked, row, index})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp get_row_cells(row, headers, nil) do
|
||||
# Default behavior: assume row is a list/tuple matching header count
|
||||
case row do
|
||||
row when is_list(row) -> row
|
||||
row when is_tuple(row) -> Tuple.to_list(row)
|
||||
_ -> List.duplicate("", length(headers))
|
||||
end
|
||||
end
|
||||
|
||||
defp get_row_cells(row, _headers, row_func) when is_function(row_func) do
|
||||
row_func.(row)
|
||||
end
|
||||
|
||||
defp get_page_range(pagination) do
|
||||
start_page = max(1, pagination.page_number - 2)
|
||||
end_page = min(pagination.total_pages, pagination.page_number + 2)
|
||||
start_page..end_page
|
||||
end
|
||||
end
|
||||
192
lib/claper_web/live/admin_live/user_live.ex
Normal file
192
lib/claper_web/live/admin_live/user_live.ex
Normal file
@@ -0,0 +1,192 @@
|
||||
defmodule ClaperWeb.AdminLive.UserLive do
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.Admin
|
||||
alias Claper.Accounts
|
||||
alias Claper.Accounts.User
|
||||
alias ClaperWeb.Helpers.CSVExporter
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
with %{"locale" => locale} <- session do
|
||||
Gettext.put_locale(ClaperWeb.Gettext, locale)
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Users"))
|
||||
|> assign(:users, list_users())
|
||||
|> assign(:search, "")
|
||||
|> assign(:current_sort, %{field: :na, order: :asc})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Users")
|
||||
|> assign(:user, nil)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("New user"))
|
||||
|> assign(:user, %User{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("Edit user"))
|
||||
|> assign(:user, Accounts.get_user!(id) |> Claper.Repo.preload(:role))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, gettext("User details"))
|
||||
|> assign(:user, Accounts.get_user!(id) |> Claper.Repo.preload(:role))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
user = Accounts.get_user!(id)
|
||||
{:ok, _} = Accounts.delete_user(user)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("User deleted successfully"))
|
||||
|> assign(:users, list_users())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("sort", %{"field" => field}, socket) do
|
||||
field_atom = String.to_existing_atom(field)
|
||||
current_sort = socket.assigns.current_sort
|
||||
|
||||
new_direction =
|
||||
if current_sort.field == field_atom do
|
||||
if current_sort.order == :asc, do: :desc, else: :asc
|
||||
else
|
||||
:asc
|
||||
end
|
||||
|
||||
users = sort_users(socket.assigns.users, field_atom, new_direction)
|
||||
current_sort = %{field: field_atom, order: new_direction}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:users, users)
|
||||
|> assign(:current_sort, current_sort)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:search_filter_changed, %{search: search, filters: _filters}}, socket) do
|
||||
users = search_users(search)
|
||||
{:noreply, socket |> assign(:search, search) |> assign(:users, users)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:export_csv_requested, _params}, socket) do
|
||||
filename = CSVExporter.generate_filename("users")
|
||||
csv_content = CSVExporter.export_users_to_csv(socket.assigns.users)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("Users exported successfully"))
|
||||
|> push_event("download_csv", %{filename: filename, content: csv_content})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:table_sort_changed, sort_config}, socket) do
|
||||
%{field: field, direction: direction} = sort_config
|
||||
users = sort_users(socket.assigns.users, field, direction)
|
||||
current_sort = %{field: field, order: direction}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:users, users)
|
||||
|> assign(:current_sort, current_sort)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:table_action, action, user, _user_id}, socket) do
|
||||
case action do
|
||||
:view ->
|
||||
{:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}")}
|
||||
|
||||
:edit ->
|
||||
{:noreply, push_navigate(socket, to: ~p"/admin/users/#{user}/edit")}
|
||||
|
||||
:delete ->
|
||||
{:ok, _} = Accounts.delete_user(user)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("User deleted successfully"))
|
||||
|> assign(:users, list_users())}
|
||||
end
|
||||
end
|
||||
|
||||
def list_users do
|
||||
Admin.list_all_users()
|
||||
end
|
||||
|
||||
defp search_users(search) when search == "", do: list_users()
|
||||
|
||||
defp search_users(search) do
|
||||
Admin.list_all_users(%{"search" => search})
|
||||
end
|
||||
|
||||
defp sort_users(users, field, order) do
|
||||
Enum.sort_by(users, &Map.get(&1, field), order)
|
||||
end
|
||||
|
||||
def sort_indicator(assigns) do
|
||||
~H"""
|
||||
<%= if @current_sort.field == @field do %>
|
||||
<%= if @current_sort.order == :asc do %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-500 group-hover:text-gray-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l2.293-2.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<svg
|
||||
class="ml-2 h-5 w-5 text-gray-400 opacity-0 group-hover:opacity-100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
end
|
||||
348
lib/claper_web/live/admin_live/user_live.html.heex
Normal file
348
lib/claper_web/live/admin_live/user_live.html.heex
Normal file
@@ -0,0 +1,348 @@
|
||||
<%= case @live_action do %>
|
||||
<% :index -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("Users")}</h1>
|
||||
<div class="flex space-x-3">
|
||||
<%!-- <.link navigate={~p"/admin/users/new"} class="btn btn-primary btn-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
class="w-3 h-3"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{gettext("New user")}
|
||||
</.link> --%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="py-4">
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-6">
|
||||
<form phx-change="search" class="flex w-full md:w-1/2 my-3">
|
||||
<label class="input">
|
||||
<svg
|
||||
class="h-[1em] opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
value={@search}
|
||||
placeholder={gettext("Search users...")}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="email"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Email")} {sort_indicator(assigns |> Map.put(:field, :email))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="role_id"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Role")} {sort_indicator(assigns |> Map.put(:field, :role_id))}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="confirmed_at"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Status")} {sort_indicator(
|
||||
assigns
|
||||
|> Map.put(:field, :confirmed_at)
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-left">
|
||||
<button
|
||||
phx-click="sort"
|
||||
phx-value-field="inserted_at"
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
{gettext("Created")} {sort_indicator(
|
||||
assigns
|
||||
|> Map.put(:field, :inserted_at)
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th class="text-right">
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= if Enum.empty?(@users) do %>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">
|
||||
{gettext("No users found")}
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
<%= for user <- @users do %>
|
||||
<tr id={"user-#{user.id}"}>
|
||||
<td class="font-medium">
|
||||
{user.email}
|
||||
</td>
|
||||
<td>
|
||||
{if user.role, do: user.role.name, else: gettext("No role")}
|
||||
</td>
|
||||
<td>
|
||||
<%= if user.confirmed_at do %>
|
||||
<span class="badge badge-success">
|
||||
{gettext("Confirmed")}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge badge-warning">
|
||||
{gettext("Unconfirmed")}
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
{Calendar.strftime(user.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<.link
|
||||
navigate={~p"/admin/users/#{user}"}
|
||||
class="btn btn-link btn-sm"
|
||||
title={gettext("View user")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/admin/users/#{user}/edit"}
|
||||
class="btn btn-link btn-sm text-primary"
|
||||
title={gettext("Edit user")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</.link>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={user.id}
|
||||
data-confirm={
|
||||
gettext("Are you sure you want to delete this user?")
|
||||
}
|
||||
class="btn btn-link btn-sm text-error"
|
||||
title={gettext("Delete user")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :show -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-24">
|
||||
<h1 class="text-3xl font-bold">{gettext("User details")}</h1>
|
||||
<div class="flex space-x-3">
|
||||
<.link navigate={~p"/admin/users"} class="btn btn-outline btn-sm">
|
||||
{gettext("Back to users")}
|
||||
</.link>
|
||||
<.link navigate={~p"/admin/users/#{@user}/edit"} class="btn btn-primary btn-sm">
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{@user.email}</h2>
|
||||
<p class="text-base-content/70">{gettext("User Account")}</p>
|
||||
<div class="divider"></div>
|
||||
<dl class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Email")}</dt>
|
||||
<dd class="col-span-2">{@user.email}</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Role")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{if @user.role, do: @user.role.name, else: gettext("No role")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Status")}</dt>
|
||||
<dd class="col-span-2">
|
||||
<%= if @user.confirmed_at do %>
|
||||
<span class="badge badge-success">
|
||||
{gettext("Confirmed")}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge badge-warning">
|
||||
{gettext("Unconfirmed")}
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Created At")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@user.inserted_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Last Updated")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@user.updated_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<%= if @user.confirmed_at do %>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<dt class="font-medium">{gettext("Confirmed At")}</dt>
|
||||
<dd class="col-span-2">
|
||||
{Calendar.strftime(@user.confirmed_at, "%Y-%m-%d %H:%M")}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :new -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold">{gettext("New user")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.UserLive.FormComponent}
|
||||
id="user-form"
|
||||
title={gettext("New user")}
|
||||
action={:new}
|
||||
user={@user}
|
||||
navigate={~p"/admin/users"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% :edit -> %>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-3xl font-bold">{gettext("Edit user")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.UserLive.FormComponent}
|
||||
id={"user-form-#{@user.id}"}
|
||||
title={gettext("Edit user")}
|
||||
action={:edit}
|
||||
user={@user}
|
||||
navigate={~p"/admin/users"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
196
lib/claper_web/live/admin_live/user_live/form_component.ex
Normal file
196
lib/claper_web/live/admin_live/user_live/form_component.ex
Normal file
@@ -0,0 +1,196 @@
|
||||
defmodule ClaperWeb.AdminLive.UserLive.FormComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
alias Claper.Accounts
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.form for={@form} id="user-form" phx-target={@myself} phx-change="validate" phx-submit="save">
|
||||
<div class="grid grid-cols-6 gap-6">
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="user-email"
|
||||
form={@form}
|
||||
field={:email}
|
||||
type="email"
|
||||
label={gettext("Email")}
|
||||
placeholder={gettext("Enter user email")}
|
||||
required={true}
|
||||
width_class="sm:col-span-6"
|
||||
description={gettext("User's email address (must be unique)")}
|
||||
/>
|
||||
|
||||
<%= if @action == :new do %>
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="user-password"
|
||||
form={@form}
|
||||
field={:password}
|
||||
type="password"
|
||||
label={gettext("Password")}
|
||||
placeholder={gettext("Enter password")}
|
||||
required={true}
|
||||
width_class="sm:col-span-3"
|
||||
description={gettext("Initial password for the user")}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<.live_component
|
||||
module={ClaperWeb.AdminLive.FormFieldComponent}
|
||||
id="user-role-id"
|
||||
form={@form}
|
||||
field={:role_id}
|
||||
type="select"
|
||||
label={gettext("Role")}
|
||||
select_options={@role_options}
|
||||
required={true}
|
||||
width_class="sm:col-span-3"
|
||||
description={gettext("User's access level")}
|
||||
/>
|
||||
|
||||
<div class="sm:col-span-6">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="user[confirmed]"
|
||||
value="true"
|
||||
checked={@confirmed_checked}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<span class="label-text ml-2">{gettext("Account is confirmed and active")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" phx-click="cancel" phx-target={@myself} class="btn btn-ghost">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button type="submit" phx-disable-with={gettext("Saving...")} class="btn btn-primary">
|
||||
{if @action == :new, do: gettext("Create User"), else: gettext("Update User")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{user: user} = assigns, socket) do
|
||||
# For edit action, ensure we have the current role_id in the changeset
|
||||
# attrs =
|
||||
# case assigns.action do
|
||||
# :edit -> %{"role_id" => user.role_id}
|
||||
# :new -> %{}
|
||||
# end
|
||||
|
||||
changeset = Accounts.change_user(user)
|
||||
|
||||
role_options =
|
||||
Accounts.list_roles()
|
||||
|> Enum.map(&{String.capitalize(&1.name), &1.id})
|
||||
|
||||
# Determine if confirmed checkbox should be checked
|
||||
confirmed_checked =
|
||||
case assigns.action do
|
||||
:edit -> !is_nil(user.confirmed_at)
|
||||
:new -> false
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:role_options, role_options)
|
||||
|> assign(:confirmed_checked, confirmed_checked)
|
||||
|> assign_form(changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
# Update the confirmed_checked state based on form params
|
||||
confirmed_checked = Map.get(user_params, "confirmed") == "true"
|
||||
|
||||
# Convert confirmed checkbox to confirmed_at datetime
|
||||
user_params = maybe_convert_confirmed_field(user_params)
|
||||
|
||||
changeset =
|
||||
socket.assigns.user
|
||||
|> Accounts.change_user(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:confirmed_checked, confirmed_checked)
|
||||
|> assign_form(changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
# Convert confirmed checkbox to confirmed_at datetime
|
||||
user_params = maybe_convert_confirmed_field(user_params)
|
||||
save_user(socket, socket.assigns.action, user_params)
|
||||
end
|
||||
|
||||
def handle_event("cancel", _params, socket) do
|
||||
{:noreply, push_navigate(socket, to: socket.assigns.navigate)}
|
||||
end
|
||||
|
||||
defp save_user(socket, :edit, user_params) do
|
||||
case Accounts.update_user(socket.assigns.user, user_params) do
|
||||
{:ok, user} ->
|
||||
notify_parent({:saved, user})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("User updated successfully"))
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_user(socket, :new, user_params) do
|
||||
case Accounts.create_user(user_params) do
|
||||
{:ok, user} ->
|
||||
notify_parent({:saved, user})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("User created successfully"))
|
||||
|> push_navigate(to: socket.assigns.navigate)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: :user)
|
||||
assign(socket, :form, form)
|
||||
end
|
||||
|
||||
defp maybe_convert_confirmed_field(user_params) do
|
||||
case Map.get(user_params, "confirmed") do
|
||||
"true" ->
|
||||
user_params
|
||||
|> Map.delete("confirmed")
|
||||
|> Map.put("confirmed_at", NaiveDateTime.utc_now())
|
||||
|
||||
"false" ->
|
||||
user_params
|
||||
|> Map.delete("confirmed")
|
||||
|> Map.put("confirmed_at", nil)
|
||||
|
||||
_ ->
|
||||
user_params
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
end
|
||||
@@ -12,8 +12,6 @@
|
||||
form={f}
|
||||
key={:title}
|
||||
name={gettext("Title")}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
@@ -24,8 +22,6 @@
|
||||
key={:provider}
|
||||
name={gettext("Provider")}
|
||||
array={@providers}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white h-full"}
|
||||
required="true"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
@@ -38,24 +34,22 @@
|
||||
do: gettext("Iframe code"),
|
||||
else: gettext("Link to the content")
|
||||
}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-700 text-xl font-semibold mt-5"><%= gettext("Options") %></p>
|
||||
<p class="text-gray-700 text-xl font-semibold mt-5">{gettext("Options")}</p>
|
||||
|
||||
<div class="flex gap-x-2 mb-5 mt-3">
|
||||
<%= checkbox(f, :attendee_visibility, class: "h-4 w-5") %>
|
||||
<%= label(
|
||||
{checkbox(f, :attendee_visibility, class: "h-4 w-5")}
|
||||
{label(
|
||||
f,
|
||||
:attendee_visibility,
|
||||
gettext("Attendees can view the web content on their device"),
|
||||
class: "text-sm font-medium"
|
||||
) %>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,15 +57,15 @@
|
||||
<button
|
||||
type="submit"
|
||||
phx_disable_with="Loading..."
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= case @live_action do
|
||||
{case @live_action do
|
||||
:new -> gettext("Create")
|
||||
:edit -> gettext("Save")
|
||||
end %>
|
||||
end}
|
||||
</button>
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_target: @myself,
|
||||
@@ -80,8 +74,8 @@
|
||||
confirm: gettext("This will delete the web content, are you sure?")
|
||||
],
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
)}
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
@@ -29,7 +29,7 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
|
||||
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold"><%= gettext("See current web content") %></span>
|
||||
<span class="font-bold">{gettext("See current web content")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,8 +52,8 @@ defmodule ClaperWeb.EventLive.EmbedComponent do
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 my-1"><%= gettext("Current web content") %></p>
|
||||
<p class="text-white text-lg font-semibold mb-4"><%= @embed.title %></p>
|
||||
<p class="text-xs text-gray-500 my-1">{gettext("Current web content")}</p>
|
||||
<p class="text-white text-lg font-semibold mb-4">{@embed.title}</p>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<.live_component
|
||||
|
||||
@@ -39,7 +39,7 @@ defmodule ClaperWeb.EventLive.EmbedIframeComponent do
|
||||
>
|
||||
</iframe>
|
||||
<% "custom" -> %>
|
||||
<%= raw(@content) %>
|
||||
{raw(@content)}
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -20,7 +20,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
class="text-lg font-medium text-primary-600 truncate"
|
||||
href={~p"/e/#{@event.code}/manage"}
|
||||
>
|
||||
<%= @event.name %>
|
||||
{@event.name}
|
||||
</a>
|
||||
<p
|
||||
:if={@event.lti_resource}
|
||||
@@ -42,22 +42,22 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<span>LTI</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex">
|
||||
<div class="ml-2 shrink-0 flex">
|
||||
<%= if Event.started?(@event) && !Event.finished?(@event) do %>
|
||||
<div class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-500 text-white items-center gap-x-1">
|
||||
<span class="h-2 w-2 bg-white rounded-full animate__animated animate__flash animate__infinite animate__slow_slow">
|
||||
</span>
|
||||
<%= gettext("Live") %>
|
||||
{gettext("Live")}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if !Event.started?(@event) && !Event.finished?(@event) do %>
|
||||
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<%= gettext("Incoming") %>
|
||||
{gettext("Incoming")}
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if Event.finished?(@event) do %>
|
||||
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
<%= gettext("Finished") %>
|
||||
{gettext("Finished")}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<div class="text-sm font-medium uppercase text-gray-700 flex justify-center space-x-1 items-center">
|
||||
<img src="/images/icons/hashtag.svg" class="h-5 w-5" />
|
||||
<p>
|
||||
<%= @event.code %>
|
||||
{@event.code}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -83,11 +83,11 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<p :if={!Event.finished?(@event) && !Event.started?(@event)}>
|
||||
<%= gettext("Starting on") %>
|
||||
{gettext("Starting on")}
|
||||
<span x-text={"moment.utc('#{@event.started_at}').local().format('lll')"}></span>
|
||||
</p>
|
||||
<p :if={Event.finished?(@event)}>
|
||||
<%= gettext("Finished on") %>
|
||||
{gettext("Finished on")}
|
||||
<span x-text={"moment.utc('#{@event.expired_at}').local().format('lll')"}></span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -106,9 +106,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
phx-click-away={JS.hide(to: "#dropdown-#{@event.uuid}")}
|
||||
phx-click={JS.toggle(to: "#dropdown-#{@event.uuid}")}
|
||||
phx-target={@myself}
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-primary-600 bg-primary-500"
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-primary-600 bg-primary-500"
|
||||
>
|
||||
<span class="mr-2"><%= gettext("Join") %></span>
|
||||
<span class="mr-2">{gettext("Join")}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -127,14 +127,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<div
|
||||
phx-hook="Dropdown"
|
||||
id={"dropdown-#{@event.uuid}"}
|
||||
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max z-30"
|
||||
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max z-30"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
href={~p"/e/#{@event.code}/manage"}
|
||||
>
|
||||
<svg
|
||||
@@ -149,14 +149,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= gettext("Event manager") %></span>
|
||||
<span>{gettext("Event manager")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
href={~p"/e/#{@event.code}"}
|
||||
>
|
||||
<svg
|
||||
@@ -172,7 +172,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
/>
|
||||
<path d="M5.082 14.254a8.287 8.287 0 0 0-1.308 5.135 9.687 9.687 0 0 1-1.764-.44l-.115-.04a.563.563 0 0 1-.373-.487l-.01-.121a3.75 3.75 0 0 1 3.57-4.047ZM20.226 19.389a8.287 8.287 0 0 0-1.308-5.135 3.75 3.75 0 0 1 3.57 4.047l-.01.121a.563.563 0 0 1-.373.486l-.115.04c-.567.2-1.156.349-1.764.441Z" />
|
||||
</svg>
|
||||
<span><%= gettext("Attendees room") %></span>
|
||||
<span>{gettext("Attendees room")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -186,7 +186,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
}
|
||||
phx-value-id={@event.uuid}
|
||||
phx-click="terminate"
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-red-500 hover:bg-red-600 transition"
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-white items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-red-500 hover:bg-red-600 transition"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -198,7 +198,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span><%= gettext("End") %></span>
|
||||
<span>{gettext("End")}</span>
|
||||
</.link>
|
||||
</div>
|
||||
<div class="flex items-start gap-x-2 relative text-sm ">
|
||||
@@ -207,9 +207,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
|
||||
phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
|
||||
phx-target={@myself}
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-gray-300 bg-gray-200"
|
||||
>
|
||||
<span class="mr-2"><%= gettext("More options") %></span>
|
||||
<span class="mr-2">{gettext("More options")}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -228,12 +228,12 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<div
|
||||
phx-hook="Dropdown"
|
||||
id={"dropdown-action-#{@event.uuid}"}
|
||||
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
|
||||
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
href={~p"/events/#{@event.uuid}/edit"}
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
@@ -247,14 +247,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<path d="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z" />
|
||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z" />
|
||||
</svg>
|
||||
<span><%= gettext("Edit") %></span>
|
||||
<span>{gettext("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
phx-value-id={@event.uuid}
|
||||
phx-click="duplicate"
|
||||
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -265,7 +265,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
|
||||
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
|
||||
</svg>
|
||||
<span><%= gettext("Duplicate") %></span>
|
||||
<span>{gettext("Duplicate")}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -279,7 +279,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
class="mt-2 flex flex-col space-y-2 sm:space-y-0 justify-between sm:flex-row items-center"
|
||||
>
|
||||
<span class="text-sm text-supporting-red-500">
|
||||
<%= gettext("Error when processing the file") %>
|
||||
{gettext("Error when processing the file")}
|
||||
</span>
|
||||
<div class="relative text-sm">
|
||||
<%= if not @is_leader do %>
|
||||
@@ -287,9 +287,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
|
||||
phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
|
||||
phx-target={@myself}
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-gray-300 bg-gray-200"
|
||||
>
|
||||
<span class="mr-2"><%= gettext("More options") %></span>
|
||||
<span class="mr-2">{gettext("More options")}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -308,12 +308,12 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<div
|
||||
phx-hook="Dropdown"
|
||||
id={"dropdown-action-#{@event.uuid}"}
|
||||
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
|
||||
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
href={~p"/events/#{@event.uuid}/edit"}
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
@@ -327,7 +327,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<path d="m5.433 13.917 1.262-3.155A4 4 0 0 1 7.58 9.42l6.92-6.918a2.121 2.121 0 0 1 3 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 0 1-.65-.65Z" />
|
||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0 0 10 3H4.75A2.75 2.75 0 0 0 2 5.75v9.5A2.75 2.75 0 0 0 4.75 18h9.5A2.75 2.75 0 0 0 17 15.25V10a.75.75 0 0 0-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5Z" />
|
||||
</svg>
|
||||
<span><%= gettext("Edit") %></span>
|
||||
<span>{gettext("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -341,7 +341,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
class="flex space-x-1 items-center"
|
||||
>
|
||||
<img src="/images/loading.gif" class="h-8" />
|
||||
<span class="text-sm text-gray-500"><%= gettext("Processing your file...") %></span>
|
||||
<span class="text-sm text-gray-500">{gettext("Processing your file...")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -356,7 +356,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
>
|
||||
<a
|
||||
href={~p"/events/#{@event.uuid}/stats"}
|
||||
class="flex w-full lg:w-auto px-3 text-white py-2 justify-center rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-primary-600 bg-primary-500 space-x-2"
|
||||
class="flex w-full lg:w-auto px-3 text-white py-2 justify-center rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-primary-600 bg-primary-500 space-x-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -367,7 +367,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
|
||||
</svg>
|
||||
<span><%= gettext("View report") %></span>
|
||||
<span>{gettext("View report")}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="relative text-sm">
|
||||
@@ -376,9 +376,9 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
phx-click-away={JS.hide(to: "#dropdown-action-#{@event.uuid}")}
|
||||
phx-click={JS.toggle(to: "#dropdown-action-#{@event.uuid}")}
|
||||
phx-target={@myself}
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline hover:bg-gray-300 bg-gray-200"
|
||||
class="flex w-full lg:w-auto pl-3 pr-4 text-gray-700 items-center justify-between py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline hover:bg-gray-300 bg-gray-200"
|
||||
>
|
||||
<span class="mr-2"><%= gettext("More options") %></span>
|
||||
<span class="mr-2">{gettext("More options")}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -397,14 +397,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<div
|
||||
phx-hook="Dropdown"
|
||||
id={"dropdown-action-#{@event.uuid}"}
|
||||
class="hidden rounded shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
|
||||
class="hidden rounded-sm shadow-lg bg-white border px-1 py-1 absolute -left-1 top-9 w-max font-medium text-sm z-30"
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<button
|
||||
phx-value-id={@event.uuid}
|
||||
phx-click="duplicate"
|
||||
class="py-2 px-2 rounded text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
class="py-2 px-2 rounded-sm text-gray-600 hover:bg-gray-100 flex items-center gap-x-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -415,7 +415,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z" />
|
||||
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z" />
|
||||
</svg>
|
||||
<span><%= gettext("Duplicate") %></span>
|
||||
<span>{gettext("Duplicate")}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
@@ -427,7 +427,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
"This will delete all data related to your event, this cannot be undone. Confirm ?"
|
||||
)
|
||||
}
|
||||
class="py-2 px-2 rounded text-red-500 hover:bg-gray-100 flex items-center gap-x-2 flex items-center gap-x-2 cursor-pointer"
|
||||
class="py-2 px-2 rounded-sm text-red-500 hover:bg-gray-100 flex items-center gap-x-2 flex items-center gap-x-2 cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -441,7 +441,7 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= gettext("Delete") %></span>
|
||||
<span>{gettext("Delete")}</span>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -33,13 +33,13 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("How it works ?") %></span>
|
||||
<span>{gettext("How it works ?")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="border-b border-gray-200 py-4 flex flex-col sm:flex-row sm:items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
|
||||
<%= @page_title %>
|
||||
{@page_title}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex mt-4 space-x-5 sm:mt-0">
|
||||
@@ -48,30 +48,30 @@
|
||||
type="submit"
|
||||
form="event-form"
|
||||
phx_disable_with="Loading..."
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= case @action do
|
||||
{case @action do
|
||||
:edit -> gettext("Save")
|
||||
:new -> gettext("Create")
|
||||
end %>
|
||||
end}
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="opacity-25 cursor-default w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0">
|
||||
<%= case @action do
|
||||
<div class="opacity-25 cursor-default w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%]">
|
||||
{case @action do
|
||||
:edit -> gettext("Save")
|
||||
:new -> gettext("Create")
|
||||
end %>
|
||||
end}
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @action == :edit && !@event.expired_at do %>
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @event.uuid,
|
||||
data: [confirm: gettext("Are you sure?")],
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
)}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@
|
||||
data-tg-title={"📄 #{gettext("Presentation file (optional)")}"}
|
||||
>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<%= gettext("Select your presentation (optional)") %>
|
||||
{gettext("Select your presentation (optional)")}
|
||||
</label>
|
||||
<div class="max-w-lg flex flex-col justify-center items-center px-6 pt-5 pb-6 border-2 bg-white shadow-base border-gray-300 border-dashed rounded-md">
|
||||
<%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) < 100 do %>
|
||||
@@ -112,22 +112,22 @@
|
||||
phx-submit="save-file"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span><%= gettext("Upload a file") %></span>
|
||||
<span>{gettext("Upload a file")}</span>
|
||||
<.live_file_input upload={@uploads.presentation_file} class="sr-only" />
|
||||
</form>
|
||||
</label>
|
||||
<p class="pl-1"><%= gettext("or drag and drop") %></p>
|
||||
<p class="pl-1">{gettext("or drag and drop")}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
<%= gettext("PDF, PPT, PPTX up to %{size} MB", size: @max_file_size) %>
|
||||
{gettext("PDF, PPT, PPTX up to %{size} MB", size: @max_file_size)}
|
||||
</p>
|
||||
<%= for entry <- @uploads.presentation_file.entries do %>
|
||||
<progress id="file" max="100" value={entry.progress}>
|
||||
<%= entry.progress %>
|
||||
{entry.progress}
|
||||
</progress>
|
||||
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
|
||||
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
|
||||
<%= error_to_string(err) %>
|
||||
{error_to_string(err)}
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -149,9 +149,9 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-700"><%= gettext("Presentation uploaded") %></p>
|
||||
<p class="text-gray-700">{gettext("Presentation uploaded")}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400"><%= entry.client_name %></p>
|
||||
<p class="text-xs text-gray-400">{entry.client_name}</p>
|
||||
<p>
|
||||
<a
|
||||
href="#"
|
||||
@@ -160,7 +160,7 @@
|
||||
phx-target={@myself}
|
||||
class="text-red-500 text-sm"
|
||||
>
|
||||
<%= gettext("Remove") %>
|
||||
{gettext("Remove")}
|
||||
</a>
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -170,7 +170,7 @@
|
||||
<% else %>
|
||||
<div class="mt-12 mb-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<%= gettext("Select your presentation") %>
|
||||
{gettext("Select your presentation")}
|
||||
</label>
|
||||
<div class="max-w-lg flex flex-col justify-center items-center px-6 pt-5 pb-6 border-2 bg-white shadow-base border-gray-300 border-dashed rounded-md">
|
||||
<%= if @uploads.presentation_file.entries |> Enum.at(0, %{}) |> Map.get(:progress, 0) < 100 do %>
|
||||
@@ -189,7 +189,7 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-700"><%= gettext("Presentation attached") %></p>
|
||||
<p class="text-gray-700">{gettext("Presentation attached")}</p>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3 items-center">
|
||||
<label class="text-primary-500 text-sm">
|
||||
@@ -199,24 +199,24 @@
|
||||
phx-submit="save-file"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span><%= gettext("Change file") %></span>
|
||||
<span>{gettext("Change file")}</span>
|
||||
<.live_file_input upload={@uploads.presentation_file} class="sr-only" />
|
||||
</form>
|
||||
</label>
|
||||
<%= for entry <- @uploads.presentation_file.entries do %>
|
||||
<progress id="file" max="100" value={entry.progress}>
|
||||
<%= entry.progress %>
|
||||
{entry.progress}
|
||||
</progress>
|
||||
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
|
||||
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
|
||||
<%= error_to_string(err) %>
|
||||
{error_to_string(err)}
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<p class="text-supporting-red-500 text-sm italic text-center hidden">
|
||||
<%= gettext(
|
||||
{gettext(
|
||||
"Changing your file will remove all interaction elements like polls associated."
|
||||
) %>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
@@ -235,19 +235,19 @@
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-700"><%= gettext("Presentation replaced") %></p>
|
||||
<p class="text-gray-700">{gettext("Presentation replaced")}</p>
|
||||
</div>
|
||||
|
||||
<%= for entry <- @uploads.presentation_file.entries do %>
|
||||
<p class="text-xs text-gray-400"><%= entry.client_name %></p>
|
||||
<p class="text-xs text-gray-400">{entry.client_name}</p>
|
||||
<div :if={entry.progress < 100}>
|
||||
<progress id="file" max="100" value={entry.progress}>
|
||||
<%= entry.progress %>
|
||||
{entry.progress}
|
||||
</progress>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
|
||||
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
|
||||
<%= error_to_string(err) %>
|
||||
{error_to_string(err)}
|
||||
</p>
|
||||
<% end %>
|
||||
<p>
|
||||
@@ -258,7 +258,7 @@
|
||||
phx-target={@myself}
|
||||
class="text-red-500 text-sm"
|
||||
>
|
||||
<%= gettext("Remove") %>
|
||||
{gettext("Remove")}
|
||||
</a>
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -267,7 +267,7 @@
|
||||
<%= for entry <- @uploads.presentation_file.entries do %>
|
||||
<%= for err <- upload_errors(@uploads.presentation_file, entry) do %>
|
||||
<p class="text-red-500 text-sm px-4 py-2 border border-red-600 rounded-md my-3">
|
||||
<%= error_to_string(err) %>
|
||||
{error_to_string(err)}
|
||||
</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -339,7 +339,7 @@
|
||||
data-tg-order="4"
|
||||
>
|
||||
<span class="text-lg block font-medium text-gray-700">
|
||||
<%= gettext("Facilitators can present and manage interactions") %>
|
||||
{gettext("Facilitators can present and manage interactions")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -360,7 +360,7 @@
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= gettext("Add facilitator") %></span>
|
||||
<span>{gettext("Add facilitator")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -381,15 +381,15 @@
|
||||
key={:email}
|
||||
name=""
|
||||
/>
|
||||
<%= hidden_input(l, :user_email, value: @current_user.email) %>
|
||||
{hidden_input(l, :user_email, value: @current_user.email)}
|
||||
</div>
|
||||
|
||||
<label
|
||||
phx-click={JS.hide(to: "##{l.data.temp_id || l.id}")}
|
||||
class="cursor-pointer md:ml-3 rounded-md bg-supporting-red-500 hover:bg-supporting-red-600 transition flex items-center mt-2 md:w-max text-white py-7 px-3 text-sm max-h-0"
|
||||
>
|
||||
<span><%= gettext("Remove") %></span>
|
||||
<%= checkbox(l, :delete, class: "hidden") %>
|
||||
<span>{gettext("Remove")}</span>
|
||||
{checkbox(l, :delete, class: "hidden")}
|
||||
</label>
|
||||
<% else %>
|
||||
<div class="relative">
|
||||
@@ -401,9 +401,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= hidden_input(l, :temp_id) %>
|
||||
<%= hidden_input(l, :event_id, value: @event.id) %>
|
||||
<%= hidden_input(l, :user_email, value: @current_user.email) %>
|
||||
{hidden_input(l, :temp_id)}
|
||||
{hidden_input(l, :event_id, value: @event.id)}
|
||||
{hidden_input(l, :user_email, value: @current_user.email)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -412,7 +412,7 @@
|
||||
phx-target={@myself}
|
||||
class="md:ml-3 rounded-md bg-supporting-red-500 hover:bg-supporting-red-600 transition flex items-center mt-2 md:w-max text-white py-5 px-3 text-sm max-h-0"
|
||||
>
|
||||
<span><%= gettext("Remove") %></span>
|
||||
<span>{gettext("Remove")}</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
<path d="M17 12h.01"></path>
|
||||
<path d="M13 12h.01"></path>
|
||||
</svg>
|
||||
<span class="font-bold"><%= gettext("See current form") %></span>
|
||||
<span class="font-bold">{gettext("See current form")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,8 +48,8 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 my-1"><%= gettext("Current form") %></p>
|
||||
<p class="text-white text-lg font-semibold mb-4"><%= @form.title %></p>
|
||||
<p class="text-xs text-gray-500 my-1">{gettext("Current form")}</p>
|
||||
<p class="text-white text-lg font-semibold mb-4">{@form.title}</p>
|
||||
</div>
|
||||
<%= form_for :form_submit, "#", [id: @id, phx_change: "validate", phx_target: @myself, phx_submit: "submit"], fn f -> %>
|
||||
<div class="flex flex-col space-y-3">
|
||||
@@ -63,7 +63,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
fieldClass="bg-gray-700 text-white"
|
||||
key={String.to_atom(field.name)}
|
||||
name={field.name}
|
||||
required="true"
|
||||
required={field.required}
|
||||
value={
|
||||
if is_nil(assigns.current_form_submit),
|
||||
do: ~c"",
|
||||
@@ -77,7 +77,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
fieldClass="bg-gray-700 text-white"
|
||||
key={String.to_atom(field.name)}
|
||||
name={field.name}
|
||||
required="true"
|
||||
required={field.required}
|
||||
value={
|
||||
if is_nil(assigns.current_form_submit),
|
||||
do: ~c"",
|
||||
@@ -94,7 +94,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
type="submit"
|
||||
class="px-3 py-2 text-white font-semibold bg-primary-500 hover:bg-primary-600 rounded-md my-5"
|
||||
>
|
||||
<%= if is_nil(assigns.current_form_submit), do: gettext("Submit"), else: gettext("Edit") %>
|
||||
{if is_nil(assigns.current_form_submit), do: gettext("Submit"), else: gettext("Edit")}
|
||||
</button>
|
||||
|
||||
<%= unless is_nil(assigns.current_form_submit) do %>
|
||||
@@ -111,7 +111,7 @@ defmodule ClaperWeb.EventLive.FormComponent do
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M9 12l2 2l4 -4" />
|
||||
</svg>
|
||||
<span><%= gettext("Saved") %></span>
|
||||
<span>{gettext("Saved")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule ClaperWeb.EventLive.Index do
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.Events
|
||||
alias Claper.{Events, Presentations}
|
||||
alias Claper.Events.Event
|
||||
|
||||
on_mount(ClaperWeb.UserLiveAuth)
|
||||
@@ -22,7 +22,7 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
})
|
||||
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Claper.PubSub, "events:#{socket.assigns.current_user.id}")
|
||||
Events.subscribe_user_events(socket.assigns.current_user.id)
|
||||
end
|
||||
|
||||
expired_events_count = Events.count_expired_events(socket.assigns.current_user.id)
|
||||
@@ -50,11 +50,22 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:presentation_file_process_done, presentation}, socket) do
|
||||
event = Claper.Events.get_event!(presentation.event.uuid, [:presentation_file])
|
||||
def handle_info({type, %Events.Event{}}, socket)
|
||||
when type in [:created, :updated, :deleted] do
|
||||
{:noreply, refresh_events(socket)}
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket |> assign(:events, [event | socket.assigns.events]) |> put_flash(:info, nil)}
|
||||
@impl true
|
||||
def handle_info({type, %Presentations.PresentationFile{}}, socket)
|
||||
when type in [:presentation_file_process_done] do
|
||||
{:noreply, refresh_events(socket)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(message, socket) do
|
||||
IO.puts("Received unknown message `#{inspect(message)}` in #{__MODULE__} #{inspect(self())}")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -245,4 +256,16 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
if(socket.assigns.page == 1, do: events, else: socket.assigns.events ++ events)
|
||||
)
|
||||
end
|
||||
|
||||
defp refresh_events(socket) do
|
||||
expired_events_count = Events.count_expired_events(socket.assigns.current_user.id)
|
||||
invited_events_count = Events.count_managed_events_by(socket.assigns.current_user.email)
|
||||
|
||||
socket
|
||||
|> assign(:has_expired_events, expired_events_count > 0)
|
||||
|> assign(:has_invited_events, invited_events_count > 0)
|
||||
|> assign(:events, [])
|
||||
|> assign(:page, 1)
|
||||
|> load_events()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("How it works ?") %></span>
|
||||
<span>{gettext("How it works ?")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<%= if @live_action in [:new, :edit] do %>
|
||||
@@ -48,11 +48,11 @@
|
||||
>
|
||||
<div
|
||||
phx-click="toggle-quick-create"
|
||||
class="fixed inset-0 bg-gray-800 bg-opacity-75 transition-opacity w-full h-full"
|
||||
class="fixed inset-0 bg-gray-800/75 transition-opacity w-full h-full -z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all relative">
|
||||
<button phx-click="toggle-quick-create" class="absolute right-0 top-0">
|
||||
<svg
|
||||
class="text-gray-500 h-9 transform rotate-45"
|
||||
@@ -80,17 +80,18 @@
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={f}
|
||||
key={:name}
|
||||
name=""
|
||||
name={gettext("Name of your event")}
|
||||
readonly={false}
|
||||
minlength="5"
|
||||
maxlength="50"
|
||||
class="h-12"
|
||||
placeholder={gettext("Name of your event")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
phx_disable_with="Loading..."
|
||||
class="mt-5 w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="mt-5 w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0px_0px] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Create") %>
|
||||
{gettext("Create")}
|
||||
</button>
|
||||
</.form>
|
||||
</div>
|
||||
@@ -108,7 +109,7 @@
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
|
||||
<%= gettext("My events") %>
|
||||
{gettext("My events")}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex flex-col w-full items-center sm:w-auto sm:flex-row mt-0">
|
||||
@@ -138,7 +139,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<%= gettext("Quick event") %>
|
||||
{gettext("Quick event")}
|
||||
</span>
|
||||
</.link>
|
||||
<.link
|
||||
@@ -147,7 +148,7 @@
|
||||
data-tg-tour={gettext("Welcome to Claper! You can create a new event here.")}
|
||||
data-tg-title={"#{gettext("Your first steps with Claper")} 👋"}
|
||||
href={~p"/events/new"}
|
||||
class="relative w-full sm:w-auto inline-flex justify-center items-center px-5 py-2 text-lg font-medium rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="relative w-full sm:w-auto inline-flex justify-center items-center px-5 py-2 text-lg font-medium rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<svg
|
||||
class="-ml-1 mr-1 h-5 w-5"
|
||||
@@ -163,7 +164,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<%= gettext("Create event") %>
|
||||
{gettext("Create event")}
|
||||
</span>
|
||||
</.link>
|
||||
</div>
|
||||
@@ -175,7 +176,7 @@
|
||||
phx-value-tab="not_expired"
|
||||
class={"#{if @active_tab == "not_expired", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md"}
|
||||
>
|
||||
<%= gettext("Active") %>
|
||||
{gettext("Active")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="change-tab"
|
||||
@@ -183,7 +184,7 @@
|
||||
disabled={not @has_expired_events}
|
||||
class={"#{if @active_tab == "expired", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md #{if not @has_expired_events, do: "opacity-50"}"}
|
||||
>
|
||||
<%= gettext("Finished") %>
|
||||
{gettext("Finished")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="change-tab"
|
||||
@@ -191,7 +192,7 @@
|
||||
disabled={not @has_invited_events}
|
||||
class={"#{if @active_tab == "invited", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md #{if not @has_invited_events, do: "opacity-50"}"}
|
||||
>
|
||||
<%= gettext("Shared with you") %>
|
||||
{gettext("Shared with you")}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -212,16 +213,16 @@
|
||||
<div class="flex justify-center my-4">
|
||||
<button
|
||||
phx-click="load-more"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<%= gettext("Load more") %>
|
||||
{gettext("Load more")}
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.count(@events) == 0 do %>
|
||||
<div class="w-full text-2xl text-black opacity-25 text-center">
|
||||
<img src="/images/icons/arrow.svg" class="h-20 float-right mr-16 -mt-5" />
|
||||
<p class="pt-12 clear-both"><%= gettext("Create your first event") %></p>
|
||||
<p class="pt-12 clear-both">{gettext("Create your first event")}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -21,21 +21,21 @@
|
||||
@click.away="open = false"
|
||||
>
|
||||
<a href="https://get.claper.co/" class="text-sm font-semibold text-black">
|
||||
<%= gettext("About") %>
|
||||
{gettext("About")}
|
||||
</a>
|
||||
<%= if @current_user do %>
|
||||
<.link
|
||||
href={~p"/events"}
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Dashboard") %>
|
||||
{gettext("Dashboard")}
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
href={~p"/users/log_in"}
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Login") %>
|
||||
{gettext("Login")}
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -44,21 +44,21 @@
|
||||
</button>
|
||||
<div class="hidden md:block">
|
||||
<a href="https://get.claper.co/" class="text-sm text-white font-semibold mr-3">
|
||||
<%= gettext("About") %>
|
||||
{gettext("About")}
|
||||
</a>
|
||||
<%= if @current_user do %>
|
||||
<.link
|
||||
href={~p"/events"}
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Dashboard") %>
|
||||
{gettext("Dashboard")}
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
href={~p"/users/log_in"}
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="relative inline-flex items-center px-4 py-1 text-base font-sm rounded-md text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Login") %>
|
||||
{gettext("Login")}
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -71,13 +71,13 @@
|
||||
|
||||
<%= form_for :event, ~p"/join", ["phx-submit": "join", "phx-hook": "JoinEvent", id: "form"], fn f -> %>
|
||||
<div class="relative">
|
||||
<%= text_input(f, :code,
|
||||
{text_input(f, :code,
|
||||
required: true,
|
||||
autofocus: true,
|
||||
id: "input",
|
||||
class:
|
||||
"transition-all bg-transparent tracking-widest w-full uppercase text-white text-2xl px-3 border-b border-gray-200 focus:border-b-2 pt-5 pl-12 pb-3 outline-none"
|
||||
) %>
|
||||
"transition-all bg-transparent tracking-widest w-full uppercase text-white text-2xl px-3 border-b border-gray-200 focus:border-b-2 pt-5 pl-12 pb-3 outline-hidden"
|
||||
)}
|
||||
<img
|
||||
class="icon absolute top-5 left-2 transition-all duration-100"
|
||||
src="/images/icons/hashtag-white.svg"
|
||||
@@ -89,19 +89,19 @@
|
||||
<button
|
||||
type="submit"
|
||||
id="submit"
|
||||
class="w-full flex justify-center text-white p-4 rounded-full tracking-wide font-bold outline-none focus:shadow-outline shadow-lg bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="w-full flex justify-center text-white p-4 rounded-full tracking-wide font-bold outline-hidden focus:shadow-outline shadow-lg bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Join") %>
|
||||
{gettext("Join")}
|
||||
</button>
|
||||
<img src="/images/loading.gif" id="loading" class="hidden h-12 mx-auto" />
|
||||
</div>
|
||||
|
||||
<%= if @last_event do %>
|
||||
<.link href={~p"/e/#{@last_event.code}"}>
|
||||
<div class="rounded-md bg-gray-600 bg-opacity-50 p-4 mt-8">
|
||||
<div class="rounded-md bg-gray-600/50 p-4 mt-8">
|
||||
<div class="flex justify-center items-center">
|
||||
<p class="text-sm text-white">
|
||||
<%= gettext("Return to your last event") %> (<%= @last_event.name %>)
|
||||
{gettext("Return to your last event")} ({@last_event.name})
|
||||
</p>
|
||||
<p class="text-base ml-3 mt-1">
|
||||
<a href="#" class="whitespace-nowrap font-medium text-white">
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
defmodule ClaperWeb.EventLive.Manage do
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.{Embeds, Forms, Polls, Presentations, Quizzes}
|
||||
alias ClaperWeb.Presence
|
||||
alias Claper.Polls
|
||||
alias Claper.Forms
|
||||
alias Claper.Embeds
|
||||
# Add this line
|
||||
alias Claper.Quizzes
|
||||
|
||||
@impl true
|
||||
def mount(%{"code" => code}, session, socket) do
|
||||
@@ -39,6 +35,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:interaction_modal, false)
|
||||
|> assign(:settings_modal, false)
|
||||
|> assign(:attendees_nb, 1)
|
||||
|> assign(:event, event)
|
||||
@@ -74,7 +71,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
end
|
||||
|
||||
defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
|
||||
Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
|
||||
Claper.Events.led_by?(current_user.email, event) || event.user.id == current_user.id
|
||||
end
|
||||
|
||||
defp leader?(_socket, _event), do: false
|
||||
@@ -585,6 +582,23 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
{:noreply, socket |> assign(:state, new_state)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"checked",
|
||||
%{"key" => "show_attendee_count", "value" => value},
|
||||
%{assigns: %{event: _event, state: state}} = socket
|
||||
) do
|
||||
{:ok, new_state} =
|
||||
Claper.Presentations.update_presentation_state(
|
||||
state,
|
||||
%{
|
||||
:show_attendee_count => value
|
||||
}
|
||||
)
|
||||
|
||||
{:noreply, socket |> assign(:state, new_state)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"checked",
|
||||
@@ -762,18 +776,26 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
def handle_event(
|
||||
"toggle-interaction-modal",
|
||||
_params,
|
||||
%{assigns: %{interaction_modal: _interaction_modal = true}} = socket
|
||||
) do
|
||||
{:noreply, socket |> push_navigate(to: ~p"/e/#{socket.assigns.event.code}/manage")}
|
||||
end
|
||||
|
||||
def toggle_add_modal(js \\ %JS{}) do
|
||||
js
|
||||
|> JS.toggle(
|
||||
to: "#add-modal",
|
||||
out: "animate__animated animate__fadeOut",
|
||||
in: "animate__animated animate__fadeIn"
|
||||
)
|
||||
|> JS.push("maybe-redirect", target: "#add-modal")
|
||||
@impl true
|
||||
def handle_event(
|
||||
"toggle-interaction-modal",
|
||||
_params,
|
||||
%{assigns: %{interaction_modal: _interaction_modal}} = socket
|
||||
) do
|
||||
{:noreply, socket |> assign(:interaction_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
def toggle_settings_modal(js \\ %JS{}) do
|
||||
@@ -802,6 +824,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|
||||
socket
|
||||
|> assign(:create, "poll")
|
||||
|> assign(:interaction_modal, true)
|
||||
|> assign(:create_action, :edit)
|
||||
|> assign(:poll, poll)
|
||||
end
|
||||
@@ -834,6 +857,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|
||||
socket
|
||||
|> assign(:create, "form")
|
||||
|> assign(:interaction_modal, true)
|
||||
|> assign(:create_action, :edit)
|
||||
|> assign(:form, form)
|
||||
end
|
||||
@@ -843,6 +867,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|
||||
socket
|
||||
|> assign(:create, "embed")
|
||||
|> assign(:interaction_modal, true)
|
||||
|> assign(:create_action, :edit)
|
||||
|> assign(:embed, embed)
|
||||
end
|
||||
@@ -873,6 +898,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|
||||
socket
|
||||
|> assign(:create, "quiz")
|
||||
|> assign(:interaction_modal, true)
|
||||
|> assign(:create_action, :edit)
|
||||
|> assign(:quiz, quiz)
|
||||
end
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("How it works ?") %></span>
|
||||
<span>{gettext("How it works ?")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -67,11 +67,11 @@
|
||||
>
|
||||
<div
|
||||
phx-click={toggle_settings_modal()}
|
||||
class="fixed inset-0 bg-gray-800 bg-opacity-75 transition-opacity w-full h-full"
|
||||
class="fixed inset-0 bg-gray-800/75 transition-opacity w-full h-full -z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all relative">
|
||||
<button phx-click={toggle_settings_modal()} class="absolute right-0 top-0">
|
||||
<svg
|
||||
class="text-gray-500 h-9 transform rotate-45"
|
||||
@@ -119,8 +119,8 @@
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" /><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<span :if={!@preview}><%= gettext("Open preview") %></span>
|
||||
<span :if={@preview}><%= gettext("Close preview") %></span>
|
||||
<span :if={!@preview}>{gettext("Open preview")}</span>
|
||||
<span :if={@preview}>{gettext("Close preview")}</span>
|
||||
</button>
|
||||
<div id="settings-modal-content" class="bg-white p-4">
|
||||
<.live_component
|
||||
@@ -134,22 +134,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="add-modal"
|
||||
class={"#{if !@create, do: "hidden" } fixed z-30 inset-0 overflow-y-auto p-4 sm:p-6 md:p-24
|
||||
transform transition-all duration-150"}
|
||||
:if={@interaction_modal}
|
||||
id="interaction-modal"
|
||||
class="fixed z-30 inset-0 overflow-y-auto p-4 sm:p-6 md:p-24 transform transition-all duration-150"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
phx-click={toggle_add_modal()}
|
||||
class="fixed inset-0 bg-gray-800 bg-opacity-75 transition-opacity w-full h-full"
|
||||
phx-click="toggle-interaction-modal"
|
||||
class="fixed inset-0 bg-gray-800/75 transition-opacity w-full h-full -z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
||||
<button phx-click={toggle_add_modal()} class="absolute right-0 top-0">
|
||||
<div class="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black/5 transition-all relative">
|
||||
<button phx-click="toggle-interaction-modal" class="absolute right-0 top-0">
|
||||
<svg
|
||||
class="text-gray-500 h-9 transform rotate-45"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -174,7 +173,7 @@
|
||||
href={~p"/e/#{@event.code}/manage/add/poll"}
|
||||
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
|
||||
>
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-7 w-7"
|
||||
@@ -192,10 +191,10 @@
|
||||
</div>
|
||||
<div class="ml-4 flex-auto text-left">
|
||||
<p class="font-medium text-gray-700">
|
||||
<%= gettext("Poll") %>
|
||||
{gettext("Poll")}
|
||||
</p>
|
||||
<p class="text-gray-500">
|
||||
<%= gettext("Add poll to know opinion of your public.") %>
|
||||
{gettext("Add poll to know opinion of your public.")}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -207,7 +206,7 @@
|
||||
href={~p"/e/#{@event.code}/manage/add/form"}
|
||||
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
|
||||
>
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-7 w-7"
|
||||
@@ -225,10 +224,10 @@
|
||||
</div>
|
||||
<div class="ml-4 flex-auto text-left">
|
||||
<p class="font-medium text-gray-700">
|
||||
<%= gettext("Form") %>
|
||||
{gettext("Form")}
|
||||
</p>
|
||||
<p class="text-gray-500">
|
||||
<%= gettext("Add form to collect data from your public.") %>
|
||||
{gettext("Add form to collect data from your public.")}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -240,7 +239,7 @@
|
||||
href={~p"/e/#{@event.code}/manage/add/embed"}
|
||||
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
|
||||
>
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -257,9 +256,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4 flex-auto text-left">
|
||||
<p class="font-medium text-gray-700"><%= gettext("Web content") %></p>
|
||||
<p class="font-medium text-gray-700">{gettext("Web content")}</p>
|
||||
<p class="text-gray-500">
|
||||
<%= gettext("Add a Youtube video or any web content.") %>
|
||||
{gettext("Add a Youtube video or any web content.")}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -271,7 +270,7 @@
|
||||
href={~p"/e/#{@event.code}/manage/add/quiz"}
|
||||
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
|
||||
>
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -289,9 +288,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4 flex-auto text-left">
|
||||
<p class="font-medium text-gray-700"><%= gettext("Quiz") %></p>
|
||||
<p class="font-medium text-gray-700">{gettext("Quiz")}</p>
|
||||
<p class="text-gray-500">
|
||||
<%= gettext("Add a quiz to test knowledge.") %>
|
||||
{gettext("Add a quiz to test knowledge.")}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -301,10 +300,10 @@
|
||||
<%= if @create=="poll" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= case @create_action do
|
||||
{case @create_action do
|
||||
:new -> gettext("New poll")
|
||||
:edit -> gettext("Edit poll")
|
||||
end %>
|
||||
end}
|
||||
</p>
|
||||
<.live_component
|
||||
module={ClaperWeb.PollLive.FormComponent}
|
||||
@@ -322,10 +321,10 @@
|
||||
<%= if @create=="form" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= case @create_action do
|
||||
{case @create_action do
|
||||
:new -> gettext("New form")
|
||||
:edit -> gettext("Edit form")
|
||||
end %>
|
||||
end}
|
||||
</p>
|
||||
<.live_component
|
||||
module={ClaperWeb.FormLive.FormComponent}
|
||||
@@ -343,10 +342,10 @@
|
||||
<%= if @create == "embed" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= case @create_action do
|
||||
{case @create_action do
|
||||
:new -> gettext("New web content")
|
||||
:edit -> gettext("Edit web content")
|
||||
end %>
|
||||
end}
|
||||
</p>
|
||||
<.live_component
|
||||
module={ClaperWeb.EmbedLive.FormComponent}
|
||||
@@ -364,10 +363,10 @@
|
||||
<%= if @create=="quiz" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= case @create_action do
|
||||
{case @create_action do
|
||||
:new -> gettext("New quiz")
|
||||
:edit -> gettext("Edit quiz")
|
||||
end %>
|
||||
end}
|
||||
</p>
|
||||
<.live_component
|
||||
module={ClaperWeb.QuizLive.QuizComponent}
|
||||
@@ -385,7 +384,7 @@
|
||||
<%= if @create == "import" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= gettext("Select presentation") %>
|
||||
{gettext("Select presentation")}
|
||||
</p>
|
||||
<ul>
|
||||
<%= for event <- @events do %>
|
||||
@@ -410,7 +409,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<%= event.name %>
|
||||
{event.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -453,13 +452,13 @@
|
||||
</a>
|
||||
<div class="flex items-center justify-start gap-x-2">
|
||||
<h2 class="text-xl font-bold leading-7 text-gray-900 md:text-2xl truncate w-24 md:w-auto">
|
||||
<%= @event.name %>
|
||||
{@event.name}
|
||||
</h2>
|
||||
<div class="flex gap-x-3 items-center">
|
||||
<div class="flex items-center text-sm text-gray-500 gap-x-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="flex-shrink-0 h-5 w-5 text-gray-400"
|
||||
class="shrink-0 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -471,12 +470,12 @@
|
||||
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
||||
/>
|
||||
</svg>
|
||||
<span class="uppercase"><%= @event.code %></span>
|
||||
<span class="uppercase">{@event.code}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-500 gap-x-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="flex-shrink-0 h-5 w-5 text-gray-400"
|
||||
class="shrink-0 h-5 w-5 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -487,14 +486,14 @@
|
||||
/>
|
||||
</svg>
|
||||
<span id="attendees-count" phx-update="ignore" phx-hook="UpdateAttendees">
|
||||
<%= @attendees_nb %>
|
||||
{@attendees_nb}
|
||||
</span>
|
||||
<span>
|
||||
<%= link(gettext("Join"),
|
||||
{link(gettext("Join"),
|
||||
to: ~p"/e/#{@event.code}",
|
||||
class: "text-xs text-primary-600 font-semibold text-sm ",
|
||||
target: "_blank"
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,8 +534,8 @@
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" /><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<span :if={!@preview}><%= gettext("Open preview") %></span>
|
||||
<span :if={@preview}><%= gettext("Close preview") %></span>
|
||||
<span :if={!@preview}>{gettext("Open preview")}</span>
|
||||
<span :if={@preview}>{gettext("Close preview")}</span>
|
||||
</button>
|
||||
<button
|
||||
phx-hook="OpenPresenter"
|
||||
@@ -547,7 +546,7 @@
|
||||
data-tg-order="4"
|
||||
data-tg-tour={"<p>#{gettext("Click here to open the presentation window. Press <strong>F</strong> in the presentation window to enable fullscreen.")}</p>"}
|
||||
data-tg-group="manage"
|
||||
class="hidden lg:inline-flex items-center py-1 px-5 rounded-r-sm text-base font-medium text-white bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="hidden lg:inline-flex items-center py-1 px-5 rounded-r-sm text-base font-medium text-white bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -563,7 +562,7 @@
|
||||
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
<%= gettext("Open presentation") %>
|
||||
{gettext("Open presentation")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-x-2 lg:hidden">
|
||||
@@ -670,34 +669,26 @@
|
||||
id="slides-layout"
|
||||
class="flex overflow-x-auto w-full md:h-full"
|
||||
>
|
||||
<%= for index <- 0..max(0, @event.presentation_file.length-1) do %>
|
||||
<button
|
||||
id={"slide-preview-#{index}"}
|
||||
phx-click="current-page"
|
||||
phx-value-page={index}
|
||||
class="h-full w-full contents"
|
||||
>
|
||||
<%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %>
|
||||
<img
|
||||
class={"#{if @state.position==index, do: "border-4 border-primary-500" , else: "opacity-20" }
|
||||
<button
|
||||
:for={
|
||||
{src, index} <-
|
||||
Presentations.get_slide_urls(@event.presentation_file) |> Enum.with_index(0)
|
||||
}
|
||||
id={"slide-preview-#{index}"}
|
||||
phx-click="current-page"
|
||||
phx-value-page={index}
|
||||
class="h-full w-full contents"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
class={"#{if @state.position==index, do: "border-4 border-primary-500" , else: "opacity-20" }
|
||||
transition-all object-contain"}
|
||||
src={"/uploads/#{@event.presentation_file.hash}/#{index+1}.jpg"}
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
class={"#{if @state.position==index, do: "border-4 border-primary-500" , else: "opacity-20" }
|
||||
transition-all object-contain"}
|
||||
src={"https://#{Application.get_env(:claper, :presentations) |>
|
||||
Keyword.get(:aws_bucket)}.s3.#{Application.get_env(:ex_aws,
|
||||
:region)}.amazonaws.com/presentations/#{@event.presentation_file.hash}/#{index+1}.jpg"}
|
||||
/>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
:if={@event.presentation_file.length > 0}
|
||||
class="hidden md:block gutter col-span-full cursor-row-resize z-20 row-[2] bg-gray-50 text-center text-gray-300 text-sm leading-3"
|
||||
class="hidden md:block gutter col-span-full cursor-row-resize z-20 row-2 bg-gray-50 text-center text-gray-300 text-sm leading-3"
|
||||
>
|
||||
•••
|
||||
</div>
|
||||
@@ -737,16 +728,16 @@
|
||||
|
||||
<p class="text-lg">
|
||||
<span :if={@event.presentation_file.length > 0}>
|
||||
<%= gettext("This slide does not have any interactions.") %>
|
||||
{gettext("This slide does not have any interactions.")}
|
||||
</span>
|
||||
<span :if={@event.presentation_file.length == 0}>
|
||||
<%= gettext("Create your first interaction.") %>
|
||||
{gettext("Create your first interaction.")}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-center gap-x-2 px-3 py-2 text-white bg-primary-500 hover:bg-primary-600 rounded-md"
|
||||
phx-click={toggle_add_modal()}
|
||||
phx-click="toggle-interaction-modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -756,10 +747,13 @@
|
||||
>
|
||||
<path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
|
||||
</svg>
|
||||
<span><%= gettext("Add interaction") %></span>
|
||||
<span>{gettext("Add interaction")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4 p-4 overflow-y-auto">
|
||||
<div
|
||||
class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4 p-4 overflow-y-auto"
|
||||
style="max-height: calc(100vh - 80px);"
|
||||
>
|
||||
<%= for interaction <- @interactions do %>
|
||||
<div class="bg-white rounded-lg p-3 shadow-base transition-all flex flex-col justify-between relative">
|
||||
<div>
|
||||
@@ -767,7 +761,7 @@
|
||||
<% %Claper.Polls.Poll{} -> %>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
@@ -783,11 +777,11 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold"><%= gettext("Poll") %></span>
|
||||
<span class="font-semibold">{gettext("Poll")}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="p-2 rounded text-xs font-medium text-center text-primary-500"
|
||||
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={~p"/e/#{@event.code}/manage/edit/poll/#{interaction.id}"}
|
||||
@@ -811,7 +805,7 @@
|
||||
<% %Claper.Forms.Form{} -> %>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
@@ -827,13 +821,11 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold"><%= gettext("Form") %></span>
|
||||
<span class="font-semibold">{gettext("Form")}</span>
|
||||
</div>
|
||||
<a
|
||||
class="p-2 rounded text-xs font-medium text-center text-primary-500"
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={~p"/e/#{@event.code}/manage/edit/form/#{interaction.id}"}
|
||||
<.link
|
||||
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
|
||||
patch={~p"/e/#{@event.code}/manage/edit/form/#{interaction.id}"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -849,12 +841,12 @@
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</.link>
|
||||
</div>
|
||||
<% %Claper.Embeds.Embed{} -> %>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -871,11 +863,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold">
|
||||
<%= gettext("Web content") %>
|
||||
{gettext("Web content")}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
class="p-2 rounded text-xs font-medium text-center text-primary-500"
|
||||
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={~p"/e/#{@event.code}/manage/edit/embed/#{interaction.id}"}
|
||||
@@ -899,7 +891,7 @@
|
||||
<% %Claper.Quizzes.Quiz{} -> %>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-linear-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
@@ -917,7 +909,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold">
|
||||
<%= gettext("Quiz") %>
|
||||
{gettext("Quiz")}
|
||||
</span>
|
||||
<p
|
||||
:if={interaction.lti_resource_id}
|
||||
@@ -940,7 +932,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
class="p-2 rounded text-xs font-medium text-center text-primary-500"
|
||||
class="p-2 rounded-sm text-xs font-medium text-center text-primary-500"
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={~p"/e/#{@event.code}/manage/edit/quiz/#{interaction.id}"}
|
||||
@@ -1079,7 +1071,7 @@
|
||||
<% end %>
|
||||
<% _ -> %>
|
||||
<% end %>
|
||||
<span><%= interaction.title %></span>
|
||||
<span>{interaction.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
@@ -1096,9 +1088,9 @@
|
||||
end
|
||||
}
|
||||
phx-value-id={interaction.id}
|
||||
class="bg-supporting-red-100 text-supporting-red-800 px-2 py-2 rounded text-sm font-medium w-full"
|
||||
class="bg-supporting-red-100 text-supporting-red-800 px-2 py-2 rounded-sm text-sm font-medium w-full cursor-pointer"
|
||||
>
|
||||
<%= gettext("Disable") %>
|
||||
{gettext("Disable")}
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
@@ -1113,9 +1105,9 @@
|
||||
end
|
||||
}
|
||||
phx-value-id={interaction.id}
|
||||
class="bg-primary-100 text-primary-800 px-2 py-2 rounded text-sm font-medium w-full"
|
||||
class="bg-primary-100 text-primary-800 px-2 py-2 rounded-sm text-sm font-medium w-full cursor-pointer"
|
||||
>
|
||||
<%= gettext("Enable") %>
|
||||
{gettext("Enable")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1123,13 +1115,13 @@
|
||||
<% end %>
|
||||
<button
|
||||
:if={length(@interactions) > 0}
|
||||
phx-click={toggle_add_modal()}
|
||||
phx-click="toggle-interaction-modal"
|
||||
class="
|
||||
bg-white @container rounded-lg p-3 shadow-base transition-all flex flex-col justify-center items-center transform hover:scale-105"
|
||||
>
|
||||
<img src="/images/interaction-icons.png" class="w-2/3 @sm:w-1/3" />
|
||||
<span class="font-semibold text-secondary-800">
|
||||
<%= gettext("Add interaction") %>
|
||||
{gettext("Add interaction")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1137,7 +1129,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden md:block gutter-1 row-span-full cursor-col-resize col-[2] bg-gray-50 text-center text-gray-300 text-sm leading-3"
|
||||
class="hidden md:block gutter-1 row-span-full cursor-col-resize col-2 bg-gray-50 text-center text-gray-300 text-sm leading-3"
|
||||
style="writing-mode: vertical-rl"
|
||||
>
|
||||
•••
|
||||
@@ -1164,35 +1156,35 @@
|
||||
>
|
||||
<li class={"rounded-md #{if @list_tab==:posts, do: "bg-secondary-600 text-white" ,
|
||||
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
|
||||
<%= link(gettext("Messages") <> " (#{@post_count})",
|
||||
{link(gettext("Messages") <> " (#{@post_count})",
|
||||
to: "#",
|
||||
phx_click: "list-tab",
|
||||
phx_value_tab: :posts
|
||||
) %>
|
||||
)}
|
||||
</li>
|
||||
<li class={"rounded-md #{if @list_tab==:questions, do: "bg-secondary-600 text-white" ,
|
||||
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
|
||||
<%= link(gettext("Questions") <> " (#{@question_count})",
|
||||
{link(gettext("Questions") <> " (#{@question_count})",
|
||||
to: "#",
|
||||
phx_click: "list-tab",
|
||||
phx_value_tab: :questions
|
||||
) %>
|
||||
)}
|
||||
</li>
|
||||
<li class={"rounded-md #{if @list_tab==:pinned_posts, do: "bg-secondary-600 text-white" ,
|
||||
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
|
||||
<%= link(gettext("Pinned messages") <> " (#{@pinned_post_count})",
|
||||
{link(gettext("Pinned messages") <> " (#{@pinned_post_count})",
|
||||
to: "#",
|
||||
phx_click: "list-tab",
|
||||
phx_value_tab: :pinned_posts
|
||||
) %>
|
||||
)}
|
||||
</li>
|
||||
<li class={"rounded-md #{if @list_tab==:forms, do: "bg-secondary-600 text-white" ,
|
||||
else: "bg-white text-gray-600" } px-2 py-0.5 text-sm shadow-sm"}>
|
||||
<%= link(gettext("Form submissions") <> " (#{@form_submit_count})",
|
||||
{link(gettext("Form submissions") <> " (#{@form_submit_count})",
|
||||
to: "#",
|
||||
phx_click: "list-tab",
|
||||
phx_value_tab: :forms
|
||||
) %>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1218,7 +1210,7 @@
|
||||
</svg>
|
||||
|
||||
<p class="text-lg">
|
||||
<%= gettext("Messages from attendees will appear here.") %>
|
||||
{gettext("Messages from attendees will appear here.")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -1259,7 +1251,7 @@
|
||||
</svg>
|
||||
|
||||
<p class="text-lg">
|
||||
<%= gettext("Questions will appear here.") %>
|
||||
{gettext("Questions will appear here.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1280,7 +1272,7 @@
|
||||
</svg>
|
||||
|
||||
<p class="flex items-center gap-x-1">
|
||||
<%= gettext("Sort by popularity") %>
|
||||
{gettext("Sort by popularity")}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
@@ -1301,7 +1293,7 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("Sort by date") %></span>
|
||||
<span>{gettext("Sort by date")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -1341,7 +1333,7 @@
|
||||
</svg>
|
||||
|
||||
<p class="text-lg">
|
||||
<%= gettext("Pinned messages will appear here.") %>
|
||||
{gettext("Pinned messages will appear here.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1387,7 +1379,7 @@
|
||||
</svg>
|
||||
|
||||
<p class="text-lg">
|
||||
<%= gettext("Form submissions from attendees will appear here.") %>
|
||||
{gettext("Form submissions from attendees will appear here.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1402,20 +1394,20 @@
|
||||
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-2">
|
||||
<div class="float-right mr-1">
|
||||
<span class="text-red-500">
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete-form-submit",
|
||||
phx_value_id: submission.id,
|
||||
phx_value_event_id: @event.uuid,
|
||||
data: [confirm: gettext("This cannot be undone, confirm ?")]
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<span class="font-semibold text-lg">
|
||||
<%= gettext("Form") %>
|
||||
</span>: <%= submission.form.title %>
|
||||
</span>: {submission.form.title}
|
||||
</p>
|
||||
|
||||
<div class="flex space-x-3 items-center">
|
||||
@@ -1435,9 +1427,9 @@
|
||||
<%= for res <- submission.response do %>
|
||||
<p>
|
||||
<strong>
|
||||
<%= elem(res, 0) %>:
|
||||
{elem(res, 0)}:
|
||||
</strong>
|
||||
<%= elem(res, 1) %>
|
||||
{elem(res, 1)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1448,28 +1440,29 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block gutter-2 col-span-full cursor-row-resize z-20 row-[2] bg-gray-50 text-center text-gray-300 text-sm leading-3">
|
||||
<div class="hidden lg:block gutter-2 col-span-full cursor-row-resize z-20 row-2 bg-gray-50 text-center text-gray-300 text-sm leading-3">
|
||||
•••
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden lg:block z-20 bg-white @container"
|
||||
class="hidden lg:flex flex-col z-20 bg-white @container max-h-full min-h-0"
|
||||
data-tg-title={"#{gettext("Settings")}"}
|
||||
data-tg-order="3"
|
||||
data-tg-tour={"<p class='mb-3'>#{gettext("You can control each setting for the presentation (showing on the big screen) and on the attendee's room.")}</p><p class='opacity-50 text-xs'>#{gettext("Use the associated keyboard shortcuts for quick toggling of these settings.")}</p>"}
|
||||
data-tg-group="manage"
|
||||
>
|
||||
<div class="w-full h-12 bg-gray-100 font-semibold text-xl flex items-center justify-center">
|
||||
<%= gettext("Settings") %>
|
||||
{gettext("Settings")}
|
||||
</div>
|
||||
<div class="grow @md:overflow-hidden overflow-auto min-h-0 ">
|
||||
<.live_component
|
||||
id="settings-pane"
|
||||
module={ClaperWeb.EventLive.ManagerSettingsComponent}
|
||||
create={@create}
|
||||
state={@state}
|
||||
current_interaction={@current_interaction}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
id="settings-pane"
|
||||
module={ClaperWeb.EventLive.ManagerSettingsComponent}
|
||||
create={@create}
|
||||
state={@state}
|
||||
current_interaction={@current_interaction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,12 +26,12 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("Question") %></span>
|
||||
<span>{gettext("Question")}</span>
|
||||
</div>
|
||||
<div :if={!@readonly} class="float-right mr-1">
|
||||
<%= if @post.attendee_identifier do %>
|
||||
<span class="text-yellow-500">
|
||||
<%= link(
|
||||
{link(
|
||||
if @post.pinned do
|
||||
gettext("Unpin")
|
||||
else
|
||||
@@ -41,11 +41,11 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
phx_click: "pin",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
/
|
||||
<span class="text-red-500">
|
||||
<%= link(gettext("Ban"),
|
||||
{link(gettext("Ban"),
|
||||
to: "#",
|
||||
phx_click: "ban",
|
||||
phx_value_attendee_identifier: @post.attendee_identifier,
|
||||
@@ -55,12 +55,12 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
"Blocking this user will delete all his messages and he will not be able to join again, confirm ?"
|
||||
)
|
||||
]
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
/
|
||||
<% else %>
|
||||
<span class="text-yellow-500">
|
||||
<%= link(
|
||||
{link(
|
||||
if @post.pinned do
|
||||
gettext("Unpin")
|
||||
else
|
||||
@@ -70,11 +70,11 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
phx_click: "pin",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
/
|
||||
<span class="text-red-500">
|
||||
<%= link(gettext("Ban"),
|
||||
{link(gettext("Ban"),
|
||||
to: "#",
|
||||
phx_click: "ban",
|
||||
phx_value_user_id: @post.user_id,
|
||||
@@ -84,17 +84,17 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
"Blocking this user will delete all his messages and he will not be able to join again, confirm ?"
|
||||
)
|
||||
]
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
/
|
||||
<% end %>
|
||||
<span class="text-red-500">
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -114,12 +114,12 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
<div class="flex flex-col">
|
||||
<%= if @post.name do %>
|
||||
<p class="text-black text-sm font-semibold mr-2">
|
||||
<%= @post.name %>
|
||||
{@post.name}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<p class="text-xl">
|
||||
<%= ClaperWeb.Helpers.format_body(@post.body) %>
|
||||
{ClaperWeb.Helpers.format_body(@post.body)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
<%= if @post.like_count> 0 do %>
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
<span class="ml-1">
|
||||
<%= @post.like_count %>
|
||||
{@post.like_count}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
<%= if @post.love_count> 0 do %>
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<span class="ml-1">
|
||||
<%= @post.love_count %>
|
||||
{@post.love_count}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -146,7 +146,7 @@ defmodule ClaperWeb.EventLive.ManageablePostComponent do
|
||||
<%= if @post.lol_count> 0 do %>
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<span class="ml-1">
|
||||
<%= @post.lol_count %>
|
||||
{@post.lol_count}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,13 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
|
||||
~H"""
|
||||
<div
|
||||
id={"#{@id}"}
|
||||
class={"#{if @quiz.show_results, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
|
||||
class={"#{if @quiz.show_results, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black/90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
|
||||
>
|
||||
<div class="w-full md:w-1/2 mx-auto h-full">
|
||||
<p class={"#{if @iframe, do: "text-xl mb-12", else: "text-5xl mb-24"} text-white font-bold text-center"}>
|
||||
<span :if={@current_question_idx < 0}><%= @quiz.title %></span>
|
||||
<span :if={@current_question_idx < 0}>{@quiz.title}</span>
|
||||
<span :if={@current_question_idx >= 0}>
|
||||
<%= Enum.at(@quiz.quiz_questions, @current_question_idx).content %>
|
||||
{Enum.at(@quiz.quiz_questions, @current_question_idx).content}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -26,9 +26,9 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
|
||||
:if={@current_question_idx == -1}
|
||||
class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col text-white text-center"}
|
||||
>
|
||||
<p class="font-semibold text-2xl"><%= gettext("Average score") %>:</p>
|
||||
<p class="font-semibold text-2xl">{gettext("Average score")}:</p>
|
||||
<p class="font-semibold text-7xl">
|
||||
<%= Claper.Quizzes.calculate_average_score(@quiz.id) %>/<%= length(@quiz.quiz_questions) %>
|
||||
{Claper.Quizzes.calculate_average_score(@quiz.id)}/{length(@quiz.quiz_questions)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -38,11 +38,11 @@ defmodule ClaperWeb.EventLive.ManageableQuizComponent do
|
||||
>
|
||||
<%= for {opt, _idx} <- Enum.with_index(Enum.at(@quiz.quiz_questions, @current_question_idx).quiz_question_opts) do %>
|
||||
<div class={"bg-gray-500 px-5 py-5 rounded-xl flex justify-between items-center relative text-white #{if opt.is_correct, do: "bg-green-600"} #{if not opt.is_correct, do: ""}"}>
|
||||
<div class="bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
</div>
|
||||
<div class="flex space-x-3 justify-between w-full items-center z-10 text-left">
|
||||
<span class="flex-1 pr-2 text-3xl"><%= opt.content %></span>
|
||||
<span class="text-xl"><%= opt.percentage %>% (<%= opt.response_count %>)</span>
|
||||
<span class="flex-1 pr-2 text-3xl">{opt.content}</span>
|
||||
<span class="text-xl">{opt.percentage}% ({opt.response_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
assigns = assigns |> assign_new(:show_shortcut, fn -> true end)
|
||||
|
||||
~H"""
|
||||
<div class="grid grid-cols-1 @md:grid-cols-2 @md:space-x-5 px-5 py-3">
|
||||
<div class="grid grid-cols-1 @md:grid-cols-2 @md:space-x-5 px-5 py-3 h-full mb-10">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
@@ -19,7 +19,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
<path d="M10.766 7.51a.75.75 0 0 0-1.37.365l-.492 6.861a.75.75 0 0 0 1.204.65l1.043-.799.985 3.678a.75.75 0 0 0 1.45-.388l-.978-3.646 1.292.204a.75.75 0 0 0 .74-1.16l-3.874-5.764Z" />
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("Interaction") %></span>
|
||||
<span>{gettext("Interaction")}</span>
|
||||
</div>
|
||||
|
||||
<%= case @current_interaction do %>
|
||||
@@ -59,10 +59,10 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
</svg>
|
||||
|
||||
<span :if={@state.poll_visible}>
|
||||
<%= gettext("Hide results on presentation") %>
|
||||
{gettext("Hide results on presentation")}
|
||||
</span>
|
||||
<span :if={!@state.poll_visible}>
|
||||
<%= gettext("Show results on presentation") %>
|
||||
{gettext("Show results on presentation")}
|
||||
</span>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
@@ -109,10 +109,10 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
</svg>
|
||||
|
||||
<span :if={@current_interaction.show_results}>
|
||||
<%= gettext("Hide results on presentation") %>
|
||||
{gettext("Hide results on presentation")}
|
||||
</span>
|
||||
<span :if={!@current_interaction.show_results}>
|
||||
<%= gettext("Show results on presentation") %>
|
||||
{gettext("Show results on presentation")}
|
||||
</span>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
@@ -141,7 +141,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
<%= gettext("Review questions") %>
|
||||
{gettext("Review questions")}
|
||||
</span>
|
||||
<div></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
@@ -165,7 +165,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
<%= gettext("Previous") %>
|
||||
{gettext("Previous")}
|
||||
</span>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
@@ -173,7 +173,7 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
key={:next_quiz_question}
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Next") %>
|
||||
{gettext("Next")}
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -198,352 +198,409 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 space-y-5">
|
||||
<div class="grid grid-cols-1 space-y-1.5">
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M1 2.75A.75.75 0 0 1 1.75 2h16.5a.75.75 0 0 1 0 1.5H18v8.75A2.75 2.75 0 0 1 15.25 15h-1.072l.798 3.06a.75.75 0 0 1-1.452.38L13.41 18H6.59l-.114.44a.75.75 0 0 1-1.452-.38L5.823 15H4.75A2.75 2.75 0 0 1 2 12.25V3.5h-.25A.75.75 0 0 1 1 2.75ZM7.373 15l-.391 1.5h6.037l-.392-1.5H7.373ZM13.25 5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm-6.5 4a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 6.75 9Zm4-1.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="h-full overflow-visible @md:overflow-auto">
|
||||
<div class="grid grid-cols-1 space-y-5">
|
||||
<div class="grid grid-cols-1 space-y-1.5">
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M1 2.75A.75.75 0 0 1 1.75 2h16.5a.75.75 0 0 1 0 1.5H18v8.75A2.75 2.75 0 0 1 15.25 15h-1.072l.798 3.06a.75.75 0 0 1-1.452.38L13.41 18H6.59l-.114.44a.75.75 0 0 1-1.452-.38L5.823 15H4.75A2.75 2.75 0 0 1 2 12.25V3.5h-.25A.75.75 0 0 1 1 2.75ZM7.373 15l-.391 1.5h6.037l-.392-1.5H7.373ZM13.25 5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm-6.5 4a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 6.75 9Zm4-1.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("Presentation") %></span>
|
||||
<span>{gettext("Presentation")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 items-center">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:join_screen_visible}
|
||||
checked={@state.join_screen_visible}
|
||||
shortcut={if @create == nil, do: "Q", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.join_screen_visible}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
:if={@state.join_screen_visible}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 4h1a1 1 0 0 1 1 1v1m-.297 3.711a1 1 0 0 1 -.703 .289h-4a1 1 0 0 1 -1 -1v-4c0 -.275 .11 -.524 .29 -.705" /><path d="M7 17v.01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7v.01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7v.01" /><path d="M20 14v.01" /><path d="M14 14v3" /><path d="M14 20h3" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.join_screen_visible}>
|
||||
{gettext("Show instructions to join")}
|
||||
</span>
|
||||
<span :if={@state.join_screen_visible}>
|
||||
{gettext("Hide instructions to join")}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
q
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:chat_visible}
|
||||
checked={@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "W", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.chat_visible}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.chat_visible}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.chat_visible}>{gettext("Show messages")}</span>
|
||||
<span :if={@state.chat_visible}>{gettext("Hide messages")}</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
w
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center"}
|
||||
title={
|
||||
if !@state.chat_visible,
|
||||
do: gettext("Show messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:show_only_pinned}
|
||||
checked={@state.show_only_pinned}
|
||||
disabled={!@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "E", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.show_only_pinned}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h4.5" /><path d="M10.325 19.605l-2.325 1.395v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.show_only_pinned}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.show_only_pinned}>
|
||||
{gettext("Show only pinned messages")}
|
||||
</span>
|
||||
<span :if={@state.show_only_pinned}>{gettext("Show all messages")}</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
e
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
<div>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:show_attendee_count}
|
||||
checked={@state.show_attendee_count}
|
||||
shortcut={if @create == nil, do: "R", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.show_attendee_count}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 10c4.42 0 8 1.79 8 4v2H4v-2c0-2.21 3.58-4 8-4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.show_attendee_count}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 4a4 4 0 0 1 4 4c0 1.95-1.4 3.58-3.25 3.93L8.07 7.25A4.004 4.004 0 0 1 12 4m.28 10l6 6L20 21.72L18.73 23l-3-3H4v-2c0-1.84 2.5-3.39 5.87-3.86L2.78 7.05l1.27-1.27zM20 18v1.18l-4.86-4.86C18 14.93 20 16.35 20 18"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.show_attendee_count}>
|
||||
{gettext("Show attendee count")}
|
||||
</span>
|
||||
<span :if={@state.show_attendee_count}>
|
||||
{gettext("Hide attendee count")}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
r
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:join_screen_visible}
|
||||
checked={@state.join_screen_visible}
|
||||
shortcut={if @create == nil, do: "Q", else: nil}
|
||||
<div class="grid grid-cols-1 space-y-1.5">
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path d="M8 16.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4Zm4-1.5v.75c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V2.5h1A1.5 1.5 0 0 1 14.5 4v12a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 16V4A1.5 1.5 0 0 1 7 2.5h1Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span>{gettext("Attendees")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:chat_enabled}
|
||||
checked={@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "A", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.chat_enabled}>{gettext("Enable messages")}</span>
|
||||
<span :if={@state.chat_enabled}>{gettext("Disable messages")}</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
a
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center"}
|
||||
title={
|
||||
if !@state.chat_enabled,
|
||||
do: gettext("Enable messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.join_screen_visible}
|
||||
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="w-5 h-5"
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:anonymous_chat_enabled}
|
||||
checked={@state.anonymous_chat_enabled}
|
||||
disabled={!@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "S", else: nil}
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={!@state.anonymous_chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h18" /><path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M10 17h4" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.anonymous_chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h8m4 0h6" /><path d="M5 11v-4c0 -.571 .16 -1.105 .437 -1.56m2.563 -1.44h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M14.88 14.877a3 3 0 1 0 4.239 4.247m.59 -3.414a3.012 3.012 0 0 0 -1.425 -1.422" /><path d="M10 17h4" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
:if={@state.join_screen_visible}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 4h1a1 1 0 0 1 1 1v1m-.297 3.711a1 1 0 0 1 -.703 .289h-4a1 1 0 0 1 -1 -1v-4c0 -.275 .11 -.524 .29 -.705" /><path d="M7 17v.01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7v.01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7v.01" /><path d="M20 14v.01" /><path d="M14 14v3" /><path d="M14 20h3" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.join_screen_visible}>
|
||||
<%= gettext("Show instructions to join") %>
|
||||
</span>
|
||||
<span :if={@state.join_screen_visible}>
|
||||
<%= gettext("Hide instructions to join") %>
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
q
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
<div>
|
||||
<span :if={!@state.anonymous_chat_enabled}>
|
||||
{gettext("Allow anonymous messages")}
|
||||
</span>
|
||||
<span :if={@state.anonymous_chat_enabled}>
|
||||
{gettext("Deny anonymous messages")}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
s
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:chat_visible}
|
||||
checked={@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "W", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.chat_visible}
|
||||
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="w-5 h-5"
|
||||
<div class="flex space-x-2 items-center">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:message_reaction_enabled}
|
||||
checked={@state.message_reaction_enabled}
|
||||
shortcut={if @create == nil, do: "D", else: nil}
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.chat_visible}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.chat_visible}><%= gettext("Show messages") %></span>
|
||||
<span :if={@state.chat_visible}><%= gettext("Hide messages") %></span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
w
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
<svg
|
||||
:if={!@state.message_reaction_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.message_reaction_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3l18 18" /><path d="M19.5 12.572l-1.5 1.428m-2 2l-4 4l-7.5 -7.428a5 5 0 0 1 -1.288 -5.068a4.976 4.976 0 0 1 1.788 -2.504m3 -1c1.56 0 3.05 .727 4 2a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center mt-3"}
|
||||
title={
|
||||
if !@state.chat_visible,
|
||||
do: gettext("Show messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:show_only_pinned}
|
||||
checked={@state.show_only_pinned}
|
||||
disabled={!@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "E", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.show_only_pinned}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h4.5" /><path d="M10.325 19.605l-2.325 1.395v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.show_only_pinned}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.show_only_pinned}>
|
||||
<%= gettext("Show only pinned messages") %>
|
||||
</span>
|
||||
<span :if={@state.show_only_pinned}><%= gettext("Show all messages") %></span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
e
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 space-y-1.5">
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path d="M8 16.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4Zm4-1.5v.75c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V2.5h1A1.5 1.5 0 0 1 14.5 4v12a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 16V4A1.5 1.5 0 0 1 7 2.5h1Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("Attendees") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:chat_enabled}
|
||||
checked={@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "A", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.chat_enabled}><%= gettext("Enable messages") %></span>
|
||||
<span :if={@state.chat_enabled}><%= gettext("Disable messages") %></span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
a
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center mt-3"}
|
||||
title={
|
||||
if !@state.chat_enabled,
|
||||
do: gettext("Enable messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:anonymous_chat_enabled}
|
||||
checked={@state.anonymous_chat_enabled}
|
||||
disabled={!@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "S", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.anonymous_chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h18" /><path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M10 17h4" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.anonymous_chat_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h8m4 0h6" /><path d="M5 11v-4c0 -.571 .16 -1.105 .437 -1.56m2.563 -1.44h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M14.88 14.877a3 3 0 1 0 4.239 4.247m.59 -3.414a3.012 3.012 0 0 0 -1.425 -1.422" /><path d="M10 17h4" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<span :if={!@state.anonymous_chat_enabled}>
|
||||
<%= gettext("Allow anonymous messages") %>
|
||||
</span>
|
||||
<span :if={@state.anonymous_chat_enabled}>
|
||||
<%= gettext("Deny anonymous messages") %>
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
s
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:message_reaction_enabled}
|
||||
checked={@state.message_reaction_enabled}
|
||||
shortcut={if @create == nil, do: "D", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.message_reaction_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.message_reaction_enabled}
|
||||
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="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3l18 18" /><path d="M19.5 12.572l-1.5 1.428m-2 2l-4 4l-7.5 -7.428a5 5 0 0 1 -1.288 -5.068a4.976 4.976 0 0 1 1.788 -2.504m3 -1c1.56 0 3.05 .727 4 2a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<span :if={!@state.message_reaction_enabled}>
|
||||
<%= gettext("Enable reactions") %>
|
||||
</span>
|
||||
<span :if={@state.message_reaction_enabled}>
|
||||
<%= gettext("Disable reactions") %>
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
d
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
<div>
|
||||
<span :if={!@state.message_reaction_enabled}>
|
||||
{gettext("Enable reactions")}
|
||||
</span>
|
||||
<span :if={@state.message_reaction_enabled}>
|
||||
{gettext("Disable reactions")}
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
d
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold"><%= gettext("See current poll") %></span>
|
||||
<span class="font-bold">{gettext("See current poll")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,12 +44,12 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-400 my-1"><%= gettext("Current poll") %></p>
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= @poll.title %></p>
|
||||
<p class="text-sm text-gray-400 my-1">{gettext("Current poll")}</p>
|
||||
<p class="text-white text-xl font-semibold mb-2">{@poll.title}</p>
|
||||
<%= if @poll.multiple do %>
|
||||
<p class="text-gray-400 text-sm mb-4"><%= gettext("Select one or multiple options") %></p>
|
||||
<p class="text-gray-400 text-sm mb-4">{gettext("Select one or multiple options")}</p>
|
||||
<% else %>
|
||||
<p class="text-gray-400 text-sm mb-4"><%= gettext("Select one option") %></p>
|
||||
<p class="text-gray-400 text-sm mb-4">{gettext("Select one option")}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
@@ -60,7 +60,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
<button class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white">
|
||||
<div
|
||||
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
@@ -78,10 +78,10 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
<span :if={@show_results} class="text-sm z-10">
|
||||
<%= opt.percentage %>% (<%= opt.vote_count %>)
|
||||
{opt.percentage}% ({opt.vote_count})
|
||||
</span>
|
||||
</button>
|
||||
<% else %>
|
||||
@@ -93,7 +93,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
>
|
||||
<div
|
||||
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
@@ -111,10 +111,10 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
<span :if={@show_results} class="text-sm z-10">
|
||||
<%= opt.percentage %>% (<%= opt.vote_count %>)
|
||||
{opt.percentage}% ({opt.vote_count})
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -124,7 +124,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
|
||||
<%= if (length @selected_poll_opt) == 0 || (length @current_poll_vote) > 0 do %>
|
||||
<button class="px-3 py-2 text-white font-medium bg-gray-500 rounded-md mt-3 mb-4 cursor-default">
|
||||
<%= gettext("Vote") %>
|
||||
{gettext("Vote")}
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
@@ -132,7 +132,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
phx-disable-with="..."
|
||||
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
|
||||
>
|
||||
<%= gettext("Vote") %>
|
||||
{gettext("Vote")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -25,12 +25,12 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
<%= if @post.name || leader?(@post, @event, @leaders) || pinned?(@post) do %>
|
||||
<div class="inline-flex items-center">
|
||||
<%= if @post.name do %>
|
||||
<p class="text-white text-xs font-semibold mb-2 mr-2"><%= @post.name %></p>
|
||||
<p class="text-white text-xs font-semibold mb-2 mr-2">{@post.name}</p>
|
||||
<% end %>
|
||||
<%= if leader?(@post, @event, @leaders) do %>
|
||||
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
|
||||
<img src="/images/icons/star.svg" class="h-3" />
|
||||
<span><%= gettext("Host") %></span>
|
||||
<span>{gettext("Host")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -56,7 +56,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<span><%= gettext("Pinned") %></span>
|
||||
<span>{gettext("Pinned")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -67,34 +67,34 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
class="hidden absolute right-4 top-7 bg-white rounded-lg px-5 py-2 animate__faster"
|
||||
>
|
||||
<span class="text-red-500">
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid,
|
||||
data: [confirm: gettext("Are you sure?")]
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p><%= ClaperWeb.Helpers.format_body(@post.body) %></p>
|
||||
<p>{ClaperWeb.Helpers.format_body(@post.body)}</p>
|
||||
|
||||
<div class="flex h-6 text-sm float-right text-white space-x-2">
|
||||
<%= if @post.like_count > 0 do %>
|
||||
<div class="flex px-1 items-center">
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
<span class="ml-1 text-white"><%= @post.like_count %></span>
|
||||
<span class="ml-1 text-white">{@post.like_count}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @post.love_count > 0 do %>
|
||||
<div class="flex px-1 items-center">
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<span class="ml-1 text-white"><%= @post.love_count %></span>
|
||||
<span class="ml-1 text-white">{@post.love_count}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @post.lol_count > 0 do %>
|
||||
<div class="flex px-1 items-center">
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<span class="ml-1 text-white"><%= @post.lol_count %></span>
|
||||
<span class="ml-1 text-white">{@post.lol_count}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -104,12 +104,12 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
<%= if @post.name || leader?(@post, @event, @leaders) do %>
|
||||
<div class="inline-flex items-center">
|
||||
<%= if @post.name do %>
|
||||
<p class="text-black text-xs font-semibold mb-2 mr-2"><%= @post.name %></p>
|
||||
<p class="text-black text-xs font-semibold mb-2 mr-2">{@post.name}</p>
|
||||
<% end %>
|
||||
<%= if leader?(@post, @event, @leaders) do %>
|
||||
<div class="inline-flex items-center space-x-1 justify-center px-3 py-0.5 rounded-full text-xs font-medium bg-supporting-yellow-100 text-supporting-yellow-800 mb-2">
|
||||
<img src="/images/icons/star.svg" class="h-3" />
|
||||
<span><%= gettext("Host") %></span>
|
||||
<span>{gettext("Host")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -139,13 +139,13 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
class="hidden absolute right-4 top-7 bg-gray-900 rounded-lg px-5 py-2"
|
||||
>
|
||||
<span class="text-red-500">
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_value_id: @post.uuid,
|
||||
phx_value_event_id: @event.uuid,
|
||||
data: [confirm: gettext("Are you sure?")]
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -172,11 +172,11 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
<span><%= gettext("Pinned") %></span>
|
||||
<span>{gettext("Pinned")}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p><%= ClaperWeb.Helpers.format_body(@post.body) %></p>
|
||||
<p>{ClaperWeb.Helpers.format_body(@post.body)}</p>
|
||||
|
||||
<div class="flex h-6 text-xs float-right space-x-2">
|
||||
<%= if @reaction_enabled do %>
|
||||
@@ -189,7 +189,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
<%= if @post.like_count > 0 do %>
|
||||
<span class="ml-1"><%= @post.like_count %></span>
|
||||
<span class="ml-1">{@post.like_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
@@ -203,7 +203,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
<img src="/images/icons/thumb.svg" class="h-4" />
|
||||
</span>
|
||||
<%= if @post.like_count > 0 do %>
|
||||
<span class="ml-1"><%= @post.like_count %></span>
|
||||
<span class="ml-1">{@post.like_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -216,7 +216,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<%= if @post.love_count > 0 do %>
|
||||
<span class="ml-1"><%= @post.love_count %></span>
|
||||
<span class="ml-1">{@post.love_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
@@ -228,7 +228,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
<img src="/images/icons/heart.svg" class="h-4" />
|
||||
<%= if @post.love_count > 0 do %>
|
||||
<span class="ml-1"><%= @post.love_count %></span>
|
||||
<span class="ml-1">{@post.love_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -241,7 +241,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<%= if @post.lol_count > 0 do %>
|
||||
<span class="ml-1"><%= @post.lol_count %></span>
|
||||
<span class="ml-1">{@post.lol_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
@@ -253,7 +253,7 @@ defmodule ClaperWeb.EventLive.PostComponent do
|
||||
>
|
||||
<img src="/images/icons/laugh.svg" class="h-4" />
|
||||
<%= if @post.lol_count > 0 do %>
|
||||
<span class="ml-1"><%= @post.lol_count %></span>
|
||||
<span class="ml-1">{@post.lol_count}</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
@@ -6,6 +6,8 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
alias Claper.Polls.Poll
|
||||
alias Claper.Forms.Form
|
||||
alias Claper.Quizzes.Quiz
|
||||
alias Claper.Presentations
|
||||
|
||||
@impl true
|
||||
def mount(%{"code" => code} = params, session, socket) do
|
||||
with %{"locale" => locale} <- session do
|
||||
@@ -70,7 +72,7 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
end
|
||||
|
||||
defp leader?(%{assigns: %{current_user: current_user}} = _socket, event) do
|
||||
Claper.Events.leaded_by?(current_user.email, event) || event.user.id == current_user.id
|
||||
Claper.Events.led_by?(current_user.email, event) || event.user.id == current_user.id
|
||||
end
|
||||
|
||||
defp leader?(_socket, _event), do: false
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
>
|
||||
<div class="h-full bg-white text-black text-center flex flex-col items-center justify-center">
|
||||
<span class="font-semibold mb-10 sm:text-3xl md:text-4xl lg:text-6xl">
|
||||
<%= gettext("Scan to interact in real-time") %>
|
||||
{gettext("Scan to interact in real-time")}
|
||||
</span>
|
||||
<div
|
||||
phx-hook="QRCode"
|
||||
@@ -35,10 +35,10 @@
|
||||
>
|
||||
</div>
|
||||
<span class="font-semibold mb-10 sm:text-3xl md:text-4xl lg:text-6xl">
|
||||
<%= gettext("Or go to %{url} and use the code:", url: @host) %>
|
||||
{gettext("Or go to %{url} and use the code:", url: @host)}
|
||||
</span>
|
||||
<span class="font-semibold mb-10 sm:text-5xl md:text-6xl lg:text-8xl">
|
||||
#<%= String.upcase(@event.code) %>
|
||||
#{String.upcase(@event.code)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,11 +46,11 @@
|
||||
<%= if @current_poll do %>
|
||||
<div
|
||||
id="poll"
|
||||
class={"#{if @state.poll_visible, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
|
||||
class={"#{if @state.poll_visible, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black/90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
|
||||
>
|
||||
<div class="w-full md:w-1/2 mx-auto h-full">
|
||||
<p class={"#{if @iframe, do: "text-xl mb-12", else: "text-5xl mb-24"} text-white font-bold text-center"}>
|
||||
<%= @current_poll.title %>
|
||||
{@current_poll.title}
|
||||
</p>
|
||||
|
||||
<div class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col"}>
|
||||
@@ -59,16 +59,16 @@
|
||||
<div class={"#{if @iframe, do: "py-1", else: "py-4"} bg-gray-500 px-6 rounded-3xl flex justify-between items-center relative text-white"}>
|
||||
<div
|
||||
style={"width: #{opt.percentage}%;"}
|
||||
class="bg-gradient-to-r from-primary-500 to-secondary-500 rounded-3xl h-full absolute left-0 transition-all"
|
||||
class="bg-linear-to-r from-primary-500 to-secondary-500 rounded-3xl h-full absolute left-0 transition-all"
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 z-10 text-left">
|
||||
<span class={"#{if @iframe, do: "text-base", else: "text-2xl"} flex-1 font-bold pr-2"}>
|
||||
<%= opt.content %>
|
||||
{opt.content}
|
||||
</span>
|
||||
</div>
|
||||
<span class={"#{if @iframe, do: "text-base", else: "text-2xl"} z-10 font-bold"}>
|
||||
<%= opt.percentage %>% (<%= opt.vote_count %>)
|
||||
{opt.percentage}% ({opt.vote_count})
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -107,10 +107,10 @@
|
||||
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4">
|
||||
<%= if post.name do %>
|
||||
<p class={"#{if @iframe, do: "text-base", else: "text-lg"} text-gray-400 font-semibold mb-2 mr-2"}>
|
||||
<%= post.name %>
|
||||
{post.name}
|
||||
</p>
|
||||
<% end %>
|
||||
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}><%= post.body %></p>
|
||||
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}>{post.body}</p>
|
||||
|
||||
<%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %>
|
||||
<div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5">
|
||||
@@ -120,7 +120,7 @@
|
||||
src="/images/icons/thumb.svg"
|
||||
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
|
||||
/>
|
||||
<span class="ml-1"><%= post.like_count %></span>
|
||||
<span class="ml-1">{post.like_count}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -129,7 +129,7 @@
|
||||
src="/images/icons/heart.svg"
|
||||
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
|
||||
/>
|
||||
<span class="ml-1"><%= post.love_count %></span>
|
||||
<span class="ml-1">{post.love_count}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -138,7 +138,7 @@
|
||||
src="/images/icons/laugh.svg"
|
||||
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
|
||||
/>
|
||||
<span class="ml-1"><%= post.lol_count %></span>
|
||||
<span class="ml-1">{post.lol_count}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,10 +154,10 @@
|
||||
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white shadow-md text-black break-word mt-4">
|
||||
<%= if post.name do %>
|
||||
<p class={"#{if @iframe, do: "text-base", else: "text-lg"} text-gray-400 font-semibold mb-2 mr-2"}>
|
||||
<%= post.name %>
|
||||
{post.name}
|
||||
</p>
|
||||
<% end %>
|
||||
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}><%= post.body %></p>
|
||||
<p class={"#{if @iframe, do: "text-xl", else: "text-3xl"}"}>{post.body}</p>
|
||||
|
||||
<%= if post.like_count > 0 || post.love_count > 0 || post.lol_count > 0 do %>
|
||||
<div class="flex h-6 space-x-2 text-lg text-gray-500 pb-3 items-center mt-5">
|
||||
@@ -167,7 +167,7 @@
|
||||
src="/images/icons/thumb.svg"
|
||||
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
|
||||
/>
|
||||
<span class="ml-1"><%= post.like_count %></span>
|
||||
<span class="ml-1">{post.like_count}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -176,7 +176,7 @@
|
||||
src="/images/icons/heart.svg"
|
||||
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
|
||||
/>
|
||||
<span class="ml-1"><%= post.love_count %></span>
|
||||
<span class="ml-1">{post.love_count}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
@@ -185,7 +185,7 @@
|
||||
src="/images/icons/laugh.svg"
|
||||
class={"#{if @iframe, do: "h-4", else: "h-7"}"}
|
||||
/>
|
||||
<span class="ml-1"><%= post.lol_count %></span>
|
||||
<span class="ml-1">{post.lol_count}</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,7 +197,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div
|
||||
class={"gutter-1 row-span-full cursor-col-resize col-[2] text-center text-sm leading-3 text-white #{if (!@state.chat_visible && @event.presentation_file.length > 0) || (!@current_embed && @event.presentation_file.length == 0), do: "hidden"}"}
|
||||
class={"gutter-1 row-span-full cursor-col-resize col-2 text-center text-sm leading-3 text-white #{if (!@state.chat_visible && @event.presentation_file.length > 0) || (!@current_embed && @event.presentation_file.length == 0), do: "hidden"}"}
|
||||
style="writing-mode: vertical-rl"
|
||||
>
|
||||
•••
|
||||
@@ -216,27 +216,17 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<div class={"#{if @current_embed, do: "hidden", else: ""} text-center"} id="slider">
|
||||
<%= for index <- 1..max(1, @event.presentation_file.length) do %>
|
||||
<%= if @event.presentation_file.length > 0 do %>
|
||||
<%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %>
|
||||
<img
|
||||
class="max-h-screen !w-auto"
|
||||
src={"/uploads/#{@event.presentation_file.hash}/#{index}.jpg"}
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
class=" max-h-screen !w-auto"
|
||||
src={"https://#{Application.get_env(:claper, :presentations) |> Keyword.get(:aws_bucket)}.s3.#{Application.get_env(:ex_aws, :region)}.amazonaws.com/presentations/#{@event.presentation_file.hash}/#{index}.jpg"}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<img
|
||||
:for={src <- Presentations.get_slide_urls(@event.presentation_file)}
|
||||
src={src}
|
||||
class="max-h-screen w-auto!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ONLINE BADGE -->
|
||||
<div
|
||||
:if={!@iframe}
|
||||
:if={!@iframe && @state.show_attendee_count}
|
||||
class="absolute z-20 bottom-5 right-5 px-4 pt-3 pb-1 rounded-md bg-black shadow-md text-white flex-1"
|
||||
>
|
||||
<div id="reacts" phx-hook="GlobalReacts" data-class-name="h-24" phx-update="ignore"></div>
|
||||
@@ -244,7 +234,7 @@
|
||||
<div class="inline-flex justify-between items-center text-white text-2xl">
|
||||
<img src="/images/icons/online-users.svg" class="h-12 mr-2" />
|
||||
<span id="counter" phx-hook="UpdateAttendees" phx-update="ignore">
|
||||
<%= @attendees_nb %>
|
||||
{@attendees_nb}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold"><%= gettext("See current quiz") %></span>
|
||||
<span class="font-bold">{gettext("See current quiz")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,13 +57,13 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-400 my-1"><%= gettext("Current quiz") %></p>
|
||||
<p class="text-sm text-gray-400 my-1">{gettext("Current quiz")}</p>
|
||||
<%= if is_nil(@current_question) do %>
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= @quiz.title %></p>
|
||||
<p class="text-white text-xl font-semibold mb-2">{@quiz.title}</p>
|
||||
<% else %>
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= @current_question.content %></p>
|
||||
<p class="text-white text-xl font-semibold mb-2">{@current_question.content}</p>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
<%= @current_quiz_question_idx + 1 %>/<%= length(@quiz.quiz_questions) %>
|
||||
{@current_quiz_question_idx + 1}/{length(@quiz.quiz_questions)}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -81,10 +81,10 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm"><%= opt.percentage %>% (<%= opt.response_count %>)</span>
|
||||
<span class="text-sm">{opt.percentage}% ({opt.response_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
@@ -93,7 +93,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
phx-value-opt={opt.id}
|
||||
class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white"
|
||||
>
|
||||
<div class="bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
<div class="bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<%= if Enum.member?(@selected_quiz_question_opts, opt) do %>
|
||||
@@ -102,7 +102,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -110,18 +110,18 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
<% else %>
|
||||
<div class="text-gray-400 flex flex-col items-center justify-center font-semibold text-lg mt-4">
|
||||
<%= if @quiz.show_results do %>
|
||||
<p><%= gettext("Your score") %></p>
|
||||
<p>{gettext("Your score")}</p>
|
||||
<p class="text-6xl font-bold mt-2">
|
||||
<%= elem(@quiz_score, 0) %>/<%= elem(@quiz_score, 1) %>
|
||||
{elem(@quiz_score, 0)}/{elem(@quiz_score, 1)}
|
||||
</p>
|
||||
<button
|
||||
phx-click="show-quiz-results"
|
||||
class="mt-7 px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
|
||||
>
|
||||
<%= gettext("Show results") %>
|
||||
{gettext("Show results")}
|
||||
</button>
|
||||
<% else %>
|
||||
<p><%= gettext("Waiting for results...") %></p>
|
||||
<p>{gettext("Waiting for results...")}</p>
|
||||
<svg
|
||||
class="w-32 h-32 mt-4"
|
||||
viewBox="0 0 360 360"
|
||||
@@ -148,7 +148,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
<div :if={not @is_submitted} class="flex justify-between items-baseline w-full h-12 mt-5">
|
||||
<%= if @current_quiz_question_idx > 0 do %>
|
||||
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
|
||||
<%= gettext("Back") %>
|
||||
{gettext("Back")}
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
@@ -158,21 +158,21 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
|
||||
disabled={not @has_selection}
|
||||
>
|
||||
<%= gettext("Next") %>
|
||||
{gettext("Next")}
|
||||
</button>
|
||||
<% else %>
|
||||
<%= if is_nil(@current_user) && !@quiz.allow_anonymous do %>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="text-white text-sm font-semibold">
|
||||
<%= gettext("Please sign in to submit your answers") %>
|
||||
{gettext("Please sign in to submit your answers")}
|
||||
</div>
|
||||
<%= link(
|
||||
{link(
|
||||
gettext("Sign in"),
|
||||
target: "_blank",
|
||||
to: ~p"/users/log_in",
|
||||
class:
|
||||
"inline px-3 py-2 text-white font-medium rounded-md h-full bg-primary-400 hover:bg-primary-500"
|
||||
) %>
|
||||
)}
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
@@ -180,7 +180,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
|
||||
disabled={not @has_selection}
|
||||
>
|
||||
<%= gettext("Submit") %>
|
||||
{gettext("Submit")}
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -195,7 +195,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
>
|
||||
<%= if (@current_quiz_question_idx > 0 && @current_quiz_question_idx <= length(@quiz.quiz_questions) - 1) do %>
|
||||
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
|
||||
<%= gettext("Back") %>
|
||||
{gettext("Back")}
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-1/2"></div>
|
||||
@@ -206,7 +206,7 @@ defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
phx-click="next-question"
|
||||
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md h-full"
|
||||
>
|
||||
<%= gettext("Next") %>
|
||||
{gettext("Next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
defp check_leader(%{assigns: %{current_user: current_user} = _assigns} = socket, event)
|
||||
when is_map(current_user) do
|
||||
is_leader =
|
||||
current_user.id == event.user_id || Claper.Events.leaded_by?(current_user.email, event)
|
||||
current_user.id == event.user_id || Claper.Events.led_by?(current_user.email, event)
|
||||
|
||||
socket |> assign(:is_leader, is_leader)
|
||||
end
|
||||
@@ -187,7 +187,7 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:event_terminated, _event}, socket) do
|
||||
def handle_info({:event_terminated, _event_uuid}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, gettext("This event has been terminated"))
|
||||
@@ -291,10 +291,6 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|
||||
@impl true
|
||||
def handle_info({:form_updated, %Claper.Forms.Form{enabled: true} = form}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_interaction, fn _current_interaction -> nil end)}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(form, true)}
|
||||
@@ -328,17 +324,6 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|> load_current_interaction(quiz, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{enabled: true}}, socket) do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_updated, %Claper.Quizzes.Quiz{enabled: true} = quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(quiz, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{enabled: true}}, socket) do
|
||||
{:noreply,
|
||||
@@ -708,7 +693,7 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
)
|
||||
when is_map(current_user) do
|
||||
case Claper.Quizzes.submit_quiz(
|
||||
current_user.id,
|
||||
current_user,
|
||||
opts,
|
||||
socket.assigns.current_interaction.id
|
||||
) do
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<%= if @started || @is_leader do %>
|
||||
<div class="relative min-h-screen lg:flex lg:flex-col lg:items-center lg:w-full bg-black lg:bg-gradient-to-tl from-primary-500 to-secondary-500">
|
||||
<div class="relative min-h-screen lg:flex lg:flex-col lg:items-center lg:w-full bg-black lg:bg-linear-to-tl from-primary-500 to-secondary-500">
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
id="side-menu-shadow"
|
||||
phx-click={toggle_side_menu()}
|
||||
class="hidden fixed z-20 h-screen bg-black bg-opacity-70 w-full"
|
||||
class="hidden fixed z-20 h-screen bg-black/70 w-full"
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div>
|
||||
<img src="/images/logo-large-black.svg" class="h-16 my-3" />
|
||||
|
||||
<span class="font-bold text-xl"><%= @event.name %></span>
|
||||
<span class="font-bold text-xl">{@event.name}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
@@ -23,7 +23,7 @@
|
||||
href={~p"/?disconnected_from=#{@event.uuid}"}
|
||||
>
|
||||
<img src="/images/icons/exit-outline.svg" class="h-5 mr-3" />
|
||||
<span><%= gettext("Leave") %></span>
|
||||
<span>{gettext("Leave")}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,23 +35,23 @@
|
||||
>
|
||||
<div id="banner" class="hidden w-full bg-gray-800 text-center" phx-hook="EmbeddedBanner">
|
||||
<a href="https://claper.co" target="_blank" class="text-xs text-white py-3 w-full">
|
||||
<%= gettext("Create your next presentation with") %>
|
||||
{gettext("Create your next presentation with")}
|
||||
<span class="underline">Claper</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-5 py-3">
|
||||
<button
|
||||
phx-click={toggle_side_menu()}
|
||||
class="bg-black rounded-full text-sm px-3 py-1 bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 text-white uppercase flex items-center"
|
||||
class="bg-black rounded-full text-sm px-3 py-1 bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] text-white uppercase flex items-center"
|
||||
>
|
||||
<img src="/images/icons/menu-outline.svg" class="h-6" />
|
||||
<span class="ml-1">#<%= @event.code %></span>
|
||||
<span class="ml-1">#{@event.code}</span>
|
||||
</button>
|
||||
|
||||
<div class="inline-flex justify-between items-center text-white text-sm">
|
||||
<img src="/images/icons/online-users.svg" class="h-6 mr-2" />
|
||||
<span id="counter" phx-update="ignore" phx-hook="UpdateAttendees">
|
||||
<%= @attendees_nb %>
|
||||
{@attendees_nb}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +162,7 @@
|
||||
|
||||
<%= if @post_count == 0 && @state.chat_enabled do %>
|
||||
<div class="text-2xl text-white block fixed bottom-32 left-0 w-full lg:w-1/3 lg:left-1/2 lg:transform lg:-translate-x-1/2 text-center opacity-30">
|
||||
<span><%= gettext("Be the first to react !") %></span>
|
||||
<span>{gettext("Be the first to react !")}</span>
|
||||
<img src="/images/icons/arrow-white.svg" class="h-24 rotate-180 ml-12 mt-8" />
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -205,7 +205,7 @@
|
||||
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M10 17h4"></path>
|
||||
</svg>
|
||||
<span><%= gettext("Anonymous") %></span>
|
||||
<span>{gettext("Anonymous")}</span>
|
||||
</button>
|
||||
<% else %>
|
||||
<button class="w-full bg-gray-900 opacity-50 text-left text-white px-3 py-2 rounded-md flex space-x-2 items-center cursor-default">
|
||||
@@ -226,7 +226,7 @@
|
||||
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M10 17h4"></path>
|
||||
</svg>
|
||||
<span><%= gettext("Anonymous") %> (<%= gettext("disabled") %>)</span>
|
||||
<span>{gettext("Anonymous")} ({gettext("disabled")})</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
@@ -246,13 +246,13 @@
|
||||
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
|
||||
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
|
||||
</svg>
|
||||
<span><%= gettext("Use your name") %></span>
|
||||
<span>{gettext("Use your name")}</span>
|
||||
</button>
|
||||
<button
|
||||
phx-click={toggle_nickname_popup()}
|
||||
class="w-full text-left text-primary-500 text-sm px-3 py-0 rounded-md"
|
||||
>
|
||||
<%= gettext("Close") %>
|
||||
{gettext("Close")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -275,15 +275,15 @@
|
||||
backdrop-filter: blur(11.5px);
|
||||
-webkit-backdrop-filter: blur(11.5px);"
|
||||
>
|
||||
<%= text_input(f, :name,
|
||||
{text_input(f, :name,
|
||||
class:
|
||||
"bg-transparent outline-none w-full text-white h-10 placeholder-white resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
|
||||
"bg-transparent outline-hidden w-full text-white h-10 placeholder-white resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
|
||||
placeholder: gettext("Enter your name")
|
||||
) %>
|
||||
)}
|
||||
<p class="font-semibold text-sm">
|
||||
<%= error_tag(f, :name) %>
|
||||
{error_tag(f, :name)}
|
||||
</p>
|
||||
<%= submit(gettext("Join"), class: "absolute right-5 top-2 p-2 bg-white rounded-md") %>
|
||||
{submit(gettext("Join"), class: "absolute right-5 top-2 p-2 bg-white rounded-md")}
|
||||
</div>
|
||||
</.form>
|
||||
<% else %>
|
||||
@@ -326,9 +326,9 @@
|
||||
/>
|
||||
</svg>
|
||||
<%= if @nickname && @nickname == "" do %>
|
||||
<span><%= gettext("Anonymous") %></span>
|
||||
<span>{gettext("Anonymous")}</span>
|
||||
<% else %>
|
||||
<span><%= @nickname %></span>
|
||||
<span>{@nickname}</span>
|
||||
<% end %>
|
||||
</a>
|
||||
</div>
|
||||
@@ -338,15 +338,15 @@
|
||||
</button>
|
||||
|
||||
<div class="flex space-x-2 items-center">
|
||||
<%= textarea(f, :body,
|
||||
{textarea(f, :body,
|
||||
id: "postFormTA",
|
||||
class:
|
||||
"bg-transparent outline-none w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
|
||||
"bg-transparent outline-hidden w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto",
|
||||
placeholder: gettext("Ask, comment...")
|
||||
) %>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<%= submit("Save", phx_disable_with: "Saving...", id: "hiddenSubmit", class: "hidden") %>
|
||||
{submit("Save", phx_disable_with: "Saving...", id: "hiddenSubmit", class: "hidden")}
|
||||
</.form>
|
||||
<% end %>
|
||||
<% else %>
|
||||
@@ -361,8 +361,8 @@
|
||||
-webkit-backdrop-filter: blur(11.5px);"
|
||||
>
|
||||
<div class="flex space-x-2 items-center">
|
||||
<div class="opacity-50 bg-transparent outline-none w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto">
|
||||
<%= gettext("Messages deactivated") %>
|
||||
<div class="opacity-50 bg-transparent outline-hidden w-full text-white h-10 placeholder-white pt-3 resize-none pr-20 leading-4 overflow-y-hidden focus:overflow-y-auto">
|
||||
{gettext("Messages deactivated")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,7 +420,7 @@
|
||||
<div class="flex bg-black h-screen">
|
||||
<div class="flex items-center text-center lg:text-left justify-center lg:justify-start lg:px-12 w-full lg:w-1/2">
|
||||
<div class="w-full mx-12 lg:w-auto lg:mx-0">
|
||||
<h1 class="py-5 text-5xl font-semibold text-white md:text-6xl"><%= @event.name %></h1>
|
||||
<h1 class="py-5 text-5xl font-semibold text-white md:text-6xl">{@event.name}</h1>
|
||||
|
||||
<h2
|
||||
x-data={"{date: moment.utc('#{@event.started_at}').local().format('LLLL')}"}
|
||||
@@ -432,36 +432,36 @@
|
||||
<div class="text-white flex justify-between items-center mt-12">
|
||||
<div class="flex flex-col items-center mr-10">
|
||||
<span class="text-5xl font-bold">
|
||||
<%= if @remaining_days < 10, do: "0" %><%= @remaining_days %>
|
||||
{if @remaining_days < 10, do: "0"}{@remaining_days}
|
||||
</span>
|
||||
<span class="text-gray-400"><%= gettext("days") %></span>
|
||||
<span class="text-gray-400">{gettext("days")}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center mr-10">
|
||||
<span class="text-5xl font-bold">
|
||||
<%= if @remaining_hours < 10, do: "0" %><%= @remaining_hours %>
|
||||
{if @remaining_hours < 10, do: "0"}{@remaining_hours}
|
||||
</span>
|
||||
<span class="text-gray-400"><%= gettext("hours") %></span>
|
||||
<span class="text-gray-400">{gettext("hours")}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center mr-10">
|
||||
<span class="text-5xl font-bold">
|
||||
<%= if @remaining_minutes < 10, do: "0" %><%= @remaining_minutes %>
|
||||
{if @remaining_minutes < 10, do: "0"}{@remaining_minutes}
|
||||
</span>
|
||||
<span class="text-gray-400"><%= gettext("minutes") %></span>
|
||||
<span class="text-gray-400">{gettext("minutes")}</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-5xl font-bold">
|
||||
<%= if @remaining_seconds < 10, do: "0" %><%= @remaining_seconds %>
|
||||
{if @remaining_seconds < 10, do: "0"}{@remaining_seconds}
|
||||
</span>
|
||||
<span class="text-gray-400"><%= gettext("seconds") %></span>
|
||||
<span class="text-gray-400">{gettext("seconds")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden lg:block lg:w-1/2">
|
||||
<div class="h-full object-cover bg-gradient-animate">
|
||||
<div class="h-full bg-black text-white bg-opacity-50 text-center flex flex-col items-center justify-center">
|
||||
<div class="h-full bg-black/50 text-white text-center flex flex-col items-center justify-center">
|
||||
<span class="text-4xl font-semibold mb-10">
|
||||
<%= gettext("Scan to interact in real-time") %>
|
||||
{gettext("Scan to interact in real-time")}
|
||||
</span>
|
||||
<div
|
||||
phx-hook="QRCode"
|
||||
@@ -470,8 +470,8 @@
|
||||
class="rounded-lg mx-auto bg-black h-64 w-64 p-12 flex items-center justify-center mb-14"
|
||||
>
|
||||
</div>
|
||||
<span class="text-4xl font-semibold mb-10"><%= gettext("Or use the code:") %></span>
|
||||
<span class="text-5xl font-semibold mb-10">#<%= String.upcase(@event.code) %></span>
|
||||
<span class="text-4xl font-semibold mb-10">{gettext("Or use the code:")}</span>
|
||||
<span class="text-5xl font-semibold mb-10">#{String.upcase(@event.code)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
form={f}
|
||||
key={:title}
|
||||
name={gettext("Title of your form")}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
@@ -24,8 +22,6 @@
|
||||
<div class="flex items-center gap-x-2">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={i}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:name}
|
||||
name={ngettext("Field %{count}", "Field %{count}", i.index + 1)}
|
||||
autofocus="true"
|
||||
@@ -33,13 +29,12 @@
|
||||
/>
|
||||
<ClaperWeb.Component.Input.select
|
||||
form={i}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
array={[{:"#{gettext("Text")}", "text"}, {:"#{gettext("Email")}", "email"}]}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:type}
|
||||
name={gettext("Type")}
|
||||
required="true"
|
||||
/>
|
||||
<ClaperWeb.Component.Input.toggle form={i} key={:required} label={gettext("Required")} />
|
||||
</div>
|
||||
<%= if i.index >= 1 do %>
|
||||
<button
|
||||
@@ -92,15 +87,15 @@
|
||||
<button
|
||||
type="submit"
|
||||
phx_disable_with="Loading..."
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= case @live_action do
|
||||
{case @live_action do
|
||||
:new -> gettext("Create")
|
||||
:edit -> gettext("Save")
|
||||
end %>
|
||||
end}
|
||||
</button>
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_target: @myself,
|
||||
@@ -112,8 +107,8 @@
|
||||
)
|
||||
],
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
)}
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
@@ -15,20 +15,19 @@ defmodule ClaperWeb.ModalComponent do
|
||||
phx-window-keydown={hide_modal()}
|
||||
phx-key="escape"
|
||||
phx-target={@myself}
|
||||
phx-page-loading
|
||||
>
|
||||
<div class="flex items-center justify-center pt-4 px-4 pb-20 text-center sm:block sm:p-4">
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
class="fixed inset-0 bg-gray-500/75 transition-opacity -z-10"
|
||||
phx-click={hide_modal()}
|
||||
phx-target={@myself}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="inline-block align-middle bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all">
|
||||
<div class="inline-block align-middle bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all relative">
|
||||
<div
|
||||
class="text-2xl text-gray-400 absolute right-5 top-3"
|
||||
class="text-2xl text-gray-400 absolute right-5 top-3 cursor-pointer"
|
||||
phx-click={hide_modal()}
|
||||
phx-target={@myself}
|
||||
>
|
||||
@@ -36,16 +35,16 @@ defmodule ClaperWeb.ModalComponent do
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
<%= @title %>
|
||||
{@title}
|
||||
</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>
|
||||
<%= @description %>
|
||||
{@description}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
<%= render_slot(@inner_block) %>
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +63,6 @@ defmodule ClaperWeb.ModalComponent do
|
||||
def hide_modal(js \\ %JS{}) do
|
||||
js
|
||||
|> JS.hide(to: "#modal", transition: "animate__animated animate__fadeOut", time: 300)
|
||||
|> JS.push("hide", target: "#modal")
|
||||
|> JS.push("hide", target: "#modal", page_loading: true)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
form={f}
|
||||
key={:title}
|
||||
name={gettext("Title of your poll")}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
@@ -24,8 +22,6 @@
|
||||
<div class="flex-1">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={i}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:content}
|
||||
name={ngettext("Choice %{count}", "Choice %{count}", i.index + 1)}
|
||||
autofocus="true"
|
||||
@@ -79,36 +75,36 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
|
||||
<p class="text-gray-700 text-xl font-semibold">{gettext("Options")}</p>
|
||||
|
||||
<div class="flex gap-x-2 mb-5 mt-3">
|
||||
<%= checkbox(f, :show_results, class: "h-4 w-4") %>
|
||||
<%= label(
|
||||
{checkbox(f, :show_results, class: "h-4 w-4")}
|
||||
{label(
|
||||
f,
|
||||
:show_results,
|
||||
gettext("Attendees can see the results on their device"),
|
||||
class: "text-sm font-medium"
|
||||
) %>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-x-2 mb-5">
|
||||
<%= checkbox(f, :multiple, class: "h-4 w-4") %>
|
||||
<%= label(f, :multiple, gettext("Multiple answers"), class: "text-sm font-medium") %>
|
||||
{checkbox(f, :multiple, class: "h-4 w-4")}
|
||||
{label(f, :multiple, gettext("Multiple answers"), class: "text-sm font-medium")}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
phx_disable_with="Loading..."
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
>
|
||||
<%= case @live_action do
|
||||
{case @live_action do
|
||||
:new -> gettext("Create")
|
||||
:edit -> gettext("Save")
|
||||
end %>
|
||||
end}
|
||||
</button>
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_target: @myself,
|
||||
@@ -120,8 +116,8 @@
|
||||
)
|
||||
],
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
)}
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
form={f}
|
||||
key={:title}
|
||||
name={gettext("Title")}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
@@ -33,7 +31,7 @@
|
||||
if(i == 0, do: "rounded-tl-md")
|
||||
]}
|
||||
>
|
||||
<%= i + 1 %>
|
||||
{i + 1}
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length() < 10 do %>
|
||||
@@ -43,7 +41,7 @@
|
||||
class="text-xs px-3"
|
||||
phx-target={@myself}
|
||||
>
|
||||
+ <%= gettext("Add Question") %>
|
||||
+ {gettext("Add Question")}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -56,8 +54,6 @@
|
||||
<div class="flex gap-x-3 mt-3 items-center justify-start">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={q}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:content}
|
||||
name={gettext("Your question")}
|
||||
autofocus="true"
|
||||
@@ -67,7 +63,7 @@
|
||||
|
||||
<%= if Keyword.has_key?(q.errors, :quiz_question_opts) do %>
|
||||
<p class="text-supporting-red-500 text-sm my-2">
|
||||
<%= elem(Keyword.get(q.errors, :quiz_question_opts), 0) %>
|
||||
{elem(Keyword.get(q.errors, :quiz_question_opts), 0)}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@@ -78,8 +74,6 @@
|
||||
<div class="flex-1">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={o}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:content}
|
||||
name={gettext("Answer %{index}", index: o.index + 1)}
|
||||
required="true"
|
||||
@@ -87,7 +81,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<%= label(class: "mt-6 cursor-pointer flex items-center text-white rounded-md px-2.5 py-2.5 h-full #{if (o.source.changes[:is_correct] != nil && o.source.changes[:is_correct]) || (!Map.has_key?(o.source.changes, :is_correct) && o.source.data.is_correct), do: "bg-green-500", else: "bg-red-500"}") do %>
|
||||
<%= checkbox(o, :is_correct, class: "hidden") %>
|
||||
{checkbox(o, :is_correct, class: "hidden")}
|
||||
<%= if (o.source.changes[:is_correct] != nil && o.source.changes[:is_correct]) || (!Map.has_key?(o.source.changes, :is_correct) && o.source.data.is_correct) do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -126,7 +120,7 @@
|
||||
<%= if o.index > 1 do %>
|
||||
<label>
|
||||
<div class="cursor-pointer mt-1 text-xs font-semibold text-red-600">
|
||||
<%= gettext("Delete") %>
|
||||
{gettext("Delete")}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -149,13 +143,13 @@
|
||||
phx-target={@myself}
|
||||
class="mt-5 text-xs text-gray-700"
|
||||
>
|
||||
+ <%= gettext("Add answer") %>
|
||||
+ {gettext("Add answer")}
|
||||
</button>
|
||||
|
||||
<%= if Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length() > 1 do %>
|
||||
<label phx-click="remove_quiz_question" phx-target={@myself}>
|
||||
<div class="cursor-pointer mt-4 w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500">
|
||||
<%= gettext("Remove question") %>
|
||||
<div class="cursor-pointer mt-4 w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500">
|
||||
{gettext("Remove question")}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -169,16 +163,16 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-700 text-xl font-semibold"><%= gettext("Options") %></p>
|
||||
<p class="text-gray-700 text-xl font-semibold">{gettext("Options")}</p>
|
||||
|
||||
<div class="flex gap-x-2 mb-5 mt-3">
|
||||
<%= checkbox(f, :allow_anonymous, class: "h-4 w-4") %>
|
||||
<%= label(
|
||||
{checkbox(f, :allow_anonymous, class: "h-4 w-4")}
|
||||
{label(
|
||||
f,
|
||||
:allow_anonymous,
|
||||
gettext("Allow anonymous submissions"),
|
||||
class: "text-sm font-medium"
|
||||
) %>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -187,16 +181,16 @@
|
||||
type="submit"
|
||||
phx_disable_with="Loading..."
|
||||
disabled={!@changeset.valid?}
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-primary-500 to-secondary-500 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<%= case @live_action do
|
||||
{case @live_action do
|
||||
:new -> gettext("Create")
|
||||
:edit -> gettext("Save")
|
||||
end %>
|
||||
end}
|
||||
</button>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link(gettext("Delete"),
|
||||
{link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_target: @myself,
|
||||
@@ -208,14 +202,14 @@
|
||||
)
|
||||
],
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
)}
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link to: ~p"/export/quizzes/#{@quiz.id}/qti", class: "text-xs text-primary-500 font-medium flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<%= gettext("Export to QTI (XML)") %>
|
||||
{gettext("Export to QTI (XML)")}
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="border-b border-gray-200 py-4 flex flex-col sm:flex-row sm:items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
|
||||
<%= @page_title %>
|
||||
{@page_title}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex mt-4 space-x-5 sm:mt-0 hidden">
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<div class="mt-12 mb-3">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
<%= gettext("Event") %>: <%= @event.name %> (#<%= @event.code %>)
|
||||
{gettext("Event")}: {@event.name} (#{@event.code})
|
||||
</h3>
|
||||
|
||||
<dl class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -35,14 +35,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
<%= gettext("Audience peak") %>
|
||||
{gettext("Audience peak")}
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
<%= @event.audience_peak %>
|
||||
{@event.audience_peak}
|
||||
<span class="text-xs text-gray-500 font-normal">
|
||||
<%= ngettext("attendee", "attendees", @event.audience_peak) %>
|
||||
{ngettext("attendee", "attendees", @event.audience_peak)}
|
||||
</span>
|
||||
</p>
|
||||
</dd>
|
||||
@@ -67,14 +67,14 @@
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
<%= gettext("Unique attendees") %>
|
||||
{gettext("Unique attendees")}
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
<%= @distinct_attendee_count %>
|
||||
{@distinct_attendee_count}
|
||||
<span class="text-xs text-gray-500 font-normal">
|
||||
<%= ngettext("attendee", "attendees", @distinct_attendee_count) %>
|
||||
{ngettext("attendee", "attendees", @distinct_attendee_count)}
|
||||
</span>
|
||||
</p>
|
||||
</dd>
|
||||
@@ -99,18 +99,18 @@
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
<%= gettext("Messages") %>
|
||||
{gettext("Messages")}
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
<%= length(@posts) %>
|
||||
{length(@posts)}
|
||||
<span class="text-xs text-gray-500 font-normal">
|
||||
<%= ngettext(
|
||||
{ngettext(
|
||||
"from %{count} people",
|
||||
"from %{count} peoples",
|
||||
@distinct_poster_count
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</dd>
|
||||
@@ -136,11 +136,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium truncate text-gray-500">
|
||||
<%= gettext("Engagement rate") %>
|
||||
{gettext("Engagement rate")}
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline flex items-center">
|
||||
<p class="text-2xl font-semibold text-gray-900"><%= @engagement_rate %>%</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{@engagement_rate}%</p>
|
||||
<a
|
||||
href="https://docs.claper.co/usage/reports.html#metrics"
|
||||
target="_blank"
|
||||
@@ -167,7 +167,7 @@
|
||||
|
||||
<div class="pt-5 pb-5">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
<%= gettext("Interactions") %>
|
||||
{gettext("Interactions")}
|
||||
</h3>
|
||||
|
||||
<div class="border-b border-gray-200">
|
||||
@@ -177,35 +177,35 @@
|
||||
phx-value-tab="messages"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :messages, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
|
||||
>
|
||||
<%= gettext("Messages") %>
|
||||
{gettext("Messages")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="polls"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :polls, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
|
||||
>
|
||||
<%= gettext("Polls") %>
|
||||
{gettext("Polls")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="forms"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :forms, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
|
||||
>
|
||||
<%= gettext("Forms") %>
|
||||
{gettext("Forms")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="web_content"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :web_content, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
|
||||
>
|
||||
<%= gettext("Web Content") %>
|
||||
{gettext("Web Content")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="quizzes"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :quizzes, do: "border-primary-500 text-primary-600", else: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"}"}
|
||||
>
|
||||
<%= gettext("Quizzes") %>
|
||||
{gettext("Quizzes")}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@
|
||||
<% total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() %>
|
||||
<div class="bg-gray-900 w-full p-6 my-5 text-black shadow-lg rounded-md">
|
||||
<div class="w-full h-full flex items-center justify-between mb-4">
|
||||
<p class="text-white text-xl font-semibold"><%= poll.title %></p>
|
||||
<p class="text-white text-xl font-semibold">{poll.title}</p>
|
||||
<%= link to: ~p"/export/polls/#{poll.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -233,7 +233,7 @@
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<span>{gettext("Export")} (CSV)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
@@ -252,14 +252,14 @@
|
||||
>
|
||||
<div
|
||||
style={"width: #{percentage}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if percentage == "100", do: "rounded-r-lg"}"}
|
||||
class={"bg-linear-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if percentage == "100", do: "rounded-r-lg"}"}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
<span class="text-sm z-10">
|
||||
<%= percentage %>% (<%= opt.vote_count %>)
|
||||
{percentage}% ({opt.vote_count})
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -269,7 +269,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500"><%= gettext("No poll has been created") %></p>
|
||||
<p class="italic text-gray-500">{gettext("No poll has been created")}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% :forms -> %>
|
||||
@@ -278,7 +278,7 @@
|
||||
<%= for form <- @event.presentation_file.forms do %>
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<span class="text-xl font-semibold text-gray-900">
|
||||
<%= form.title %>
|
||||
{form.title}
|
||||
</span>
|
||||
|
||||
<%= if length(form.form_submits) > 0 do %>
|
||||
@@ -295,14 +295,14 @@
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<span>{gettext("Export")} (CSV)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if length(form.form_submits) == 0 do %>
|
||||
<p class="italic text-gray-500">
|
||||
<%= gettext("No form submission has been sent") %>
|
||||
{gettext("No form submission has been sent")}
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
|
||||
<div>
|
||||
<%= for res <- fs.response do %>
|
||||
<p><strong><%= elem(res, 0) %>:</strong> <%= elem(res, 1) %></p>
|
||||
<p><strong>{elem(res, 0)}:</strong> {elem(res, 1)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,7 +335,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500"><%= gettext("No form has been created") %></p>
|
||||
<p class="italic text-gray-500">{gettext("No form has been created")}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% :web_content -> %>
|
||||
@@ -343,7 +343,7 @@
|
||||
<%= if length(@event.presentation_file.embeds) > 0 do %>
|
||||
<%= for embed <- @event.presentation_file.embeds do %>
|
||||
<span class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<%= embed.title %>
|
||||
{embed.title}
|
||||
</span>
|
||||
<div class="text-black break-all mt-4 mb-10">
|
||||
<.live_component
|
||||
@@ -356,7 +356,7 @@
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500">
|
||||
<%= gettext("No web content has been created") %>
|
||||
{gettext("No web content has been created")}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -377,13 +377,13 @@
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<span>{gettext("Export")} (CSV)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if length(@posts) == 0 do %>
|
||||
<p class="italic text-gray-500"><%= gettext("No messages has been sent") %></p>
|
||||
<p class="italic text-gray-500">{gettext("No messages has been sent")}</p>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
@@ -405,7 +405,7 @@
|
||||
<div class="bg-gray-900 w-full p-4 text-black shadow-lg rounded-md mb-10">
|
||||
<div class="mb-4">
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= quiz.title %></p>
|
||||
<p class="text-white text-xl font-semibold mb-2">{quiz.title}</p>
|
||||
<%= link to: ~p"/export/quizzes/#{quiz.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -419,22 +419,22 @@
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<span>{gettext("Export")} (CSV)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">
|
||||
<%= gettext("Average score") %>:
|
||||
{gettext("Average score")}:
|
||||
<span class="font-semibold">
|
||||
<%= Claper.Quizzes.calculate_average_score(quiz.id) %>/<%= length(
|
||||
{Claper.Quizzes.calculate_average_score(quiz.id)}/{length(
|
||||
quiz.quiz_questions
|
||||
) %>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="text-gray-400 text-sm">
|
||||
<%= gettext("Total submissions") %>:
|
||||
{gettext("Total submissions")}:
|
||||
<span class="font-semibold">
|
||||
<%= Claper.Quizzes.get_submission_count(quiz.id) %>
|
||||
{Claper.Quizzes.get_submission_count(quiz.id)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -443,7 +443,7 @@
|
||||
<%= for {question, _idx} <- Enum.with_index(quiz.quiz_questions) do %>
|
||||
<div class="border-t border-gray-700 pt-4 mt-4 first:border-t-0 first:pt-0 first:mt-0">
|
||||
<p class="text-white text-lg font-medium mb-3">
|
||||
<%= question.content %>
|
||||
{question.content}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<%= for opt <- question.quiz_question_opts do %>
|
||||
@@ -457,10 +457,10 @@
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
<span class="flex-1 pr-2">{opt.content}</span>
|
||||
</div>
|
||||
<span class="text-sm">
|
||||
<%= opt.percentage %>% (<%= opt.response_count %>)
|
||||
{opt.percentage}% ({opt.response_count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,7 +473,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500"><%= gettext("No quiz has been created") %></p>
|
||||
<p class="italic text-gray-500">{gettext("No quiz has been created")}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="border-b border-gray-200 py-4 sm:flex sm:items-center sm:justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
|
||||
<%= gettext("My account") %>
|
||||
{gettext("My account")}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mt-4 flex sm:mt-0 sm:ml-4"></div>
|
||||
@@ -26,7 +26,7 @@
|
||||
id="update_email"
|
||||
class="mt-5 md:flex md:items-end"
|
||||
>
|
||||
<%= hidden_input(f, :action, name: "action", value: "update_email") %>
|
||||
{hidden_input(f, :action, name: "action", value: "update_email")}
|
||||
|
||||
<ClaperWeb.Component.Input.email
|
||||
form={f}
|
||||
@@ -35,11 +35,11 @@
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<%= submit(gettext("Save"),
|
||||
{submit(gettext("Save"),
|
||||
phx_disable_with: "Saving...",
|
||||
class:
|
||||
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
|
||||
) %>
|
||||
)}
|
||||
</.form>
|
||||
</div>
|
||||
</.live_component>
|
||||
@@ -62,7 +62,7 @@
|
||||
id="update_password"
|
||||
class="mt-5 md:flex md:items-end gap-x-2"
|
||||
>
|
||||
<%= hidden_input(f, :action, name: "action", value: "update_password") %>
|
||||
{hidden_input(f, :action, name: "action", value: "update_password")}
|
||||
|
||||
<ClaperWeb.Component.Input.password
|
||||
form={f}
|
||||
@@ -77,11 +77,11 @@
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<%= submit(gettext("Save"),
|
||||
{submit(gettext("Save"),
|
||||
phx_disable_with: "Saving...",
|
||||
class:
|
||||
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
|
||||
) %>
|
||||
)}
|
||||
</.form>
|
||||
</div>
|
||||
</.live_component>
|
||||
@@ -104,7 +104,7 @@
|
||||
id="set_password"
|
||||
class="mt-5 md:flex md:items-end gap-x-2"
|
||||
>
|
||||
<%= hidden_input(f, :action, name: "action", value: "set_password") %>
|
||||
{hidden_input(f, :action, name: "action", value: "set_password")}
|
||||
|
||||
<ClaperWeb.Component.Input.password
|
||||
form={f}
|
||||
@@ -120,67 +120,67 @@
|
||||
required="true"
|
||||
/>
|
||||
|
||||
<%= submit(gettext("Save"),
|
||||
{submit(gettext("Save"),
|
||||
phx_disable_with: "Saving...",
|
||||
class:
|
||||
"mt-2 w-full h-14 inline-flex transition-all items-center justify-center px-4 py-2 shadow-sm font-medium rounded-md text-white bg-black hover:bg-primary-500 md:mt-0 md:ml-3 md:w-auto md:text-sm"
|
||||
) %>
|
||||
)}
|
||||
</.form>
|
||||
</div>
|
||||
</.live_component>
|
||||
<% end %>
|
||||
|
||||
<div class="shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="overflow-hidden sm:rounded-lg">
|
||||
<div class="py-5">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
<%= gettext("Personal informations") %>
|
||||
{gettext("Personal informations")}
|
||||
</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<%= gettext("Your personal informations to access your account") %>
|
||||
{gettext("Your personal informations to access your account")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-2 sm:gap-4">
|
||||
<dt class="text-sm font-medium text-gray-500">
|
||||
<%= gettext("Email address") %>
|
||||
{gettext("Email address")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<span class="flex-grow"><%= @current_user.email %></span>
|
||||
<span :if={!@is_external_user} class="ml-4 flex-shrink-0">
|
||||
<span class="grow">{@current_user.email}</span>
|
||||
<span :if={!@is_external_user} class="ml-4 shrink-0">
|
||||
<.link
|
||||
patch={~p"/users/settings/edit/email"}
|
||||
class="rounded-md font-medium text-purple-600 hover:text-purple-500"
|
||||
>
|
||||
<%= gettext("Change") %>
|
||||
{gettext("Change")}
|
||||
</.link>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt :if={!@is_external_user} class="text-sm font-medium text-gray-500">
|
||||
<%= gettext("Password") %>
|
||||
{gettext("Password")}
|
||||
</dt>
|
||||
<dd :if={!@is_external_user} class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<span class="flex-grow">********</span>
|
||||
<span class="ml-4 flex-shrink-0">
|
||||
<span class="grow">********</span>
|
||||
<span class="ml-4 shrink-0">
|
||||
<.link
|
||||
patch={~p"/users/settings/edit/password"}
|
||||
class="rounded-md font-medium text-purple-600 hover:text-purple-500"
|
||||
>
|
||||
<%= gettext("Change") %>
|
||||
{gettext("Change")}
|
||||
</.link>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt :if={@is_external_user} class="text-sm font-medium text-gray-500">
|
||||
<%= gettext("Accounts linked") %>
|
||||
{gettext("Accounts linked")}
|
||||
</dt>
|
||||
<dd class="text-sm text-gray-900 sm:col-span-2">
|
||||
<%= for account <- @oidc_accounts do %>
|
||||
<div class="text-sm text-gray-900 bg-white rounded-md py-2 px-4 shadow-base flex gap-x-2 items-center justify-start mt-2 sm:mt-0 mb-2">
|
||||
<img src="/images/icons/openid.png" class="w-5" />
|
||||
<span class="flex-grow flex items-center gap-x-2">
|
||||
<span><%= account.provider %></span>
|
||||
<span class="grow flex items-center gap-x-2">
|
||||
<span>{account.provider}</span>
|
||||
<div
|
||||
:if={account.organization}
|
||||
class="text-gray-500 text-xs flex items-center gap-x-1"
|
||||
@@ -198,7 +198,7 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= account.organization %></span>
|
||||
<span>{account.organization}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span :if={@allow_unlink_external_provider}>
|
||||
@@ -208,7 +208,7 @@
|
||||
data-confirm={gettext("Are you sure you want to unlink this account?")}
|
||||
class="font-medium text-red-600 hover:text-red-500"
|
||||
>
|
||||
<%= gettext("Unlink") %>
|
||||
{gettext("Unlink")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -216,17 +216,17 @@
|
||||
<%= for account <- @lti_accounts do %>
|
||||
<div class="text-sm text-gray-900 bg-white rounded-md py-2 px-4 shadow-base flex gap-x-2 items-center justify-start mb-2">
|
||||
<img src="/images/icons/lms.png" class="w-8" />
|
||||
<span class="flex-grow">
|
||||
LMS <span class="text-gray-500 text-xs">#<%= account.registration_id %></span>
|
||||
<span class="grow">
|
||||
LMS <span class="text-gray-500 text-xs">#{account.registration_id}</span>
|
||||
</span>
|
||||
<span :if={@allow_unlink_external_provider} class="ml-4 flex-shrink-0">
|
||||
<span :if={@allow_unlink_external_provider} class="ml-4 shrink-0">
|
||||
<button
|
||||
phx-click="unlink"
|
||||
phx-value-registration_id={account.registration_id}
|
||||
data-confirm={gettext("Are you sure you want to unlink this account?")}
|
||||
class="font-medium text-red-600 hover:text-red-500"
|
||||
>
|
||||
<%= gettext("Unlink") %>
|
||||
{gettext("Unlink")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -238,29 +238,42 @@
|
||||
<div>
|
||||
<div class="py-5">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
<%= gettext("Preferences") %>
|
||||
{gettext("Preferences")}
|
||||
</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<%= gettext("Customize your account") %>
|
||||
{gettext("Customize your account")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="mt-5">
|
||||
<.form :let={f} for={@preferences_changeset} phx-change="save">
|
||||
<%= hidden_input(f, :action, name: "action", value: "update_preferences") %>
|
||||
{hidden_input(f, :action, name: "action", value: "update_preferences")}
|
||||
<ClaperWeb.Component.Input.select
|
||||
form={f}
|
||||
fieldClass="!w-auto"
|
||||
fieldClass="w-auto! bg-white"
|
||||
labelClass="text-sm font-medium text-gray-500"
|
||||
array={[
|
||||
{"Deutsch", "de"},
|
||||
{"English", "en"},
|
||||
{"Español", "es"},
|
||||
{"Français", "fr"},
|
||||
{"Italiano", "it"},
|
||||
{"Nederlands", "nl"}
|
||||
]}
|
||||
array={
|
||||
[
|
||||
{"Deutsch", "de"},
|
||||
{"English", "en"},
|
||||
{"Español", "es"},
|
||||
{"Français", "fr"},
|
||||
{"Hungarian", "hu"},
|
||||
{"Italiano", "it"},
|
||||
{"Latvian", "lv"},
|
||||
{"Nederlands", "nl"}
|
||||
]
|
||||
|> Enum.filter(fn {_name, code} ->
|
||||
code in Application.get_env(:claper, :languages, [
|
||||
"en",
|
||||
"fr",
|
||||
"es",
|
||||
"it",
|
||||
"de"
|
||||
])
|
||||
end)
|
||||
}
|
||||
key={:locale}
|
||||
name={gettext("Language")}
|
||||
/>
|
||||
@@ -272,23 +285,23 @@
|
||||
<div :if={!@is_external_user}>
|
||||
<div class="py-5">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
<%= gettext("Danger zone") %>
|
||||
{gettext("Danger zone")}
|
||||
</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
<%= gettext("Be careful, these actions are irreversible") %>
|
||||
{gettext("Be careful, these actions are irreversible")}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 py-5 sm:p-0">
|
||||
<dl class="sm:divide-y sm:divide-gray-200">
|
||||
<div class="my-5">
|
||||
<%= link(gettext("Delete account"),
|
||||
{link(gettext("Delete account"),
|
||||
to: ~p"/users/register/delete",
|
||||
method: :delete,
|
||||
"data-confirm":
|
||||
gettext("All your events and files will be permanently deleted, are you sure?"),
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-hidden focus:shadow-outline bg-linear-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-[200%_200%] bg-position-[0%_0%] hover:bg-position-[100%_100%] transition-all duration-500"
|
||||
)}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user