Introduce notifications app and integrate emails
Add a new notifications app (models, serializers, views, admin, tasks, consumers, routing, urls, tests) with Notification.notify for in-app websocket pushes and optional email delivery. Centralize email sending in notifications.tasks.send_email_with_context and re-export it from account.tasks for compatibility. Update account and commerce and advertisement tasks to use Notification.notify/_notify_order and the new email helper; adjust order/notification tasks to consolidate logic. Wire notifications into ASGI routing, settings and URL conf. Misc: handle OSError when importing weasyprint, add tmp/ to .gitignore, add local .claude PowerShell checks, add social.blog skeleton, and remove legacy ews-component test files.
This commit is contained in:
@@ -16,7 +16,9 @@
|
|||||||
"Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaExchangeAlt' in r, 'FaSyncAlt' in r, 'FaCloudUploadAlt' in r, 'FaRandom' in r, 'FaDatabase' in r\\);\")",
|
"Bash(node -e \"const r = require\\('react-icons/fa'\\); console.log\\('FaExchangeAlt' in r, 'FaSyncAlt' in r, 'FaCloudUploadAlt' in r, 'FaRandom' in r, 'FaDatabase' in r\\);\")",
|
||||||
"Bash(node -e \"const r = require\\('react-icons/gi'\\); console.log\\('GiStabilizer' in r, 'GiDroneBoy' in r, 'GiCctvCamera' in r, 'GiFilmProjector' in r, 'GiGyroscope' in r\\);\")",
|
"Bash(node -e \"const r = require\\('react-icons/gi'\\); console.log\\('GiStabilizer' in r, 'GiDroneBoy' in r, 'GiCctvCamera' in r, 'GiFilmProjector' in r, 'GiGyroscope' in r\\);\")",
|
||||||
"Bash(node -e \"const r = require\\('react-icons/si'\\); const celery = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('celery'\\) || k.toLowerCase\\(\\).includes\\('worker'\\) || k.toLowerCase\\(\\).includes\\('task'\\)\\).slice\\(0,10\\); console.log\\(celery\\);\")",
|
"Bash(node -e \"const r = require\\('react-icons/si'\\); const celery = Object.keys\\(r\\).filter\\(k => k.toLowerCase\\(\\).includes\\('celery'\\) || k.toLowerCase\\(\\).includes\\('worker'\\) || k.toLowerCase\\(\\).includes\\('task'\\)\\).slice\\(0,10\\); console.log\\(celery\\);\")",
|
||||||
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\backend\\\\\" -Directory | Select-Object -ExpandProperty Name)"
|
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\bruno\\\\Documents\\\\GitHub\\\\vontor-cz\\\\backend\\\\\" -Directory | Select-Object -ExpandProperty Name)",
|
||||||
|
"PowerShell(Get-Command python)",
|
||||||
|
"PowerShell(python -c \"import django; print\\(django.__version__\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -88,5 +88,7 @@ venv
|
|||||||
|
|
||||||
certs
|
certs
|
||||||
|
|
||||||
|
tmp/
|
||||||
|
|
||||||
# large video assets — host externally (S3)
|
# large video assets — host externally (S3)
|
||||||
frontend/public/portfolio/*.mp4
|
frontend/public/portfolio/*.mp4
|
||||||
@@ -1,121 +1,80 @@
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from django.core.mail import send_mail
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.http import urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_encode
|
||||||
from django.utils.encoding import force_bytes
|
from django.utils.encoding import force_bytes
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from .tokens import *
|
from .tokens import *
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# Re-exported so existing imports from account.tasks still work
|
||||||
|
from notifications.tasks import send_email_with_context # noqa: F401
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
|
|
||||||
"""
|
|
||||||
Send emails rendering a single HTML template.
|
|
||||||
- `template_name` is a simple base name without extension, e.g. "email/test".
|
|
||||||
- Renders only HTML (".html"), no ".txt" support.
|
|
||||||
- Converts `user` in context to a plain dict to avoid passing models to templates.
|
|
||||||
"""
|
|
||||||
if isinstance(recipients, str):
|
|
||||||
recipients = [recipients]
|
|
||||||
|
|
||||||
html_message = None
|
|
||||||
if template_path:
|
|
||||||
ctx = dict(context or {})
|
|
||||||
# Render base layout and include the provided template as the main content.
|
|
||||||
# The included template receives the same context as the base.
|
|
||||||
html_message = render_to_string(
|
|
||||||
"email/components/base.html",
|
|
||||||
{"content_template": template_path, **ctx},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message=message or "",
|
|
||||||
from_email=None,
|
|
||||||
recipient_list=recipients,
|
|
||||||
fail_silently=False,
|
|
||||||
html_message=html_message,
|
|
||||||
)
|
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend' and message:
|
|
||||||
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"E-mail se neodeslal: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
#----------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# This function sends an email to the user for email verification after registration.
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_email_verification_task(user_id):
|
def send_email_verification_task(user_id):
|
||||||
|
from notifications.models import Notification
|
||||||
try:
|
try:
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
except CustomUser.DoesNotExist:
|
except CustomUser.DoesNotExist:
|
||||||
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
logger.info("send_email_verification_task: invalid user_id %s", user_id)
|
||||||
return 0
|
return
|
||||||
|
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
# {changed} generate and store a per-user token
|
|
||||||
token = user.generate_email_verification_token()
|
token = user.generate_email_verification_token()
|
||||||
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
|
|
||||||
context = {
|
Notification.notify(
|
||||||
"user": user,
|
user=user,
|
||||||
"action_url": verify_url,
|
title="Ověření e‑mailu",
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
text="Prosím ověřte svou e-mailovou adresu kliknutím na odkaz v e-mailu.",
|
||||||
"cta_label": "Ověřit e‑mail",
|
action_url=verify_url,
|
||||||
}
|
|
||||||
|
|
||||||
send_email_with_context(
|
|
||||||
recipients=user.email,
|
|
||||||
subject="Ověření e‑mailu",
|
|
||||||
template_path="email/email_verification.html",
|
template_path="email/email_verification.html",
|
||||||
context=context,
|
email_context={
|
||||||
|
"action_url": verify_url,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Ověřit e‑mail",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_email_test_task(email):
|
def send_email_test_task(email):
|
||||||
context = {
|
|
||||||
"action_url": settings.FRONTEND_URL,
|
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
|
||||||
"cta_label": "Otevřít aplikaci",
|
|
||||||
}
|
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=email,
|
recipients=email,
|
||||||
subject="Testovací e‑mail",
|
subject="Testovací e‑mail",
|
||||||
template_path="email/test.html",
|
template_path="email/test.html",
|
||||||
context=context,
|
context={
|
||||||
|
"action_url": settings.FRONTEND_URL,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Otevřít aplikaci",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_password_reset_email_task(user_id):
|
def send_password_reset_email_task(user_id):
|
||||||
|
from notifications.models import Notification
|
||||||
try:
|
try:
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
except CustomUser.DoesNotExist:
|
except CustomUser.DoesNotExist:
|
||||||
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
|
logger.info("send_password_reset_email_task: invalid user_id %s", user_id)
|
||||||
return 0
|
return
|
||||||
|
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
token = password_reset_token.make_token(user)
|
token = password_reset_token.make_token(user)
|
||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||||
|
|
||||||
context = {
|
Notification.notify(
|
||||||
"user": user,
|
user=user,
|
||||||
"action_url": reset_url,
|
title="Obnova hesla",
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
text="Byl vyžádán reset vašeho hesla. Klikněte na odkaz pro nastavení nového hesla.",
|
||||||
"cta_label": "Obnovit heslo",
|
action_url=reset_url,
|
||||||
}
|
|
||||||
|
|
||||||
send_email_with_context(
|
|
||||||
recipients=user.email,
|
|
||||||
subject="Obnova hesla",
|
|
||||||
template_path="email/password_reset.html",
|
template_path="email/password_reset.html",
|
||||||
context=context,
|
email_context={
|
||||||
|
"action_url": reset_url,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Obnovit heslo",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
@@ -237,7 +237,9 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
user = getattr(self.request, 'user', None)
|
user = getattr(self.request, 'user', None)
|
||||||
|
|
||||||
is_admin = user and (getattr(user, 'role', None) == 'admin' or getattr(user, 'is_superuser', False))
|
is_admin = user and (getattr(user, 'role', None) == 'admin' or getattr(user, 'is_superuser', False))
|
||||||
|
|
||||||
if self.action in ['retrieve', 'list'] and not is_admin:
|
if self.action in ['retrieve', 'list'] and not is_admin:
|
||||||
return PublicUserSerializer
|
return PublicUserSerializer
|
||||||
return CustomUserSerializer
|
return CustomUserSerializer
|
||||||
@@ -277,11 +279,15 @@ class ChangePasswordView(APIView):
|
|||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = ChangePasswordSerializer(data=request.data)
|
serializer = ChangePasswordSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
if not user.check_password(serializer.validated_data['current_password']):
|
if not user.check_password(serializer.validated_data['current_password']):
|
||||||
return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"current_password": "Nesprávné heslo."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
user.set_password(serializer.validated_data['new_password'])
|
user.set_password(serializer.validated_data['new_password'])
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,35 @@
|
|||||||
from venv import create
|
from notifications.tasks import send_email_with_context
|
||||||
from account.tasks import send_email_with_context
|
|
||||||
from configuration.models import SiteConfiguration
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery.schedules import crontab
|
|
||||||
|
|
||||||
from commerce.models import Product
|
from commerce.models import Product
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_contact_me_email_task(client_email, message_content):
|
def send_contact_me_email_task(client_email, message_content):
|
||||||
context = {
|
|
||||||
"client_email": client_email,
|
|
||||||
"message_content": message_content
|
|
||||||
}
|
|
||||||
config_email = SiteConfiguration.get_solo().contact_email
|
config_email = SiteConfiguration.get_solo().contact_email
|
||||||
recipient = config_email if config_email else "brunovontor@gmail.com"
|
recipient = config_email if config_email else "brunovontor@gmail.com"
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=recipient,
|
recipients=recipient,
|
||||||
subject="Poptávka z kontaktního formuláře!!!",
|
subject="Poptávka z kontaktního formuláře!!!",
|
||||||
template_path="email/contact_me.html",
|
template_path="email/contact_me.html",
|
||||||
context=context,
|
context={
|
||||||
|
"client_email": client_email,
|
||||||
|
"message_content": message_content,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_newly_added_items_to_store_email_task_last_week():
|
def send_newly_added_items_to_store_email_task_last_week():
|
||||||
last_week_date = datetime.datetime.now() - datetime.timedelta(days=7)
|
last_week_date = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||||
|
|
||||||
"""
|
|
||||||
__lte -> Less than or equal
|
|
||||||
__gte -> Greater than or equal
|
|
||||||
__lt -> Less than
|
|
||||||
__gt -> Greater than
|
|
||||||
"""
|
|
||||||
|
|
||||||
products_of_week = Product.objects.filter(
|
products_of_week = Product.objects.filter(
|
||||||
include_in_week_summary_email=True,
|
include_in_week_summary_email=True,
|
||||||
created_at__gte=last_week_date
|
created_at__gte=last_week_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
config = SiteConfiguration.get_solo()
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=config.contact_email,
|
recipients=config.contact_email,
|
||||||
subject="Nový produkt přidán do obchodu",
|
subject="Nový produkt přidán do obchodu",
|
||||||
@@ -49,6 +37,5 @@ def send_newly_added_items_to_store_email_task_last_week():
|
|||||||
context={
|
context={
|
||||||
"products_of_week": products_of_week,
|
"products_of_week": products_of_week,
|
||||||
"site_currency": config.currency,
|
"site_currency": config.currency,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator, validat
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
except ImportError:
|
except (ImportError, OSError):
|
||||||
HTML = None
|
HTML = None
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,182 +1,157 @@
|
|||||||
from account.tasks import send_email_with_context
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from notifications.models import Notification
|
||||||
|
from notifications.tasks import send_email_with_context
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_order(order, title, text, template_path, action_url=None):
|
||||||
|
"""Send in-app notification + email for logged-in users, email-only for guests."""
|
||||||
|
if order.user:
|
||||||
|
Notification.notify(
|
||||||
|
user=order.user,
|
||||||
|
title=title,
|
||||||
|
text=text,
|
||||||
|
notification_type=Notification.Type.ORDER,
|
||||||
|
action_url=action_url or f"/objednavky/{order.id}/",
|
||||||
|
template_path=template_path,
|
||||||
|
email_context={"order": order},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=order.email,
|
||||||
|
subject=title,
|
||||||
|
template_path=template_path,
|
||||||
|
context={"order": order},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# -- CLEANUP TASKS --
|
# -- CLEANUP TASKS --
|
||||||
|
|
||||||
# Delete expired/cancelled orders (older than 24 hours)
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def delete_expired_orders():
|
def delete_expired_orders():
|
||||||
Order = apps.get_model('commerce', 'Order')
|
Order = apps.get_model('commerce', 'Order')
|
||||||
|
expired_orders = Order.objects.filter(
|
||||||
expired_orders = Order.objects.filter(status=Order.OrderStatus.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
|
status=Order.OrderStatus.CANCELLED,
|
||||||
|
created_at__lt=timezone.now() - timezone.timedelta(hours=24),
|
||||||
|
)
|
||||||
count = expired_orders.count()
|
count = expired_orders.count()
|
||||||
expired_orders.delete()
|
expired_orders.delete()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
# -- NOTIFICATIONS CARRIER --
|
# -- CARRIER NOTIFICATIONS --
|
||||||
|
|
||||||
# Zásilkovna
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_zasilkovna_sended(order = None, **kwargs):
|
def notify_zasilkovna_sended(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_zasilkovna_sended:", kwargs)
|
title="Vaše objednávka byla odeslána",
|
||||||
|
text=f"Objednávka #{order.id} byla předána přepravci Zásilkovna.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your order has been shipped",
|
|
||||||
template_path="email/shipping/zasilkovna/zasilkovna_sended.html",
|
template_path="email/shipping/zasilkovna/zasilkovna_sended.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# Shop
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_Ready_to_pickup(order = None, **kwargs):
|
def notify_Ready_to_pickup(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_Ready_to_pickup:", kwargs)
|
title="Vaše objednávka je připravena k vyzvednutí",
|
||||||
|
text=f"Objednávka #{order.id} čeká na vyzvednutí na prodejně.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your order is ready for pickup",
|
|
||||||
template_path="email/shipping/ready_to_pickup/ready_to_pickup.html",
|
template_path="email/shipping/ready_to_pickup/ready_to_pickup.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# -- NOTIFICATIONS ORDER --
|
# -- ORDER NOTIFICATIONS --
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_order_successfuly_created(order = None, **kwargs):
|
def notify_order_successfuly_created(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
|
title="Objednávka byla úspěšně vytvořena",
|
||||||
|
text=f"Vaše objednávka #{order.id} byla přijata a čeká na zpracování.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your order has been successfully created",
|
|
||||||
template_path="email/order_created.html",
|
template_path="email/order_created.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_order_payed(order = None, **kwargs):
|
def notify_order_payed(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_order_payed:", kwargs)
|
title="Platba objednávky přijata",
|
||||||
|
text=f"Platba za objednávku #{order.id} byla úspěšně přijata.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your order has been paid",
|
|
||||||
template_path="email/order_paid.html",
|
template_path="email/order_paid.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_about_missing_payment(order = None, **kwargs):
|
def notify_about_missing_payment(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_about_missing_payment:", kwargs)
|
title="Nezaplacená objednávka",
|
||||||
|
text=f"Objednávka #{order.id} dosud nebyla zaplacena. Dokončete platbu co nejdříve.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Payment missing for your order",
|
|
||||||
template_path="email/order_missing_payment.html",
|
template_path="email/order_missing_payment.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# -- NOTIFICATIONS REFUND --
|
# -- REFUND NOTIFICATIONS --
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_refund_items_arrived(order = None, **kwargs):
|
def notify_refund_items_arrived(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
|
title="Vrácené zboží přijato",
|
||||||
|
text=f"Vrácené zboží k objednávce #{order.id} bylo přijato. Reklamace bude zpracována.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your refund items have arrived",
|
|
||||||
template_path="email/order_refund_items_arrived.html",
|
template_path="email/order_refund_items_arrived.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# Refund accepted, returning money
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_refund_accepted(order = None, **kwargs):
|
def notify_refund_accepted(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_refund_accepted:", kwargs)
|
title="Vrácení peněz schváleno",
|
||||||
|
text=f"Vaše reklamace k objednávce #{order.id} byla schválena. Peníze budou vráceny.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your refund has been accepted",
|
|
||||||
template_path="email/order_refund_accepted.html",
|
template_path="email/order_refund_accepted.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# -- NOTIFICATIONS ORDER STATUS --
|
# -- ORDER STATUS NOTIFICATIONS --
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_order_cancelled(order = None, **kwargs):
|
def notify_order_cancelled(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_order_cancelled:", kwargs)
|
title="Objednávka zrušena",
|
||||||
|
text=f"Objednávka #{order.id} byla zrušena.",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your order has been cancelled",
|
|
||||||
template_path="email/order_cancelled.html",
|
template_path="email/order_cancelled.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def notify_order_completed(order = None, **kwargs):
|
def notify_order_completed(order=None, **kwargs):
|
||||||
if not order:
|
if not order:
|
||||||
raise ValueError("Order must be provided for notification.")
|
raise ValueError("Order must be provided.")
|
||||||
|
_notify_order(
|
||||||
if kwargs:
|
order,
|
||||||
print("Additional kwargs received in notify_order_completed:", kwargs)
|
title="Objednávka dokončena",
|
||||||
|
text=f"Vaše objednávka #{order.id} byla úspěšně dokončena. Děkujeme za nákup!",
|
||||||
send_email_with_context(
|
|
||||||
recipients=order.email,
|
|
||||||
subject="Your order has been completed",
|
|
||||||
template_path="email/order_completed.html",
|
template_path="email/order_completed.html",
|
||||||
context={
|
)
|
||||||
"order": order,
|
|
||||||
})
|
|
||||||
|
|||||||
0
backend/notifications/__init__.py
Normal file
0
backend/notifications/__init__.py
Normal file
11
backend/notifications/admin.py
Normal file
11
backend/notifications/admin.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Notification)
|
||||||
|
class NotificationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "title", "notification_type", "user", "is_read", "bulk", "send_email", "created_at")
|
||||||
|
list_filter = ("notification_type", "is_read", "bulk", "send_email", "created_at")
|
||||||
|
search_fields = ("title", "text", "user__email", "user__username")
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
readonly_fields = ("created_at", "read_at", "email_subject", "email_template_path", "send_email", "bulk")
|
||||||
5
backend/notifications/apps.py
Normal file
5
backend/notifications/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
name = 'notifications'
|
||||||
93
backend/notifications/consumers.py
Normal file
93
backend/notifications/consumers.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import json
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _mark_notification_read(notification_id, user):
|
||||||
|
from .models import Notification
|
||||||
|
try:
|
||||||
|
n = Notification.objects.get(pk=notification_id, user=user)
|
||||||
|
if not n.is_read:
|
||||||
|
n.is_read = True
|
||||||
|
n.read_at = timezone.now()
|
||||||
|
n.save(update_fields=["is_read", "read_at"])
|
||||||
|
return n.read_at.isoformat()
|
||||||
|
except Notification.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _mark_all_notifications_read(user):
|
||||||
|
from .models import Notification
|
||||||
|
now = timezone.now()
|
||||||
|
return Notification.objects.filter(user=user, is_read=False).update(is_read=True, read_at=now)
|
||||||
|
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _delete_notification(notification_id, user):
|
||||||
|
from .models import Notification
|
||||||
|
try:
|
||||||
|
n = Notification.objects.get(pk=notification_id, user=user)
|
||||||
|
n.delete()
|
||||||
|
return True
|
||||||
|
except Notification.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationConsumer(AsyncWebsocketConsumer):
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
user = self.scope["user"]
|
||||||
|
if not user.is_authenticated:
|
||||||
|
await self.close(code=4401)
|
||||||
|
return
|
||||||
|
self.group_name = f"notifications_{user.pk}"
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
if hasattr(self, "group_name"):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
data = json.loads(text_data)
|
||||||
|
msg_type = data.get("type")
|
||||||
|
|
||||||
|
if msg_type == "mark_read":
|
||||||
|
notification_id = data.get("id")
|
||||||
|
read_at = await _mark_notification_read(notification_id, self.scope["user"])
|
||||||
|
if read_at:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
"type": "notification.read",
|
||||||
|
"id": notification_id,
|
||||||
|
"read_at": read_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
elif msg_type == "mark_all_read":
|
||||||
|
count = await _mark_all_notifications_read(self.scope["user"])
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
"type": "notification.read_all",
|
||||||
|
"marked": count,
|
||||||
|
}))
|
||||||
|
|
||||||
|
elif msg_type == "delete":
|
||||||
|
notification_id = data.get("id")
|
||||||
|
deleted = await _delete_notification(notification_id, self.scope["user"])
|
||||||
|
if deleted:
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
"type": "notification.deleted",
|
||||||
|
"id": notification_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def notification_new(self, event):
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
"type": "notification.new",
|
||||||
|
"id": event["id"],
|
||||||
|
"title": event["title"],
|
||||||
|
"text": event["text"],
|
||||||
|
"notification_type": event["notification_type"],
|
||||||
|
"action_url": event.get("action_url"),
|
||||||
|
"created_at": event["created_at"],
|
||||||
|
}))
|
||||||
0
backend/notifications/migrations/__init__.py
Normal file
0
backend/notifications/migrations/__init__.py
Normal file
145
backend/notifications/models.py
Normal file
145
backend/notifications/models.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import logging
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from account.models import CustomUser
|
||||||
|
from vontor_cz.models import SoftDeleteModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(SoftDeleteModel):
|
||||||
|
class Type(models.TextChoices):
|
||||||
|
SYSTEM = "system", "Systém"
|
||||||
|
ORDER = "order", "Objednávka"
|
||||||
|
PAYMENT = "payment", "Platba"
|
||||||
|
SOCIAL = "social", "Sociální"
|
||||||
|
CHAT = "chat", "Chat"
|
||||||
|
ADVERTISEMENT = "advertisement", "Inzerát"
|
||||||
|
|
||||||
|
title = models.CharField(max_length=200, help_text="Předmět oznámení.")
|
||||||
|
text = models.TextField(help_text="Obsah oznámení.")
|
||||||
|
|
||||||
|
notification_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Type.choices,
|
||||||
|
default=Type.SYSTEM,
|
||||||
|
help_text="Kategorie oznámení — používá se pro ikonky a filtrování na frontendu.",
|
||||||
|
)
|
||||||
|
action_url = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Volitelný odkaz na detail (např. '/objednavky/123/').",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
CustomUser, on_delete=models.CASCADE, null=True, related_name='notifications',
|
||||||
|
help_text="Příjemce oznámení.",
|
||||||
|
)
|
||||||
|
bulk = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True, pokud bylo oznámení vytvořeno hromadně pro více uživatelů.",
|
||||||
|
)
|
||||||
|
|
||||||
|
is_read = models.BooleanField(default=False)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
send_email = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True, pokud byl zároveň odeslán e-mail.",
|
||||||
|
)
|
||||||
|
email_subject = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
email_template_path = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def notify(cls, user=None, title=None, text=None, *, users=None,
|
||||||
|
notification_type=None, action_url=None,
|
||||||
|
email_subject=None, template_path=None,
|
||||||
|
email_context=None, attachments=None, bulk=False):
|
||||||
|
"""Create an in-app notification and optionally send an email + WebSocket push.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
• Single — user=<CustomUser> → returns Notification
|
||||||
|
• Bulk — users=<list|qs> → returns list[Notification]
|
||||||
|
|
||||||
|
notification_type controls the frontend icon/colour (default: system).
|
||||||
|
action_url is an optional relative path the bell badge links to.
|
||||||
|
If template_path is set, an email is dispatched (Celery if CELERY_ENABLED, else sync).
|
||||||
|
"""
|
||||||
|
if users is not None:
|
||||||
|
return [
|
||||||
|
cls.notify(
|
||||||
|
user=u, title=title, text=text,
|
||||||
|
notification_type=notification_type, action_url=action_url,
|
||||||
|
email_subject=email_subject, template_path=template_path,
|
||||||
|
email_context=email_context, attachments=attachments,
|
||||||
|
bulk=True,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("Notification.notify() requires either 'user' or 'users'.")
|
||||||
|
|
||||||
|
notification = cls.objects.create(
|
||||||
|
user=user,
|
||||||
|
title=title,
|
||||||
|
text=text,
|
||||||
|
notification_type=notification_type or cls.Type.SYSTEM,
|
||||||
|
action_url=action_url,
|
||||||
|
bulk=bulk,
|
||||||
|
send_email=bool(template_path),
|
||||||
|
email_subject=email_subject,
|
||||||
|
email_template_path=template_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Real-time push via WebSocket
|
||||||
|
try:
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
if channel_layer:
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
f"notifications_{user.pk}",
|
||||||
|
{
|
||||||
|
"type": "notification.new",
|
||||||
|
"id": notification.pk,
|
||||||
|
"title": notification.title,
|
||||||
|
"text": notification.text,
|
||||||
|
"notification_type": notification.notification_type,
|
||||||
|
"action_url": notification.action_url,
|
||||||
|
"created_at": notification.created_at.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[Notification.notify] WebSocket push failed for user %s: %s", user.pk, exc)
|
||||||
|
|
||||||
|
if template_path and getattr(user, 'email', None):
|
||||||
|
try:
|
||||||
|
from django.conf import settings
|
||||||
|
from notifications.tasks import send_notification_email_task
|
||||||
|
ctx = dict(email_context or {})
|
||||||
|
if 'user' not in ctx:
|
||||||
|
ctx['user'] = user
|
||||||
|
kwargs = dict(
|
||||||
|
recipient_email=user.email,
|
||||||
|
subject=email_subject or title,
|
||||||
|
template_path=template_path,
|
||||||
|
context=ctx,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
if getattr(settings, 'CELERY_ENABLED', False):
|
||||||
|
send_notification_email_task.delay(**kwargs)
|
||||||
|
else:
|
||||||
|
send_notification_email_task(**kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[Notification.notify] Email failed for user %s: %s", user.pk, exc)
|
||||||
|
|
||||||
|
return notification
|
||||||
6
backend/notifications/routing.py
Normal file
6
backend/notifications/routing.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"ws/notifications/$", consumers.NotificationConsumer.as_asgi()),
|
||||||
|
]
|
||||||
15
backend/notifications/serializers.py
Normal file
15
backend/notifications/serializers.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Notification
|
||||||
|
fields = [
|
||||||
|
'id', 'title', 'text', 'notification_type', 'action_url',
|
||||||
|
'is_read', 'read_at', 'created_at', 'bulk', 'send_email',
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'id', 'title', 'text', 'notification_type', 'action_url',
|
||||||
|
'is_read', 'read_at', 'created_at', 'bulk', 'send_email',
|
||||||
|
]
|
||||||
119
backend/notifications/tasks.py
Normal file
119
backend/notifications/tasks.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
|
||||||
|
from django.core.mail import send_mail, EmailMultiAlternatives
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
from account.models import CustomUser
|
||||||
|
from django.conf import settings
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_notification_email_task(recipient_email, subject, template_path, context=None, attachments=None):
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=recipient_email,
|
||||||
|
subject=subject,
|
||||||
|
template_path=template_path,
|
||||||
|
context=context or {},
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None, attachments=None):
|
||||||
|
"""
|
||||||
|
Send email using component-based template system.
|
||||||
|
Uses base.html with header/footer components and includes the specified content template.
|
||||||
|
"""
|
||||||
|
if isinstance(recipients, str):
|
||||||
|
recipients = [recipients]
|
||||||
|
|
||||||
|
if not recipients:
|
||||||
|
logger.warning("No recipients provided for email. Skipping sending email.")
|
||||||
|
return "No recipients provided for email."
|
||||||
|
|
||||||
|
ctx = dict(context or {})
|
||||||
|
|
||||||
|
# Add current_year to context if not present
|
||||||
|
if "current_year" not in ctx:
|
||||||
|
ctx["current_year"] = datetime.now().year
|
||||||
|
|
||||||
|
# Add AppConfig data for footer if not already present
|
||||||
|
if "company_name" not in ctx:
|
||||||
|
try:
|
||||||
|
from configuration.models import AppConfig
|
||||||
|
config = AppConfig.objects.first()
|
||||||
|
if config:
|
||||||
|
ctx["company_name"] = config.company_name
|
||||||
|
ctx["company_address"] = config.company_address
|
||||||
|
ctx["company_ico"] = config.company_ico
|
||||||
|
ctx["company_dic"] = config.company_dic
|
||||||
|
ctx["contact_email"] = config.contact_email
|
||||||
|
ctx["contact_phone"] = config.contact_phone
|
||||||
|
except Exception:
|
||||||
|
pass # Gracefully skip if AppConfig not available
|
||||||
|
|
||||||
|
# Sanitize user if someone passes the model by mistake
|
||||||
|
if "user" in ctx and not isinstance(ctx["user"], dict):
|
||||||
|
try:
|
||||||
|
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
||||||
|
except Exception:
|
||||||
|
ctx["user"] = {}
|
||||||
|
|
||||||
|
# Render HTML using base template with content include
|
||||||
|
html_message = None
|
||||||
|
if template_path:
|
||||||
|
ctx["content_template"] = template_path
|
||||||
|
html_message = render_to_string("email/components/base.html", ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=message or "Tento e-mail vyžaduje HTML podporu.",
|
||||||
|
from_email=None,
|
||||||
|
to=recipients if len(recipients) == 1 else [],
|
||||||
|
bcc=recipients if len(recipients) > 1 else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
if html_message:
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
|
||||||
|
if attachments:
|
||||||
|
for filename, content, mimetype in attachments:
|
||||||
|
email.attach(filename, content, mimetype)
|
||||||
|
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f"Sent email to {recipients} with subject '{subject}'")
|
||||||
|
|
||||||
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||||
|
logger.debug(f"\nEMAIL HTML:\n{html_message}\nKONEC OBSAHU")
|
||||||
|
return "Successfully sent email."
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
|
raise # Re-raise so Celery marks the task as FAILED and can retry
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_template_ctx(user: CustomUser) -> dict:
|
||||||
|
"""
|
||||||
|
Return a plain dict for templates instead of passing the DB model.
|
||||||
|
Provides aliases to avoid template errors (firstname vs first_name).
|
||||||
|
Adds a backward-compatible key 'get_full_name' for templates using user.get_full_name.
|
||||||
|
"""
|
||||||
|
first_name = getattr(user, "first_name", "") or ""
|
||||||
|
last_name = getattr(user, "last_name", "") or ""
|
||||||
|
full_name = f"{first_name} {last_name}".strip()
|
||||||
|
return {
|
||||||
|
"id": user.pk,
|
||||||
|
"email": getattr(user, "email", "") or "",
|
||||||
|
"first_name": first_name,
|
||||||
|
"firstname": first_name, # alias for templates using `firstname`
|
||||||
|
"last_name": last_name,
|
||||||
|
"lastname": last_name, # alias for templates using `lastname`
|
||||||
|
"full_name": full_name,
|
||||||
|
"get_full_name": full_name, # compatibility for templates using method-style access
|
||||||
|
}
|
||||||
3
backend/notifications/tests.py
Normal file
3
backend/notifications/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
10
backend/notifications/urls.py
Normal file
10
backend/notifications/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'', views.NotificationViewSet, basename='notification')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
51
backend/notifications/views.py
Normal file
51
backend/notifications/views.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import logging
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.viewsets import GenericViewSet
|
||||||
|
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
|
from .models import Notification
|
||||||
|
from .serializers import NotificationSerializer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(tags=["notifications"])
|
||||||
|
class NotificationViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
|
||||||
|
serializer_class = NotificationSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Notification.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Unread notification count",
|
||||||
|
responses={200: {"type": "object", "properties": {"unread_count": {"type": "integer"}}}},
|
||||||
|
)
|
||||||
|
@action(detail=False, methods=["get"], url_path="unread-count")
|
||||||
|
def unread_count(self, request):
|
||||||
|
count = self.get_queryset().filter(is_read=False).count()
|
||||||
|
return Response({"unread_count": count})
|
||||||
|
|
||||||
|
@extend_schema(summary="Mark a single notification as read", responses={200: NotificationSerializer})
|
||||||
|
@action(detail=True, methods=["post"], url_path="read")
|
||||||
|
def mark_read(self, request, pk=None):
|
||||||
|
notification = self.get_object()
|
||||||
|
if not notification.is_read:
|
||||||
|
notification.is_read = True
|
||||||
|
notification.read_at = timezone.now()
|
||||||
|
notification.save(update_fields=["is_read", "read_at"])
|
||||||
|
return Response(NotificationSerializer(notification).data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Mark all notifications as read",
|
||||||
|
responses={200: {"type": "object", "properties": {"marked": {"type": "integer"}}}},
|
||||||
|
)
|
||||||
|
@action(detail=False, methods=["post"], url_path="read-all")
|
||||||
|
def mark_all_read(self, request):
|
||||||
|
now = timezone.now()
|
||||||
|
updated = self.get_queryset().filter(is_read=False).update(is_read=True, read_at=now)
|
||||||
|
return Response({"marked": updated})
|
||||||
1
backend/social/blog/IDEA.md
Normal file
1
backend/social/blog/IDEA.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# editorjs.io - základ
|
||||||
0
backend/social/blog/__init__.py
Normal file
0
backend/social/blog/__init__.py
Normal file
3
backend/social/blog/admin.py
Normal file
3
backend/social/blog/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
backend/social/blog/apps.py
Normal file
5
backend/social/blog/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BlogConfig(AppConfig):
|
||||||
|
name = 'blog'
|
||||||
0
backend/social/blog/migrations/__init__.py
Normal file
0
backend/social/blog/migrations/__init__.py
Normal file
16
backend/social/blog/models.py
Normal file
16
backend/social/blog/models.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
|
||||||
|
class Blog(models.Model):
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
content = models.JsonField()
|
||||||
|
|
||||||
|
authors = models.ManyToManyField('Author', related_name='blogs')
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
0
backend/social/blog/serializers.py
Normal file
0
backend/social/blog/serializers.py
Normal file
3
backend/social/blog/tests.py
Normal file
3
backend/social/blog/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/social/blog/views.py
Normal file
3
backend/social/blog/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
6
backend/social/chat/IDEA.md
Normal file
6
backend/social/chat/IDEA.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Notify requests for chats
|
||||||
|
|
||||||
|
# Notify @mentions in chats
|
||||||
|
|
||||||
|
# Add requests before filling chat (maybe add chat type request and allowed and archived for advanced filtering) + actions
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
chat app diagram
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ CLIENT (browser) │
|
|
||||||
└────────────┬────────────────────────────┬───────────────────────┘
|
|
||||||
│ WebSocket │ HTTP REST
|
|
||||||
│ ws/chat/<id>/ │ /api/social/
|
|
||||||
▼ ▼
|
|
||||||
┌────────────────────────┐ ┌────────────────────────────────────┐
|
|
||||||
│ ChatConsumer │ │ REST Views │
|
|
||||||
│ (consumers.py) │ │ (views.py) │
|
|
||||||
│ │ │ │
|
|
||||||
│ connect() │ │ ChatViewSet │
|
|
||||||
│ ├─ auth check │ │ ├─ list / retrieve (GET) │
|
|
||||||
│ └─ membership check ──┼───┼──► IsChatMember │
|
|
||||||
│ │ │ ├─ create (POST) │
|
|
||||||
│ receive() │ │ ├─ update/partial (PATCH/PUT) │
|
|
||||||
│ ├─ new_chat_message │ │ │ └─ CanManageChat │
|
|
||||||
│ │ (text only) │ │ ├─ destroy (DELETE) │
|
|
||||||
│ │ └─► _create_message │ │ └─ CanManageChat │
|
|
||||||
│ │ (DB INSERT) │ │ └─ add/remove member & moderator │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ ├─ new_reply_message │ │ MessageViewSet │
|
|
||||||
│ │ (text only) │ │ ├─ list / retrieve (GET) │
|
|
||||||
│ │ └─► _create_message │ │ └─ IsAuthenticated │
|
|
||||||
│ │ (DB INSERT) │ │ ├─ send (POST) │
|
|
||||||
│ │ │ │ │ ├─ IsAuthenticated │
|
|
||||||
│ └─ reaction │ │ │ ├─ DB INSERT Message │
|
|
||||||
│ └─► _toggle_reaction │ │ ├─ DB INSERT MessageFile(s) │
|
|
||||||
│ (DB UPDATE/ │ │ │ └─► group_send(chat.msg) ─────┼──► WebSocket push
|
|
||||||
│ DELETE) │ │ ├─ update/partial (PATCH/PUT) │
|
|
||||||
│ │ │ │ ├─ IsMessageSenderOnly │
|
|
||||||
│ typing / stop_typing │ │ │ ├─ message.edit_content() │
|
|
||||||
│ └─► group_send only │ │ │ └─► group_send(edit.msg) ─────┼──► WebSocket push
|
|
||||||
│ (no DB write) │ │ └─ destroy (DELETE) │
|
|
||||||
│ │ │ ├─ CanDeleteMessage │
|
|
||||||
│ │ │ └─► group_send(delete.msg) ───┼──► WebSocket push
|
|
||||||
└────────────┬───────────┘ └──────────────┬─────────────────────┘
|
|
||||||
│ │
|
|
||||||
│ both use channel_layer │
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Channel Layer (Redis) │
|
|
||||||
│ group: "chat_{id}" │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼ group_send dispatches to all connected consumers
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ChatConsumer event handlers (push to each connected client) │
|
|
||||||
│ chat_message · reply_chat_message · edit_message │
|
|
||||||
│ delete_message · message_reaction · typing_status · stop_typing│
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
┌──────────────────────────────────┐ ┌──────────────────────────┐
|
|
||||||
│ Database writes summary │ │ When to use which path │
|
|
||||||
│ │ │ │
|
|
||||||
│ CREATE Message ← consumer │ │ WS ← text-only msg │
|
|
||||||
│ (text only) │ │ HTTP← msg with files │
|
|
||||||
│ CREATE Message ← view /send │ │ HTTP← edit message │
|
|
||||||
│ (any) │ │ HTTP← delete message │
|
|
||||||
│ CREATE MessageFile ← view/send │ │ WS ← reaction │
|
|
||||||
│ UPDATE Message ← view │ │ WS ← typing indicator │
|
|
||||||
│ DELETE Message ← view (soft) │ └──────────────────────────┘
|
|
||||||
│ CREATE MessageReaction ← cons │
|
|
||||||
│ UPDATE MessageReaction ← cons │
|
|
||||||
│ DELETE MessageReaction ← cons │
|
|
||||||
│ CREATE MessageHistory ← model │
|
|
||||||
│ (auto on edit_content) │
|
|
||||||
└──────────────────────────────────┘
|
|
||||||
3
backend/social/hubs/IDEA.md
Normal file
3
backend/social/hubs/IDEA.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Notify on mod ADD
|
||||||
|
|
||||||
|
# notify for Apeal
|
||||||
11
backend/social/posts/IDEA.md
Normal file
11
backend/social/posts/IDEA.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Votes in posts as widget
|
||||||
|
|
||||||
|
# Location tag
|
||||||
|
|
||||||
|
# PFP from hub on posts outside of hub
|
||||||
|
|
||||||
|
# Collab/multiple authors on posts
|
||||||
|
|
||||||
|
# NSFW tag, spoiler tag
|
||||||
|
|
||||||
|
# Notify on likes 1 --> 10 --> 100 -> 500 -> 1000 etc..
|
||||||
@@ -18,9 +18,10 @@ django_asgi_app = get_asgi_application()
|
|||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
from social.chat.routing import websocket_urlpatterns as social_ws
|
from social.chat.routing import websocket_urlpatterns as social_ws
|
||||||
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
|
from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws
|
||||||
|
from notifications.routing import websocket_urlpatterns as notifications_ws
|
||||||
from vontor_cz.middleware import JWTAuthMiddleware
|
from vontor_cz.middleware import JWTAuthMiddleware
|
||||||
|
|
||||||
websocket_urlpatterns = downloader_ws + social_ws
|
websocket_urlpatterns = downloader_ws + social_ws + notifications_ws
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
application = ProtocolTypeRouter({
|
||||||
"http": django_asgi_app,
|
"http": django_asgi_app,
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ MY_CREATED_APPS = [
|
|||||||
'social.posts',
|
'social.posts',
|
||||||
|
|
||||||
'advertisement',
|
'advertisement',
|
||||||
|
'notifications',
|
||||||
|
|
||||||
'thirdparty.downloader',
|
'thirdparty.downloader',
|
||||||
'thirdparty.stripe', # register Stripe app so its models are recognized
|
'thirdparty.stripe', # register Stripe app so its models are recognized
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ urlpatterns = [
|
|||||||
path('api/configuration/', include('configuration.urls')),
|
path('api/configuration/', include('configuration.urls')),
|
||||||
path('api/advertisement/', include('advertisement.urls')),
|
path('api/advertisement/', include('advertisement.urls')),
|
||||||
|
|
||||||
|
path('api/notifications/', include('notifications.urls')),
|
||||||
path('api/social/hubs/', include('social.hubs.urls')),
|
path('api/social/hubs/', include('social.hubs.urls')),
|
||||||
path('api/social/posts/', include('social.posts.urls')),
|
path('api/social/posts/', include('social.posts.urls')),
|
||||||
path('api/social/', include('social.chat.urls')),
|
path('api/social/', include('social.chat.urls')),
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export default function Post({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{post.content && (
|
{post.content && (
|
||||||
<p className={`mt-1 whitespace-pre-wrap break-words text-brand-text ${isFocused ? "text-lg" : "text-[15px]"}`}>
|
<p className={`mt-1 whitespace-pre-wrap wrap-break-word text-brand-text ${isFocused ? "text-lg" : "text-[15px]"}`}>
|
||||||
{post.content}
|
{post.content}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# http://editorconfig.org
|
|
||||||
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
insert_final_newline = false
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
dist/
|
|
||||||
www/
|
|
||||||
loader/
|
|
||||||
|
|
||||||
*~
|
|
||||||
*.sw[mnpcod]
|
|
||||||
*.log
|
|
||||||
*.lock
|
|
||||||
*.tmp
|
|
||||||
*.tmp.*
|
|
||||||
log.txt
|
|
||||||
*.sublime-project
|
|
||||||
*.sublime-workspace
|
|
||||||
|
|
||||||
.stencil/
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
.sass-cache/
|
|
||||||
.versions/
|
|
||||||
node_modules/
|
|
||||||
$RECYCLE.BIN/
|
|
||||||
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
UserInterfaceState.xcuserstate
|
|
||||||
.env
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"jsxBracketSameLine": false,
|
|
||||||
"jsxSingleQuote": false,
|
|
||||||
"quoteProps": "consistent",
|
|
||||||
"printWidth": 180,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"useTabs": false
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# Cara Menggunakan Komponen Secara Lokal
|
|
||||||
|
|
||||||
Ikuti langkah-langkah berikut untuk menggunakan `ews-card` dan `ews-hex-shape` di proyek lokal lain.
|
|
||||||
|
|
||||||
## 1. Build Library
|
|
||||||
Pastikan library sudah di-build di direktori ini (`ews-component`):
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Link Library (Development)
|
|
||||||
Gunakan `npm link` agar perubahan di library ini langsung terlihat di proyek tujuan.
|
|
||||||
|
|
||||||
**Di direktori ini (`ews-component`):**
|
|
||||||
```bash
|
|
||||||
npm link
|
|
||||||
```
|
|
||||||
|
|
||||||
**Di direktori proyek tujuan (misal: `ews-concept-new`):**
|
|
||||||
```bash
|
|
||||||
npm link ews-component
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Framework: Svelte / Vite / Vanilla JS
|
|
||||||
Tambahkan loader di file entri utama (seperti `src/routes/+layout.svelte` atau `main.ts`):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { defineCustomElements } from 'ews-component/loader';
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
defineCustomElements();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Framework: React
|
|
||||||
Untuk React, panggil `defineCustomElements()` di file entri utama (`index.js` atau `App.js`):
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { defineCustomElements } from 'ews-component/loader';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
useEffect(() => {
|
|
||||||
defineCustomElements();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ews-card>
|
|
||||||
<div slot="header">React Card</div>
|
|
||||||
<p>Konten di React</p>
|
|
||||||
</ews-card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Framework: Vue (Vite)
|
|
||||||
Untuk Vue 3 dengan Vite, cara paling stabil adalah mengimport komponen secara langsung (menghindari error "Constructor not found"):
|
|
||||||
|
|
||||||
1. Konfigurasi `vite.config.ts`:
|
|
||||||
```typescript
|
|
||||||
// vite.config.ts
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
vue({
|
|
||||||
template: {
|
|
||||||
compilerOptions: {
|
|
||||||
isCustomElement: (tag) => tag.startsWith('ews-')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Register komponen di `main.ts` atau di component yang membutuhkan:
|
|
||||||
```typescript
|
|
||||||
// Mengimport dan register secara eksplisit (lebih stabil untuk development/npm link)
|
|
||||||
import 'ews-component/components/ews-card';
|
|
||||||
import 'ews-component/components/ews-hex-shape';
|
|
||||||
import 'ews-component/components/ews-stripe-bar';
|
|
||||||
```
|
|
||||||
|
|
||||||
Atau jika ingin loader otomatis (namun kadang terkendala `npm link`):
|
|
||||||
```typescript
|
|
||||||
import { defineCustomElements } from 'ews-component/loader';
|
|
||||||
defineCustomElements();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plain HTML (Tanpa Bundler)
|
|
||||||
Jika ingin menggunakan langsung di file HTML tanpa build tool:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>EWS Component Demo</title>
|
|
||||||
<!-- Load component bundle (sesuaikan path ke node_modules jika lokal) -->
|
|
||||||
<script type="module" src="./node_modules/ews-component/dist/ews-component/ews-component.esm.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<ews-card>
|
|
||||||
<div slot="header">Vanilla HTML</div>
|
|
||||||
<p>Berjalan langsung di browser.</p>
|
|
||||||
</ews-card>
|
|
||||||
|
|
||||||
<ews-hex-shape color="#3498db" size="120"></ews-hex-shape>
|
|
||||||
|
|
||||||
<ews-stripe-bar color="red" loop="true" duration="10"></ews-stripe-bar>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Alternatif: Install Langsung
|
|
||||||
Jika tidak ingin menggunakan `link`, Anda bisa menginstall langsung dari path folder:
|
|
||||||
```bash
|
|
||||||
npm install ../path/to/ews-component
|
|
||||||
```
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ews-component",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"description": "Stencil Component Starter",
|
|
||||||
"main": "dist/index.cjs.js",
|
|
||||||
"module": "dist/index.js",
|
|
||||||
"types": "dist/types/index.d.ts",
|
|
||||||
"collection": "dist/collection/collection-manifest.json",
|
|
||||||
"collection:main": "dist/collection/index.js",
|
|
||||||
"unpkg": "dist/ews-component/ews-component.esm.js",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/ews-component/ews-component.esm.js",
|
|
||||||
"require": "./dist/ews-component/ews-component.cjs.js"
|
|
||||||
},
|
|
||||||
"./components": {
|
|
||||||
"import": "./dist/components/index.js",
|
|
||||||
"types": "./dist/components/index.d.ts"
|
|
||||||
},
|
|
||||||
"./components/*": {
|
|
||||||
"import": "./dist/components/*.js",
|
|
||||||
"types": "./dist/components/*.d.ts"
|
|
||||||
},
|
|
||||||
"./loader": {
|
|
||||||
"import": "./loader/index.js",
|
|
||||||
"require": "./loader/index.cjs",
|
|
||||||
"types": "./loader/index.d.ts"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/bagusindrayana/ews-component"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist/",
|
|
||||||
"loader/"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "stencil build",
|
|
||||||
"start": "stencil build --dev --watch --serve",
|
|
||||||
"test": "stencil-test",
|
|
||||||
"test:watch": "stencil-test --watch",
|
|
||||||
"generate": "stencil generate"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@stencil/core": "^4.27.1",
|
|
||||||
"@stencil/vitest": "^1.8.3",
|
|
||||||
"@types/node": "^22.13.5",
|
|
||||||
"@vitest/browser-playwright": "^4.0.0",
|
|
||||||
"vitest": "^4.0.0"
|
|
||||||
},
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# EWS Component Library
|
|
||||||
|
|
||||||
> A collection of StencilJS web components for the EWS project.
|
|
||||||
|
|
||||||
This library contains reusable web components such as layout managers, charts, and UI elements designed for high-performance and framework-agnostic usage.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
To use `ews-component` in your project, install it via npm:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install ews-component
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Components
|
|
||||||
|
|
||||||
- `ews-card`: A versatile card component for displaying content.
|
|
||||||
- `ews-hex-grid`: A grid layout with hexagonal cells.
|
|
||||||
- `ews-hex-shape`: Individual hexagonal shape component.
|
|
||||||
- `ews-rib-layout`: A responsive "ribcage" layout for hierarchical data.
|
|
||||||
- `ews-stripe-bar`: A striped status or progress bar.
|
|
||||||
|
|
||||||
## Local Development (StencilJS)
|
|
||||||
|
|
||||||
To start developing components locally, clone this repository and follow these steps:
|
|
||||||
|
|
||||||
1. **Install dependencies**:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start development server**:
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
This will start a local dev server with hot-reloading.
|
|
||||||
|
|
||||||
3. **Build for production**:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run tests**:
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Framework Integration
|
|
||||||
|
|
||||||
Since these are standard Web Components, they work in any framework (React, Vue, Angular, Svelte) or with no framework at all.
|
|
||||||
|
|
||||||
### Lazy Loading (Universal)
|
|
||||||
|
|
||||||
Include the loader script in your HTML:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script type="module" src="https://unpkg.com/ews-component/dist/ews-component/ews-component.esm.js"></script>
|
|
||||||
<ews-rib-layout max-branches="4">
|
|
||||||
<!-- Your content here -->
|
|
||||||
</ews-rib-layout>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Direct Import (React/Vite/NextJS)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { defineCustomElements } from 'ews-component/loader';
|
|
||||||
|
|
||||||
defineCustomElements();
|
|
||||||
|
|
||||||
// Use in your component
|
|
||||||
<ews-stripe-bar percent={75} status="active"></ews-stripe-bar>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
For more detailed information on specific components, please refer to the documentation in each component's directory or the official [StencilJS documentation](https://stenciljs.com/docs/introduction).
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing & Adding New Components
|
|
||||||
|
|
||||||
To maintain consistency, please follow these steps when adding a new component:
|
|
||||||
|
|
||||||
1. **Generate Component**:
|
|
||||||
Use the Stencil CLI to scaffold your component:
|
|
||||||
```bash
|
|
||||||
npm run generate
|
|
||||||
```
|
|
||||||
*Input the name with `ews-` prefix (e.g., `ews-new-button`).*
|
|
||||||
|
|
||||||
2. **Naming & Directory**:
|
|
||||||
- **Folder**: `src/components/ews-[name]/`
|
|
||||||
- **Tag Name**: `ews-[name]`
|
|
||||||
- **Class Name**: `Ews[Name]` (PascalCase)
|
|
||||||
|
|
||||||
3. **Code Style Guidelines**:
|
|
||||||
- **TypeScript & TSX**: Always use TypeScript/TSX for component logic.
|
|
||||||
- **Styling**: Use a dedicated CSS file (`[name].css`). Prefix all classes with `ews-` (e.g., `.ews-card`) to avoid global style collisions.
|
|
||||||
- **Reactivity**: Use `@Prop()`, `@State()`, and `@Event()` decorators for state management and communication.
|
|
||||||
- **Documentation**: Write clear JSDoc comments for props and events; Stencil will automatically update the component's `readme.md`.
|
|
||||||
|
|
||||||
|
|
||||||
## Support Me!
|
|
||||||
[](https://sociabuzz.com/bagusindrayana/tribe)
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/* tslint:disable */
|
|
||||||
/**
|
|
||||||
* This is an autogenerated file created by the Stencil compiler.
|
|
||||||
* It contains typing information for all components that exist in this project.
|
|
||||||
*/
|
|
||||||
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
|
|
||||||
export namespace Components {
|
|
||||||
interface EwsCard {
|
|
||||||
/**
|
|
||||||
* Custom color for border and content (red or orange)
|
|
||||||
*/
|
|
||||||
"color"?: string;
|
|
||||||
/**
|
|
||||||
* Additional CSS classes to apply to the card wrapper
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"customClass": string;
|
|
||||||
/**
|
|
||||||
* Inline style
|
|
||||||
*/
|
|
||||||
"customStyle"?: string;
|
|
||||||
}
|
|
||||||
interface EwsHexGrid {
|
|
||||||
/**
|
|
||||||
* Additional CSS class for the container.
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"customClass": string;
|
|
||||||
/**
|
|
||||||
* Gap between hex cells in pixels.
|
|
||||||
* @default 4
|
|
||||||
*/
|
|
||||||
"gap": number;
|
|
||||||
/**
|
|
||||||
* Height of each hex cell in pixels.
|
|
||||||
*/
|
|
||||||
"hexHeight": number;
|
|
||||||
/**
|
|
||||||
* Width of each hex cell in pixels.
|
|
||||||
*/
|
|
||||||
"hexWidth": number;
|
|
||||||
/**
|
|
||||||
* Hex orientation variant: 'pointy' or 'flat'.
|
|
||||||
* @default 'pointy'
|
|
||||||
*/
|
|
||||||
"variant": 'pointy' | 'flat';
|
|
||||||
}
|
|
||||||
interface EwsHexShape {
|
|
||||||
/**
|
|
||||||
* Whether to clip content within the hex shape.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
"clipContent": boolean;
|
|
||||||
/**
|
|
||||||
* The color variant of the hex shape.
|
|
||||||
* @default 'orange'
|
|
||||||
*/
|
|
||||||
"color": string;
|
|
||||||
/**
|
|
||||||
* Additional CSS classes for the component.
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"customClass": string;
|
|
||||||
/**
|
|
||||||
* Whether the hex shape has a flat top.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
"flatTop": boolean;
|
|
||||||
/**
|
|
||||||
* The padding inside the hex shape for content.
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
"paddingContent": number;
|
|
||||||
}
|
|
||||||
interface EwsRibLayout {
|
|
||||||
/**
|
|
||||||
* Optional renderer function for the connector content.
|
|
||||||
*/
|
|
||||||
"connectorRenderer"?: (item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) => any;
|
|
||||||
/**
|
|
||||||
* Function to get the href for a node. If provided, nodes will be rendered as <a> tags.
|
|
||||||
*/
|
|
||||||
"getHref"?: (item: any) => string;
|
|
||||||
/**
|
|
||||||
* Array of items to be displayed in the rib cage layout.
|
|
||||||
* @default []
|
|
||||||
*/
|
|
||||||
"items": any[];
|
|
||||||
/**
|
|
||||||
* Maximum number of branches to display. If not provided, it defaults to 5 (responsive).
|
|
||||||
*/
|
|
||||||
"maxBranches"?: number;
|
|
||||||
/**
|
|
||||||
* Optional renderer function for the node content.
|
|
||||||
*/
|
|
||||||
"nodeRenderer"?: (item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) => any;
|
|
||||||
}
|
|
||||||
interface EwsStripeBar {
|
|
||||||
/**
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"color": string;
|
|
||||||
/**
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
"duration": number;
|
|
||||||
/**
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
"loop": boolean;
|
|
||||||
/**
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"orientation": string;
|
|
||||||
/**
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
"reverse": boolean;
|
|
||||||
/**
|
|
||||||
* @default '30px'
|
|
||||||
*/
|
|
||||||
"size": string;
|
|
||||||
}
|
|
||||||
interface MyComponent {
|
|
||||||
/**
|
|
||||||
* The first name
|
|
||||||
*/
|
|
||||||
"first": string;
|
|
||||||
/**
|
|
||||||
* The last name
|
|
||||||
*/
|
|
||||||
"last": string;
|
|
||||||
/**
|
|
||||||
* The middle name
|
|
||||||
*/
|
|
||||||
"middle": string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export interface EwsCardCustomEvent<T> extends CustomEvent<T> {
|
|
||||||
detail: T;
|
|
||||||
target: HTMLEwsCardElement;
|
|
||||||
}
|
|
||||||
declare global {
|
|
||||||
interface HTMLEwsCardElementEventMap {
|
|
||||||
"toggle": void;
|
|
||||||
}
|
|
||||||
interface HTMLEwsCardElement extends Components.EwsCard, HTMLStencilElement {
|
|
||||||
addEventListener<K extends keyof HTMLEwsCardElementEventMap>(type: K, listener: (this: HTMLEwsCardElement, ev: EwsCardCustomEvent<HTMLEwsCardElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
|
|
||||||
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
|
||||||
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
|
||||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
|
|
||||||
removeEventListener<K extends keyof HTMLEwsCardElementEventMap>(type: K, listener: (this: HTMLEwsCardElement, ev: EwsCardCustomEvent<HTMLEwsCardElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
|
|
||||||
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
|
||||||
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
|
||||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
|
|
||||||
}
|
|
||||||
var HTMLEwsCardElement: {
|
|
||||||
prototype: HTMLEwsCardElement;
|
|
||||||
new (): HTMLEwsCardElement;
|
|
||||||
};
|
|
||||||
interface HTMLEwsHexGridElement extends Components.EwsHexGrid, HTMLStencilElement {
|
|
||||||
}
|
|
||||||
var HTMLEwsHexGridElement: {
|
|
||||||
prototype: HTMLEwsHexGridElement;
|
|
||||||
new (): HTMLEwsHexGridElement;
|
|
||||||
};
|
|
||||||
interface HTMLEwsHexShapeElement extends Components.EwsHexShape, HTMLStencilElement {
|
|
||||||
}
|
|
||||||
var HTMLEwsHexShapeElement: {
|
|
||||||
prototype: HTMLEwsHexShapeElement;
|
|
||||||
new (): HTMLEwsHexShapeElement;
|
|
||||||
};
|
|
||||||
interface HTMLEwsRibLayoutElement extends Components.EwsRibLayout, HTMLStencilElement {
|
|
||||||
}
|
|
||||||
var HTMLEwsRibLayoutElement: {
|
|
||||||
prototype: HTMLEwsRibLayoutElement;
|
|
||||||
new (): HTMLEwsRibLayoutElement;
|
|
||||||
};
|
|
||||||
interface HTMLEwsStripeBarElement extends Components.EwsStripeBar, HTMLStencilElement {
|
|
||||||
}
|
|
||||||
var HTMLEwsStripeBarElement: {
|
|
||||||
prototype: HTMLEwsStripeBarElement;
|
|
||||||
new (): HTMLEwsStripeBarElement;
|
|
||||||
};
|
|
||||||
interface HTMLMyComponentElement extends Components.MyComponent, HTMLStencilElement {
|
|
||||||
}
|
|
||||||
var HTMLMyComponentElement: {
|
|
||||||
prototype: HTMLMyComponentElement;
|
|
||||||
new (): HTMLMyComponentElement;
|
|
||||||
};
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"ews-card": HTMLEwsCardElement;
|
|
||||||
"ews-hex-grid": HTMLEwsHexGridElement;
|
|
||||||
"ews-hex-shape": HTMLEwsHexShapeElement;
|
|
||||||
"ews-rib-layout": HTMLEwsRibLayoutElement;
|
|
||||||
"ews-stripe-bar": HTMLEwsStripeBarElement;
|
|
||||||
"my-component": HTMLMyComponentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
declare namespace LocalJSX {
|
|
||||||
interface EwsCard {
|
|
||||||
/**
|
|
||||||
* Custom color for border and content (red or orange)
|
|
||||||
*/
|
|
||||||
"color"?: string;
|
|
||||||
/**
|
|
||||||
* Additional CSS classes to apply to the card wrapper
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"customClass"?: string;
|
|
||||||
/**
|
|
||||||
* Inline style
|
|
||||||
*/
|
|
||||||
"customStyle"?: string;
|
|
||||||
/**
|
|
||||||
* Emitted when the card toggles open/close state
|
|
||||||
*/
|
|
||||||
"onToggle"?: (event: EwsCardCustomEvent<void>) => void;
|
|
||||||
}
|
|
||||||
interface EwsHexGrid {
|
|
||||||
/**
|
|
||||||
* Additional CSS class for the container.
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"customClass"?: string;
|
|
||||||
/**
|
|
||||||
* Gap between hex cells in pixels.
|
|
||||||
* @default 4
|
|
||||||
*/
|
|
||||||
"gap"?: number;
|
|
||||||
/**
|
|
||||||
* Height of each hex cell in pixels.
|
|
||||||
*/
|
|
||||||
"hexHeight"?: number;
|
|
||||||
/**
|
|
||||||
* Width of each hex cell in pixels.
|
|
||||||
*/
|
|
||||||
"hexWidth"?: number;
|
|
||||||
/**
|
|
||||||
* Hex orientation variant: 'pointy' or 'flat'.
|
|
||||||
* @default 'pointy'
|
|
||||||
*/
|
|
||||||
"variant"?: 'pointy' | 'flat';
|
|
||||||
}
|
|
||||||
interface EwsHexShape {
|
|
||||||
/**
|
|
||||||
* Whether to clip content within the hex shape.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
"clipContent"?: boolean;
|
|
||||||
/**
|
|
||||||
* The color variant of the hex shape.
|
|
||||||
* @default 'orange'
|
|
||||||
*/
|
|
||||||
"color"?: string;
|
|
||||||
/**
|
|
||||||
* Additional CSS classes for the component.
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"customClass"?: string;
|
|
||||||
/**
|
|
||||||
* Whether the hex shape has a flat top.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
"flatTop"?: boolean;
|
|
||||||
/**
|
|
||||||
* The padding inside the hex shape for content.
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
"paddingContent"?: number;
|
|
||||||
}
|
|
||||||
interface EwsRibLayout {
|
|
||||||
/**
|
|
||||||
* Optional renderer function for the connector content.
|
|
||||||
*/
|
|
||||||
"connectorRenderer"?: (item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) => any;
|
|
||||||
/**
|
|
||||||
* Function to get the href for a node. If provided, nodes will be rendered as <a> tags.
|
|
||||||
*/
|
|
||||||
"getHref"?: (item: any) => string;
|
|
||||||
/**
|
|
||||||
* Array of items to be displayed in the rib cage layout.
|
|
||||||
* @default []
|
|
||||||
*/
|
|
||||||
"items"?: any[];
|
|
||||||
/**
|
|
||||||
* Maximum number of branches to display. If not provided, it defaults to 5 (responsive).
|
|
||||||
*/
|
|
||||||
"maxBranches"?: number;
|
|
||||||
/**
|
|
||||||
* Optional renderer function for the node content.
|
|
||||||
*/
|
|
||||||
"nodeRenderer"?: (item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) => any;
|
|
||||||
}
|
|
||||||
interface EwsStripeBar {
|
|
||||||
/**
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"color"?: string;
|
|
||||||
/**
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
"duration"?: number;
|
|
||||||
/**
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
"loop"?: boolean;
|
|
||||||
/**
|
|
||||||
* @default ''
|
|
||||||
*/
|
|
||||||
"orientation"?: string;
|
|
||||||
/**
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
"reverse"?: boolean;
|
|
||||||
/**
|
|
||||||
* @default '30px'
|
|
||||||
*/
|
|
||||||
"size"?: string;
|
|
||||||
}
|
|
||||||
interface MyComponent {
|
|
||||||
/**
|
|
||||||
* The first name
|
|
||||||
*/
|
|
||||||
"first"?: string;
|
|
||||||
/**
|
|
||||||
* The last name
|
|
||||||
*/
|
|
||||||
"last"?: string;
|
|
||||||
/**
|
|
||||||
* The middle name
|
|
||||||
*/
|
|
||||||
"middle"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EwsCardAttributes {
|
|
||||||
"customClass": string;
|
|
||||||
"color": string;
|
|
||||||
"customStyle": string;
|
|
||||||
}
|
|
||||||
interface EwsHexGridAttributes {
|
|
||||||
"customClass": string;
|
|
||||||
"variant": 'pointy' | 'flat';
|
|
||||||
"hexWidth": number;
|
|
||||||
"hexHeight": number;
|
|
||||||
"gap": number;
|
|
||||||
}
|
|
||||||
interface EwsHexShapeAttributes {
|
|
||||||
"customClass": string;
|
|
||||||
"color": string;
|
|
||||||
"flatTop": boolean;
|
|
||||||
"clipContent": boolean;
|
|
||||||
"paddingContent": number;
|
|
||||||
}
|
|
||||||
interface EwsRibLayoutAttributes {
|
|
||||||
"maxBranches": number;
|
|
||||||
}
|
|
||||||
interface EwsStripeBarAttributes {
|
|
||||||
"color": string;
|
|
||||||
"orientation": string;
|
|
||||||
"loop": boolean;
|
|
||||||
"reverse": boolean;
|
|
||||||
"duration": number;
|
|
||||||
"size": string;
|
|
||||||
}
|
|
||||||
interface MyComponentAttributes {
|
|
||||||
"first": string;
|
|
||||||
"middle": string;
|
|
||||||
"last": string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IntrinsicElements {
|
|
||||||
"ews-card": Omit<EwsCard, keyof EwsCardAttributes> & { [K in keyof EwsCard & keyof EwsCardAttributes]?: EwsCard[K] } & { [K in keyof EwsCard & keyof EwsCardAttributes as `attr:${K}`]?: EwsCardAttributes[K] } & { [K in keyof EwsCard & keyof EwsCardAttributes as `prop:${K}`]?: EwsCard[K] };
|
|
||||||
"ews-hex-grid": Omit<EwsHexGrid, keyof EwsHexGridAttributes> & { [K in keyof EwsHexGrid & keyof EwsHexGridAttributes]?: EwsHexGrid[K] } & { [K in keyof EwsHexGrid & keyof EwsHexGridAttributes as `attr:${K}`]?: EwsHexGridAttributes[K] } & { [K in keyof EwsHexGrid & keyof EwsHexGridAttributes as `prop:${K}`]?: EwsHexGrid[K] };
|
|
||||||
"ews-hex-shape": Omit<EwsHexShape, keyof EwsHexShapeAttributes> & { [K in keyof EwsHexShape & keyof EwsHexShapeAttributes]?: EwsHexShape[K] } & { [K in keyof EwsHexShape & keyof EwsHexShapeAttributes as `attr:${K}`]?: EwsHexShapeAttributes[K] } & { [K in keyof EwsHexShape & keyof EwsHexShapeAttributes as `prop:${K}`]?: EwsHexShape[K] };
|
|
||||||
"ews-rib-layout": Omit<EwsRibLayout, keyof EwsRibLayoutAttributes> & { [K in keyof EwsRibLayout & keyof EwsRibLayoutAttributes]?: EwsRibLayout[K] } & { [K in keyof EwsRibLayout & keyof EwsRibLayoutAttributes as `attr:${K}`]?: EwsRibLayoutAttributes[K] } & { [K in keyof EwsRibLayout & keyof EwsRibLayoutAttributes as `prop:${K}`]?: EwsRibLayout[K] };
|
|
||||||
"ews-stripe-bar": Omit<EwsStripeBar, keyof EwsStripeBarAttributes> & { [K in keyof EwsStripeBar & keyof EwsStripeBarAttributes]?: EwsStripeBar[K] } & { [K in keyof EwsStripeBar & keyof EwsStripeBarAttributes as `attr:${K}`]?: EwsStripeBarAttributes[K] } & { [K in keyof EwsStripeBar & keyof EwsStripeBarAttributes as `prop:${K}`]?: EwsStripeBar[K] };
|
|
||||||
"my-component": Omit<MyComponent, keyof MyComponentAttributes> & { [K in keyof MyComponent & keyof MyComponentAttributes]?: MyComponent[K] } & { [K in keyof MyComponent & keyof MyComponentAttributes as `attr:${K}`]?: MyComponentAttributes[K] } & { [K in keyof MyComponent & keyof MyComponentAttributes as `prop:${K}`]?: MyComponent[K] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export { LocalJSX as JSX };
|
|
||||||
declare module "@stencil/core" {
|
|
||||||
export namespace JSX {
|
|
||||||
interface IntrinsicElements {
|
|
||||||
"ews-card": LocalJSX.IntrinsicElements["ews-card"] & JSXBase.HTMLAttributes<HTMLEwsCardElement>;
|
|
||||||
"ews-hex-grid": LocalJSX.IntrinsicElements["ews-hex-grid"] & JSXBase.HTMLAttributes<HTMLEwsHexGridElement>;
|
|
||||||
"ews-hex-shape": LocalJSX.IntrinsicElements["ews-hex-shape"] & JSXBase.HTMLAttributes<HTMLEwsHexShapeElement>;
|
|
||||||
"ews-rib-layout": LocalJSX.IntrinsicElements["ews-rib-layout"] & JSXBase.HTMLAttributes<HTMLEwsRibLayoutElement>;
|
|
||||||
"ews-stripe-bar": LocalJSX.IntrinsicElements["ews-stripe-bar"] & JSXBase.HTMLAttributes<HTMLEwsStripeBarElement>;
|
|
||||||
"my-component": LocalJSX.IntrinsicElements["my-component"] & JSXBase.HTMLAttributes<HTMLMyComponentElement>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { render, h, describe, it, expect } from '@stencil/vitest';
|
|
||||||
|
|
||||||
describe('ews-card', () => {
|
|
||||||
it('renders', async () => {
|
|
||||||
const { root } = await render(<ews-card></ews-card>);
|
|
||||||
await expect(root).toBeDefined();
|
|
||||||
await expect(root.querySelector('.ews-card')).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
.ews-card {
|
|
||||||
--ews-card-color: var(--orange, #fa0);
|
|
||||||
--ews-card-radius: var(--gutter-size, 8px);
|
|
||||||
--ews-card-border-width: 3px;
|
|
||||||
background-color: black;
|
|
||||||
transition: 0.3s;
|
|
||||||
|
|
||||||
border-radius: var(--ews-card-radius);
|
|
||||||
border-style: solid;
|
|
||||||
border-width: var(--ews-card-border-width);
|
|
||||||
border-color: var(--ews-card-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card.ews-card-red {
|
|
||||||
--ews-card-color: var(--red, #f23);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-header {
|
|
||||||
/* padding: 6px; */
|
|
||||||
color: var(--ews-card-color);
|
|
||||||
position: relative;
|
|
||||||
border-radius: 10px 10px 0px 0px;
|
|
||||||
border-bottom: var(--ews-card-border-width) solid var(--ews-card-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-header .ews-card-text {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-header button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-footer {
|
|
||||||
/* padding: 6px; */
|
|
||||||
color: var(--ews-card-color);
|
|
||||||
position: relative;
|
|
||||||
border-radius: 0px 0px 10px 10px;
|
|
||||||
border-top: var(--ews-card-border-width) solid var(--ews-card-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-footer .ews-card-text {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-content {
|
|
||||||
color: var(--ews-card-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.ews-card-content::-webkit-scrollbar-track {
|
|
||||||
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: rgb(61, 61, 61);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-content::-webkit-scrollbar {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background-color: rgb(61, 61, 61);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-content::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: 10px;
|
|
||||||
-webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
|
|
||||||
background-color: var(--red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-content tbody {
|
|
||||||
font-size: 10px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-float {
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-float .ews-card-content {
|
|
||||||
display: block;
|
|
||||||
max-height: 45vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-close-button {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #e60003;
|
|
||||||
padding: 2px 4px;
|
|
||||||
background-color: black !important;
|
|
||||||
right: 10px !important;
|
|
||||||
top: 10px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
|
|
||||||
.ews-card {
|
|
||||||
--ews-card-border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-float .ews-card-content {
|
|
||||||
display: none;
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-float.open .ews-card-content {
|
|
||||||
display: block;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-float {
|
|
||||||
margin: auto;
|
|
||||||
right: 0.25rem;
|
|
||||||
left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-float .ews-card-header {
|
|
||||||
border-bottom: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-card-header {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Component, Prop, State, Event, EventEmitter, Element, h } from '@stencil/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'ews-card',
|
|
||||||
styleUrl: 'ews-card.css',
|
|
||||||
shadow: false,
|
|
||||||
})
|
|
||||||
export class EwsCard {
|
|
||||||
@Element() el: HTMLElement;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Additional CSS classes to apply to the card wrapper
|
|
||||||
*/
|
|
||||||
@Prop() customClass: string = '';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom color for border and content (red or orange)
|
|
||||||
*/
|
|
||||||
@Prop() color?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inline style
|
|
||||||
*/
|
|
||||||
@Prop() customStyle?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks whether the card content is toggled open
|
|
||||||
*/
|
|
||||||
@State() open: boolean = false;
|
|
||||||
|
|
||||||
@State() hasHeader: boolean = false;
|
|
||||||
@State() hasFooter: boolean = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emitted when the card toggles open/close state
|
|
||||||
*/
|
|
||||||
@Event() toggle: EventEmitter<void>;
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
this.checkSlots();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.checkSlots();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
this.checkSlots();
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkSlots() {
|
|
||||||
if (!this.el) return;
|
|
||||||
const headerSlot = !!this.el.querySelector('[slot="header"]');
|
|
||||||
const footerSlot = !!this.el.querySelector('[slot="footer"]');
|
|
||||||
|
|
||||||
if (this.hasHeader !== headerSlot) {
|
|
||||||
this.hasHeader = headerSlot;
|
|
||||||
}
|
|
||||||
if (this.hasFooter !== footerSlot) {
|
|
||||||
this.hasFooter = footerSlot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleToggle = () => {
|
|
||||||
this.open = !this.open;
|
|
||||||
this.toggle.emit();
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={`ews-card ${this.customClass} ews-card-${this.color} ${this.open ? 'open' : ''}`.trim()}
|
|
||||||
style={this.customStyle ? { style: this.customStyle } : {}}
|
|
||||||
>
|
|
||||||
{this.hasHeader && (
|
|
||||||
<div
|
|
||||||
class="ews-card-header"
|
|
||||||
onClick={this.handleToggle}
|
|
||||||
>
|
|
||||||
<slot name="header" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="ews-card-content">
|
|
||||||
<slot />
|
|
||||||
<slot name="content" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.hasFooter && (
|
|
||||||
<div class="ews-card-footer">
|
|
||||||
<slot name="footer" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# ews-card
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Auto Generated Below -->
|
|
||||||
|
|
||||||
|
|
||||||
## Properties
|
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
|
||||||
| ------------- | -------------- | --------------------------------------------------- | -------- | ----------- |
|
|
||||||
| `color` | `color` | Custom color for border and content (red or orange) | `string` | `undefined` |
|
|
||||||
| `customClass` | `custom-class` | Additional CSS classes to apply to the card wrapper | `string` | `''` |
|
|
||||||
| `customStyle` | `custom-style` | Inline style | `string` | `undefined` |
|
|
||||||
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
| Event | Description | Type |
|
|
||||||
| -------- | ---------------------------------------------- | ------------------- |
|
|
||||||
| `toggle` | Emitted when the card toggles open/close state | `CustomEvent<void>` |
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
*Built with [StencilJS](https://stenciljs.com/)*
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
/* =============================================
|
|
||||||
HEXAGONAL GRID STYLES
|
|
||||||
============================================= */
|
|
||||||
|
|
||||||
/* --- Shared hex clip-path (flat-top orientation) --- */
|
|
||||||
.ews-hex-clip {
|
|
||||||
clip-path: polygon(24.96% 100%,
|
|
||||||
0% 50%,
|
|
||||||
24.96% 0%,
|
|
||||||
74.87% 0%,
|
|
||||||
99.84% 50%,
|
|
||||||
74.87% 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Shared hex clip-path (pointy-top / rotated 90°) --- */
|
|
||||||
.ews-hex-clip-pointy {
|
|
||||||
clip-path: polygon(0% 25.13%,
|
|
||||||
50% 0%,
|
|
||||||
100% 25.13%,
|
|
||||||
100% 74.87%,
|
|
||||||
50% 100%,
|
|
||||||
0% 74.87%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 1. Basic Flat Hex Grid ---- */
|
|
||||||
:host .ews-hex-grid-flat {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-cell-flat) {
|
|
||||||
position: relative;
|
|
||||||
width: 80px;
|
|
||||||
height: 70px;
|
|
||||||
aspect-ratio: 584 / 507;
|
|
||||||
clip-path: polygon(24.96% 100%, 0% 50%, 24.96% 0%, 74.87% 0%, 99.84% 50%, 74.87% 100%);
|
|
||||||
background-color: rgba(255, 170, 0, 0.08);
|
|
||||||
border: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.25s ease, transform 0.2s ease;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-cell-flat:hover) {
|
|
||||||
background-color: rgba(255, 170, 0, 0.18);
|
|
||||||
transform: scale(1.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-cell-flat.ews-hex-danger) {
|
|
||||||
background-color: rgba(255, 34, 51, 0.15);
|
|
||||||
box-shadow: 0 0 12px 2px rgba(255, 34, 51, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-cell-flat.ews-hex-warn) {
|
|
||||||
background-color: rgba(255, 170, 0, 0.15);
|
|
||||||
box-shadow: 0 0 10px 2px rgba(255, 170, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-cell-flat.ews-hex-safe) {
|
|
||||||
background-color: rgba(0, 200, 80, 0.12);
|
|
||||||
box-shadow: 0 0 8px 1px rgba(0, 200, 80, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
color: var(--orange);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 2. Honeycomb Offset Grid ---- */
|
|
||||||
.ews-hex-honeycomb {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host .ews-hex-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host .ews-hex-row-offset {
|
|
||||||
margin-left: calc(72px / 2 + 2px);
|
|
||||||
margin-top: -14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-hive) {
|
|
||||||
position: relative;
|
|
||||||
width: 72px;
|
|
||||||
height: 83px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-hive.ews-hex-danger) {
|
|
||||||
background-color: rgba(255, 34, 51, 0.18);
|
|
||||||
filter: drop-shadow(0 0 8px rgba(255, 34, 51, 0.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-hive.ews-hex-warn) {
|
|
||||||
background-color: rgba(255, 170, 0, 0.18);
|
|
||||||
filter: drop-shadow(0 0 6px rgba(255, 170, 0, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-hive-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 3. Animated Status Hex Cells ---- */
|
|
||||||
::slotted(.ews-hex-status-cell) {
|
|
||||||
position: relative;
|
|
||||||
width: 90px;
|
|
||||||
height: 104px;
|
|
||||||
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
|
|
||||||
background-color: rgba(255, 170, 0, 0.06);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-status-cell:hover) {
|
|
||||||
transform: scale(1.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-status-cell.ews-hex-danger) {
|
|
||||||
background-color: rgba(255, 34, 51, 0.12);
|
|
||||||
filter: drop-shadow(0 0 10px rgba(255, 34, 51, 0.45));
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-status-cell.ews-hex-warn) {
|
|
||||||
background-color: rgba(255, 170, 0, 0.12);
|
|
||||||
filter: drop-shadow(0 0 8px rgba(255, 170, 0, 0.4));
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-status-cell.ews-hex-caution) {
|
|
||||||
background-color: rgba(255, 255, 0, 0.08);
|
|
||||||
filter: drop-shadow(0 0 6px rgba(255, 255, 0, 0.25));
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-status-cell.ews-hex-safe) {
|
|
||||||
background-color: rgba(0, 200, 80, 0.08);
|
|
||||||
filter: drop-shadow(0 0 6px rgba(0, 200, 80, 0.2));
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-status-cell.ews-hex-pulse) {
|
|
||||||
animation: hexPulse 1.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes hexPulse {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
filter: drop-shadow(0 0 8px rgba(255, 34, 51, 0.3));
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
filter: drop-shadow(0 0 22px rgba(255, 34, 51, 0.85));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-status-inner {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 2px;
|
|
||||||
color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 4. Hex with Strip Decoration ---- */
|
|
||||||
::slotted(.ews-hex-stripe-cell) {
|
|
||||||
position: relative;
|
|
||||||
width: 110px;
|
|
||||||
height: 127px;
|
|
||||||
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: rgba(255, 170, 0, 0.05);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-stripe-cell:hover) {
|
|
||||||
transform: scale(1.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
::slotted(.ews-hex-stripe-cell.ews-hex-danger) {
|
|
||||||
background-color: rgba(255, 34, 51, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-stripe-bg {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-stripe-content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 1px;
|
|
||||||
color: var(--orange);
|
|
||||||
background-color: rgba(0, 0, 0, 0.55);
|
|
||||||
padding: 6px 10px;
|
|
||||||
clip-path: polygon(0% 25.13%, 50% 0%, 100% 25.13%, 100% 74.87%, 50% 100%, 0% 74.87%);
|
|
||||||
width: 88%;
|
|
||||||
height: 88%;
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { Component, Host, h, Prop, Element, Watch, State } from '@stencil/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'ews-hex-grid',
|
|
||||||
styleUrl: 'ews-hex-grid.css',
|
|
||||||
shadow: true,
|
|
||||||
})
|
|
||||||
export class EwsHexGrid {
|
|
||||||
/**
|
|
||||||
* Additional CSS class for the container.
|
|
||||||
*/
|
|
||||||
@Prop() customClass: string = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hex orientation variant: 'pointy' or 'flat'.
|
|
||||||
*/
|
|
||||||
@Prop() variant: 'pointy' | 'flat' = 'pointy';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Width of each hex cell in pixels.
|
|
||||||
*/
|
|
||||||
@Prop() hexWidth: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Height of each hex cell in pixels.
|
|
||||||
*/
|
|
||||||
@Prop() hexHeight: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gap between hex cells in pixels.
|
|
||||||
*/
|
|
||||||
@Prop() gap: number = 4;
|
|
||||||
|
|
||||||
@Element() el: HTMLElement;
|
|
||||||
|
|
||||||
@State() containerHeight: string = 'auto';
|
|
||||||
|
|
||||||
private ro: ResizeObserver;
|
|
||||||
private mo: MutationObserver;
|
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.setupLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
if (this.ro) this.ro.disconnect();
|
|
||||||
if (this.mo) this.mo.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Watch('variant')
|
|
||||||
@Watch('hexWidth')
|
|
||||||
@Watch('hexHeight')
|
|
||||||
@Watch('gap')
|
|
||||||
onPropChange() {
|
|
||||||
this.layout();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupLayout() {
|
|
||||||
// Delay initial layout to next tick to ensure styles are computed
|
|
||||||
setTimeout(() => this.layout(), 0);
|
|
||||||
|
|
||||||
this.ro = new ResizeObserver(() => this.layout());
|
|
||||||
this.ro.observe(this.el);
|
|
||||||
|
|
||||||
const slot = this.el.shadowRoot.querySelector('slot');
|
|
||||||
if (slot) {
|
|
||||||
slot.addEventListener('slotchange', () => this.layout());
|
|
||||||
}
|
|
||||||
|
|
||||||
this.mo = new MutationObserver(() => this.layout());
|
|
||||||
this.mo.observe(this.el, { childList: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private layout() {
|
|
||||||
const container = this.el.shadowRoot.querySelector('.ews-hex-honeycomb') as HTMLElement;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const containerWidth = container.clientWidth;
|
|
||||||
if (!containerWidth) return;
|
|
||||||
|
|
||||||
const slot = container.querySelector('slot') as HTMLSlotElement;
|
|
||||||
if (!slot) return;
|
|
||||||
|
|
||||||
const childElements = slot.assignedElements() as HTMLElement[];
|
|
||||||
if (childElements.length === 0) return;
|
|
||||||
|
|
||||||
const isFlat = this.variant === 'flat';
|
|
||||||
const w = this.hexWidth ?? (isFlat ? 83 : 72);
|
|
||||||
const h = this.hexHeight ?? (isFlat ? 72 : 83);
|
|
||||||
const gap = this.gap;
|
|
||||||
|
|
||||||
if (!isFlat) {
|
|
||||||
// Pointy (Variant 1)
|
|
||||||
const rowOffsetTop = gap + -20;
|
|
||||||
const itemFullWidth = w + gap;
|
|
||||||
|
|
||||||
let maxCols = Math.floor((containerWidth + gap) / itemFullWidth);
|
|
||||||
if (maxCols < 1) maxCols = 1;
|
|
||||||
|
|
||||||
let isOffset = false;
|
|
||||||
let currentCol = 0;
|
|
||||||
let currentRow = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < childElements.length; i++) {
|
|
||||||
const child = childElements[i];
|
|
||||||
const colsInThisRow = isOffset ? Math.max(1, maxCols - 1) : maxCols;
|
|
||||||
|
|
||||||
let x = currentCol * itemFullWidth;
|
|
||||||
if (isOffset) {
|
|
||||||
x += w / 2 + gap / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const y = currentRow * (h + rowOffsetTop);
|
|
||||||
|
|
||||||
child.style.position = 'absolute';
|
|
||||||
child.style.left = `${x}px`;
|
|
||||||
child.style.top = `${y}px`;
|
|
||||||
child.style.margin = '0';
|
|
||||||
child.style.width = `${w}px`;
|
|
||||||
child.style.height = `${h}px`;
|
|
||||||
|
|
||||||
currentCol++;
|
|
||||||
if (currentCol >= colsInThisRow) {
|
|
||||||
currentCol = 0;
|
|
||||||
isOffset = !isOffset;
|
|
||||||
currentRow++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalHeight = 0;
|
|
||||||
if (currentCol > 0) {
|
|
||||||
totalHeight = currentRow * (h + rowOffsetTop) + h;
|
|
||||||
} else {
|
|
||||||
totalHeight = (currentRow - 1) * (h + rowOffsetTop) + h;
|
|
||||||
}
|
|
||||||
this.containerHeight = `${totalHeight}px`;
|
|
||||||
} else {
|
|
||||||
// Flat (Variant 2)
|
|
||||||
const colAdvanceX = w * 0.75 + gap;
|
|
||||||
const rowAdvanceY = h + gap;
|
|
||||||
|
|
||||||
let maxCols = Math.floor((containerWidth - w) / colAdvanceX) + 1;
|
|
||||||
if (containerWidth < w) maxCols = 1;
|
|
||||||
|
|
||||||
let currentCol = 0;
|
|
||||||
let currentRow = 0;
|
|
||||||
let maxBottom = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < childElements.length; i++) {
|
|
||||||
const child = childElements[i];
|
|
||||||
|
|
||||||
let x = currentCol * colAdvanceX;
|
|
||||||
let y = currentRow * rowAdvanceY;
|
|
||||||
|
|
||||||
// Offset odd columns down
|
|
||||||
if (currentCol % 2 === 1) {
|
|
||||||
y += rowAdvanceY / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
child.style.position = 'absolute';
|
|
||||||
child.style.left = `${x}px`;
|
|
||||||
child.style.top = `${y}px`;
|
|
||||||
child.style.margin = '0';
|
|
||||||
child.style.width = `${w}px`;
|
|
||||||
child.style.height = `${h}px`;
|
|
||||||
|
|
||||||
const bottom = y + h;
|
|
||||||
if (bottom > maxBottom) maxBottom = bottom;
|
|
||||||
|
|
||||||
currentCol++;
|
|
||||||
if (currentCol >= maxCols) {
|
|
||||||
currentCol = 0;
|
|
||||||
currentRow++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.containerHeight = `${maxBottom}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Host>
|
|
||||||
<div
|
|
||||||
class={`ews-hex-honeycomb ${this.customClass}`.trim()}
|
|
||||||
style={{ position: 'relative', display: 'block', height: this.containerHeight }}
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# ews-hex-grid
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Auto Generated Below -->
|
|
||||||
|
|
||||||
|
|
||||||
## Properties
|
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
|
||||||
| ------------- | -------------- | -------------------------------------------- | -------------------- | ----------- |
|
|
||||||
| `customClass` | `custom-class` | Additional CSS class for the container. | `string` | `''` |
|
|
||||||
| `gap` | `gap` | Gap between hex cells in pixels. | `number` | `4` |
|
|
||||||
| `hexHeight` | `hex-height` | Height of each hex cell in pixels. | `number` | `undefined` |
|
|
||||||
| `hexWidth` | `hex-width` | Width of each hex cell in pixels. | `number` | `undefined` |
|
|
||||||
| `variant` | `variant` | Hex orientation variant: 'pointy' or 'flat'. | `"flat" \| "pointy"` | `'pointy'` |
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
*Built with [StencilJS](https://stenciljs.com/)*
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 0.866 / 1;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='115' height='133' viewBox='0 0 115 133' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z' fill='%23fa0'/%3E%3Cpath d='M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z' fill='black'/%3E%3Cpath d='M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z' fill='%23fa0'/%3E%3C/svg%3E%0A");
|
|
||||||
background-size: contain;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
--polygon-shape: polygon(0% 25.13%,
|
|
||||||
/* top-left point */
|
|
||||||
50% 0%,
|
|
||||||
/* top center point */
|
|
||||||
100% 25.13%,
|
|
||||||
/* top-right point */
|
|
||||||
100% 74.87%,
|
|
||||||
/* bottom-right point */
|
|
||||||
50% 100%,
|
|
||||||
/* bottom center point */
|
|
||||||
0% 74.87%
|
|
||||||
/* bottom-left point */
|
|
||||||
);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.clip-content {
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: var(--polygon-shape);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.clip-content .inner-content {
|
|
||||||
--ews-hex-padding: 10px;
|
|
||||||
width: calc(100% - var(--ews-hex-padding));
|
|
||||||
height: calc(100% - var(--ews-hex-padding));
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: var(--polygon-shape);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.flat-top {
|
|
||||||
aspect-ratio: 1.1547 / 1;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='584' height='507' viewBox='0 0 584 507' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z' fill='%23E60003'/%3E%3Cpath d='M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z' fill='black'/%3E%3Cpath d='M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z' fill='%23E60003'/%3E%3C/svg%3E%0A");
|
|
||||||
--polygon-shape: polygon(24.96% 100%,
|
|
||||||
/* 145.77/584, 507/507 */
|
|
||||||
0% 50%,
|
|
||||||
/* 0/584, 253.5/507 */
|
|
||||||
24.96% 0%,
|
|
||||||
/* 145.77/584, 0/507 */
|
|
||||||
74.87% 0%,
|
|
||||||
/* 437.28/584, 0/507 */
|
|
||||||
99.84% 50%,
|
|
||||||
/* 583.05/584, 253.5/507 */
|
|
||||||
74.87% 100%
|
|
||||||
/* 437.28/584, 507/507 */
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.orange {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='115' height='133' viewBox='0 0 115 133' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z' fill='%23fa0'/%3E%3Cpath d='M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z' fill='black'/%3E%3Cpath d='M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z' fill='%23fa0'/%3E%3C/svg%3E%0A");
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.orange.flat-top {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='584' height='507' viewBox='0 0 584 507' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z' fill='%23fa0'/%3E%3Cpath d='M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z' fill='black'/%3E%3Cpath d='M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z' fill='%23fa0'/%3E%3C/svg%3E%0A");
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.red {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='115' height='133' viewBox='0 0 115 133' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M115 99.1859L57.5 132.25L7.62939e-06 99.1859L7.62939e-06 33.0641L57.5 3.05176e-05L115 33.0641L115 99.1859Z' fill='%23E60003'/%3E%3Cpath d='M113.69 98.4426L57.6307 130.678L1.57149 98.4426L1.57149 33.9776L57.6307 1.74199L113.69 33.9776L113.69 98.4426Z' fill='black'/%3E%3Cpath d='M111.071 97.0671L57.6309 127.796L4.1913 97.0671L4.1913 35.6145L57.6309 4.88519L111.071 35.6145L111.071 97.0671Z' fill='%23E60003'/%3E%3C/svg%3E%0A");
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-hex-shape.red.flat-top {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='584' height='507' viewBox='0 0 584 507' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M145.77 507L0 253.5L145.77 -3.05176e-05H437.28L583.05 253.5L437.28 507H145.77Z' fill='%23E60003'/%3E%3Cpath d='M149.046 500.648L6.92932 253.5L149.046 6.35181H433.253L575.37 253.5L433.253 500.648H149.046Z' fill='black'/%3E%3Cpath d='M155.007 492L18 253.5L155.007 15H428.993L566 253.5L428.993 492H155.007Z' fill='%23E60003'/%3E%3C/svg%3E%0A");
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Component, Host, h, Prop } from '@stencil/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'ews-hex-shape',
|
|
||||||
styleUrl: 'ews-hex-shape.css',
|
|
||||||
shadow: true,
|
|
||||||
})
|
|
||||||
export class EwsHexShape {
|
|
||||||
/**
|
|
||||||
* Additional CSS classes for the component.
|
|
||||||
*/
|
|
||||||
@Prop() customClass: string = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The color variant of the hex shape.
|
|
||||||
*/
|
|
||||||
@Prop() color: string = 'orange';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the hex shape has a flat top.
|
|
||||||
*/
|
|
||||||
@Prop() flatTop: boolean = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to clip content within the hex shape.
|
|
||||||
*/
|
|
||||||
@Prop() clipContent: boolean = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The padding inside the hex shape for content.
|
|
||||||
*/
|
|
||||||
@Prop() paddingContent: number = 10;
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const classes = {
|
|
||||||
'ews-hex-shape': true,
|
|
||||||
'flat-top': this.flatTop,
|
|
||||||
'clip-content': this.clipContent,
|
|
||||||
[this.color]: !!this.color,
|
|
||||||
[this.customClass]: !!this.customClass,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Host>
|
|
||||||
<div class={classes}>
|
|
||||||
<div
|
|
||||||
class="inner-content"
|
|
||||||
style={{ '--ews-hex-padding': `${this.paddingContent}px` }}
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# ews-hex-shape
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Auto Generated Below -->
|
|
||||||
|
|
||||||
|
|
||||||
## Properties
|
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
|
||||||
| ---------------- | ----------------- | --------------------------------------------- | --------- | ---------- |
|
|
||||||
| `clipContent` | `clip-content` | Whether to clip content within the hex shape. | `boolean` | `false` |
|
|
||||||
| `color` | `color` | The color variant of the hex shape. | `string` | `'orange'` |
|
|
||||||
| `customClass` | `custom-class` | Additional CSS classes for the component. | `string` | `''` |
|
|
||||||
| `flatTop` | `flat-top` | Whether the hex shape has a flat top. | `boolean` | `true` |
|
|
||||||
| `paddingContent` | `padding-content` | The padding inside the hex shape for content. | `number` | `10` |
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
*Built with [StencilJS](https://stenciljs.com/)*
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout {
|
|
||||||
display: inline-flex;
|
|
||||||
height: auto;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__branch {
|
|
||||||
position: relative;
|
|
||||||
padding-top: 1rem;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.ews-rib-layout__branch {
|
|
||||||
padding-top: 2.5rem;
|
|
||||||
padding-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__spine {
|
|
||||||
position: absolute;
|
|
||||||
height: auto;
|
|
||||||
left: 50%;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 0.25rem;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 0;
|
|
||||||
background-color: var(--orange, #FC8416);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__node {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__node--left {
|
|
||||||
flex-grow: 1;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-right: 0;
|
|
||||||
grid-column-start: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__node--right {
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding-left: 0;
|
|
||||||
grid-column-start: 2;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__node-content {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-wrapper {
|
|
||||||
width: 6rem;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-wrapper--left {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-wrapper--right {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-line {
|
|
||||||
height: 2px;
|
|
||||||
width: 6rem;
|
|
||||||
z-index: 0;
|
|
||||||
background-color: var(--orange, #FC8416);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-text {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
position: absolute;
|
|
||||||
top: 0.25rem;
|
|
||||||
z-index: 10;
|
|
||||||
color: var(--orange, #FC8416);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-text--left {
|
|
||||||
left: 0.5rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__connector-text--right {
|
|
||||||
right: 0.5rem;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-node {
|
|
||||||
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M500 0H37.5414L-1.63437e-05 100H462.459L500 0Z" fill="%2300FF80"/><path d="M168.5 0H37.1618L30.5 19H161.838L168.5 0Z" fill="%23FC8416"/></svg>');
|
|
||||||
background-image: var(--bg-url);
|
|
||||||
background-size: contain;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
width: 6rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-right: -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parent-node {
|
|
||||||
transform: translateX(0px);
|
|
||||||
rotate: -21deg !important;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
margin-left: 0px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parent-node.flip {
|
|
||||||
margin-left: -0.5rem;
|
|
||||||
rotate: 21deg !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__node--left:hover .parent-node {
|
|
||||||
cursor: pointer;
|
|
||||||
transform: translateX(-20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-node.danger {
|
|
||||||
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M500 0H37.5414L-1.63437e-05 100H462.459L500 0Z" fill="%23E60003"/><path d="M168.5 0H37.1618L30.5 19H161.838L168.5 0Z" fill="%23FC8416"/></svg>');
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-node.flip {
|
|
||||||
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 0H462.459L500 100H37.5415L0 0Z" fill="%2300FF80"/><path d="M0 0H131.338L138 19H6.66178L0 0Z" fill="%23FC8416"/></svg>');
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-layout__node--right:hover .parent-node {
|
|
||||||
cursor: pointer;
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-node.flip.danger {
|
|
||||||
--bg-url: url('data:image/svg+xml,<svg width="500" height="100" viewBox="0 0 500 100" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 0H462.459L500 100H37.5415L0 0Z" fill="%23E60003"/><path d="M0 0H131.338L138 19H6.66178L0 0Z" fill="%23FC8416"/></svg>');
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-node.slide-fade-in {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-20px);
|
|
||||||
animation: slideFadeIn 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-rib-node-flip.slide-fade-in {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
animation: slideFadeInFlip 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-fade-in {
|
|
||||||
animation: slideFadeIn 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideFadeIn {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-15px);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-fade-in-flip {
|
|
||||||
animation: slideFadeInFlip 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideFadeInFlip {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(15px);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
opacity: 0;
|
|
||||||
animation: fadeIn 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animation-delay-5 {
|
|
||||||
animation-delay: 500ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-node {
|
|
||||||
width: 0%;
|
|
||||||
animation: slideWidth 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideWidth {
|
|
||||||
0% {
|
|
||||||
width: 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
width: 6rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-central {
|
|
||||||
height: 0%;
|
|
||||||
animation: slideHeight 0.5s ease-in-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideHeight {
|
|
||||||
0% {
|
|
||||||
height: 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { Component, Host, h, Prop, State, Listen } from '@stencil/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'ews-rib-layout',
|
|
||||||
styleUrl: 'ews-rib-layout.css',
|
|
||||||
shadow: true,
|
|
||||||
})
|
|
||||||
export class EwsRibLayout {
|
|
||||||
/**
|
|
||||||
* Array of items to be displayed in the rib cage layout.
|
|
||||||
*/
|
|
||||||
@Prop() items: any[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function to get the href for a node. If provided, nodes will be rendered as <a> tags.
|
|
||||||
*/
|
|
||||||
@Prop() getHref?: (item: any) => string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional renderer function for the node content.
|
|
||||||
*/
|
|
||||||
@Prop() nodeRenderer?: (item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) => any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional renderer function for the connector content.
|
|
||||||
*/
|
|
||||||
@Prop() connectorRenderer?: (item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) => any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maximum number of branches to display. If not provided, it defaults to 5 (responsive).
|
|
||||||
*/
|
|
||||||
@Prop() maxBranches?: number;
|
|
||||||
|
|
||||||
@State() branchCount: number = 5;
|
|
||||||
@State() windowWidth: number = 0;
|
|
||||||
|
|
||||||
componentWillLoad() {
|
|
||||||
this.handleResize();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Listen('resize', { target: 'window' })
|
|
||||||
handleResize() {
|
|
||||||
this.windowWidth = typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
||||||
this.branchCount = this.getBranchCount(this.windowWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getBranchCount(width: number): number {
|
|
||||||
let count = 5;
|
|
||||||
if (width < 768) count = 1;
|
|
||||||
else if (width < 1024) count = 2;
|
|
||||||
else if (width < 1300) count = 4;
|
|
||||||
|
|
||||||
if (this.maxBranches && this.maxBranches > 0) {
|
|
||||||
return Math.min(count, this.maxBranches);
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get chunkedItems() {
|
|
||||||
if (!this.items || this.items.length === 0) return [];
|
|
||||||
const count = Math.max(1, this.branchCount);
|
|
||||||
const result = [];
|
|
||||||
const itemsPerBranch = Math.ceil(this.items.length / count);
|
|
||||||
|
|
||||||
for (let i = 0; i < this.items.length; i += itemsPerBranch) {
|
|
||||||
result.push(this.items.slice(i, i + itemsPerBranch));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderNodeContent(item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) {
|
|
||||||
if (this.nodeRenderer) {
|
|
||||||
const content = this.nodeRenderer(item, props);
|
|
||||||
|
|
||||||
// Handle HTMLElement return (common in plain JS)
|
|
||||||
if (content instanceof HTMLElement) {
|
|
||||||
return <div class="node-content-wrapper" ref={el => {
|
|
||||||
if (el) {
|
|
||||||
el.innerHTML = '';
|
|
||||||
el.appendChild(content);
|
|
||||||
}
|
|
||||||
}}></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle string return (simple HTML)
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
return <div class="node-content-wrapper" innerHTML={content}></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default rendering if no renderer is provided
|
|
||||||
const isDanger = item.type === 'danger';
|
|
||||||
return (
|
|
||||||
<div class={`ews-rib-node ${props.side === 'right' ? 'flip' : ''} ${isDanger ? 'danger' : ''}`}>
|
|
||||||
<span>{item.label || item.name || item.value || 'Node'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderConnectorContent(item: any, props: { side: 'left' | 'right'; branchIndex: number; index: number; delay: number }) {
|
|
||||||
if (this.connectorRenderer) {
|
|
||||||
const content = this.connectorRenderer(item, props);
|
|
||||||
|
|
||||||
const renderWrapper = (inner) => (
|
|
||||||
<div class={`ews-rib-layout__connector-text ews-rib-layout__connector-text--${props.side} fade-in animation-delay-5`}>
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (content instanceof HTMLElement) {
|
|
||||||
return renderWrapper(<div ref={el => {
|
|
||||||
if (el) {
|
|
||||||
el.innerHTML = '';
|
|
||||||
el.appendChild(content);
|
|
||||||
}
|
|
||||||
}}></div>);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
return renderWrapper(<div innerHTML={content}></div>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderWrapper(content);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const chunked = this.chunkedItems;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Host>
|
|
||||||
<div class="ews-rib-layout">
|
|
||||||
{chunked.map((branchItems, branchIndex) => (
|
|
||||||
<div class="ews-rib-layout__branch" key={branchIndex}>
|
|
||||||
{/* Central Spine */}
|
|
||||||
<div
|
|
||||||
class="ews-rib-layout__spine line-central"
|
|
||||||
style={{ animationDelay: `${branchIndex * 200}ms` }}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div class="ews-rib-layout__grid">
|
|
||||||
{branchItems.map((item, index) => {
|
|
||||||
const side = index % 2 === 0 ? 'left' : 'right';
|
|
||||||
const delay = (branchIndex + 1) * (index + 1) * 10;
|
|
||||||
const href = this.getHref?.(item);
|
|
||||||
const Tag = href ? 'a' : 'div';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tag
|
|
||||||
href={href}
|
|
||||||
class={`ews-rib-layout__node ${side === 'left' ? 'ews-rib-layout__node--left node' : 'ews-rib-layout__node--right node-flip'}`}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
{side === 'left' ? (
|
|
||||||
[
|
|
||||||
<div class="ews-rib-layout__node-content parent-node">
|
|
||||||
{this.renderNodeContent(item, { side, branchIndex, index, delay })}
|
|
||||||
</div>,
|
|
||||||
<div class="ews-rib-layout__connector-wrapper ews-rib-layout__connector-wrapper--left line">
|
|
||||||
<div class="ews-rib-layout__connector-line line-node" style={{ animationDelay: `${delay}ms` }}></div>
|
|
||||||
{this.renderConnectorContent(item, { side, branchIndex, index, delay })}
|
|
||||||
</div>,
|
|
||||||
]
|
|
||||||
) : (
|
|
||||||
[
|
|
||||||
<div class="ews-rib-layout__connector-wrapper ews-rib-layout__connector-wrapper--right">
|
|
||||||
<div class="ews-rib-layout__connector-line line-node" style={{ animationDelay: `${delay}ms` }}></div>
|
|
||||||
{this.renderConnectorContent(item, { side, branchIndex, index, delay })}
|
|
||||||
</div>,
|
|
||||||
<div class="parent-node flip ews-rib-layout__node-content ews-rib-layout__node-content--right">
|
|
||||||
{this.renderNodeContent(item, { side, branchIndex, index, delay })}
|
|
||||||
</div>,
|
|
||||||
]
|
|
||||||
)}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# ews-rib-layout
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Auto Generated Below -->
|
|
||||||
|
|
||||||
|
|
||||||
## Properties
|
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
|
||||||
| ------------------- | -------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------- |
|
|
||||||
| `connectorRenderer` | -- | Optional renderer function for the connector content. | `(item: any, props: { side: "left" \| "right"; branchIndex: number; index: number; delay: number; }) => any` | `undefined` |
|
|
||||||
| `getHref` | -- | Function to get the href for a node. If provided, nodes will be rendered as <a> tags. | `(item: any) => string` | `undefined` |
|
|
||||||
| `items` | -- | Array of items to be displayed in the rib cage layout. | `any[]` | `[]` |
|
|
||||||
| `maxBranches` | `max-branches` | Maximum number of branches to display. If not provided, it defaults to 5 (responsive). | `number` | `undefined` |
|
|
||||||
| `nodeRenderer` | -- | Optional renderer function for the node content. | `(item: any, props: { side: "left" \| "right"; branchIndex: number; index: number; delay: number; }) => any` | `undefined` |
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
*Built with [StencilJS](https://stenciljs.com/)*
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.host-wrapper {
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Strip Bar Styles */
|
|
||||||
.ews-stripe-wrapper {
|
|
||||||
width: max(200vw, 2000px);
|
|
||||||
height: 30px;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin: 0px !important;
|
|
||||||
padding: 0px !important;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-wrapper.vertical {
|
|
||||||
height: 100%;
|
|
||||||
width: 30px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-bar {
|
|
||||||
width: max(200vw, 2000px);
|
|
||||||
height: 100%;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 0px !important;
|
|
||||||
margin-left: 0px !important;
|
|
||||||
--ews-stripe-color: var(--orange, #fa0);
|
|
||||||
--ews-stripe-size: 15px;
|
|
||||||
--ews-glow-color: rgba(255, 94, 0, 0.8);
|
|
||||||
--ews-glow-size: 3px;
|
|
||||||
background-image: repeating-linear-gradient(-45deg,
|
|
||||||
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
|
|
||||||
var(--ews-stripe-color) 0,
|
|
||||||
var(--ews-stripe-color) calc(var(--ews-stripe-size) - var(--ews-glow-size) / 2),
|
|
||||||
var(--ews-glow-color) calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(2 * var(--ews-stripe-size)),
|
|
||||||
var(--ews-glow-color) calc(2 * var(--ews-stripe-size) - var(--ews-glow-size)));
|
|
||||||
background-size: 47px 47px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-bar.red {
|
|
||||||
--ews-stripe-color: var(--red, #f23);
|
|
||||||
--ews-stripe-size: 15px;
|
|
||||||
--ews-glow-color: rgba(255, 17, 0, 0.8);
|
|
||||||
--ews-glow-size: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-bar.vertical {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Include the legacy combined classes just in case */
|
|
||||||
.ews-stripe-bar-red {
|
|
||||||
width: max(200vw, 2000px);
|
|
||||||
height: 30px;
|
|
||||||
display: inline-block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
--ews-stripe-color: var(--red, #f23);
|
|
||||||
--ews-stripe-size: 15px;
|
|
||||||
--ews-glow-color: rgba(255, 17, 0, 0.8);
|
|
||||||
--ews-glow-size: 3px;
|
|
||||||
background-image: repeating-linear-gradient(-45deg,
|
|
||||||
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
|
|
||||||
var(--ews-stripe-color) 0,
|
|
||||||
var(--ews-stripe-color) calc(var(--ews-stripe-size) - var(--ews-glow-size) / 2),
|
|
||||||
var(--ews-glow-color) calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(2 * var(--ews-stripe-size)),
|
|
||||||
var(--ews-glow-color) calc(2 * var(--ews-stripe-size) - var(--ews-glow-size)));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-bar-vertical {
|
|
||||||
height: max(2000px, 200vh);
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
--ews-stripe-color: var(--orange, #fa0);
|
|
||||||
--ews-stripe-size: 15px;
|
|
||||||
--ews-glow-color: rgba(255, 94, 0, 0.8);
|
|
||||||
--ews-glow-size: 3px;
|
|
||||||
background-image: repeating-linear-gradient(45deg,
|
|
||||||
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
|
|
||||||
var(--ews-stripe-color) 0,
|
|
||||||
var(--ews-stripe-color) calc(var(--ews-stripe-size) - var(--ews-glow-size) / 2),
|
|
||||||
var(--ews-glow-color) calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(2 * var(--ews-stripe-size)),
|
|
||||||
var(--ews-glow-color) calc(2 * var(--ews-stripe-size) - var(--ews-glow-size)));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-bar-red-vertical {
|
|
||||||
height: max(2000px, 200vh);
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
--ews-stripe-color: var(--red, #f23);
|
|
||||||
--ews-stripe-size: 15px;
|
|
||||||
--ews-glow-color: rgba(255, 17, 0, 0.8);
|
|
||||||
--ews-glow-size: 3px;
|
|
||||||
background-image: repeating-linear-gradient(45deg,
|
|
||||||
var(--ews-glow-color) calc(-1 * var(--ews-glow-size)),
|
|
||||||
var(--ews-stripe-color) 0,
|
|
||||||
var(--ews-stripe-color) calc(var(--ews-stripe-size) - var(--ews-glow-size) / 2),
|
|
||||||
var(--ews-glow-color) calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(var(--ews-stripe-size) + var(--ews-glow-size) / 2),
|
|
||||||
transparent calc(2 * var(--ews-stripe-size)),
|
|
||||||
var(--ews-glow-color) calc(2 * var(--ews-stripe-size) - var(--ews-glow-size)));
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe-wrapper-vertical {
|
|
||||||
height: max(200vh, 2000px);
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin: 0px !important;
|
|
||||||
padding: 0px !important;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ews-stripe {
|
|
||||||
background-color: black;
|
|
||||||
width: 100vw;
|
|
||||||
border-top: 1px solid var(--red, #f23);
|
|
||||||
border-bottom: 1px solid var(--red, #f23);
|
|
||||||
position: fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes loopStripVertical {
|
|
||||||
from {
|
|
||||||
background-position: 0px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
background-position: 0px calc(-42.4264px * 47);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stripeAnimationVertical {
|
|
||||||
from {
|
|
||||||
background-position: 0px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
background-position: 0px calc(-42.4264px * 47);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stripeAnimation {
|
|
||||||
from {
|
|
||||||
background-position: 0px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
background-position: calc(-42.4264px * 47) 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loopStrip {
|
|
||||||
from {
|
|
||||||
background-position: 0px 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
background-position: calc(-42.4264px * 47) 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loop-stripe-vertical {
|
|
||||||
animation: stripeAnimationVertical 15s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loop-stripe-vertical.reverse {
|
|
||||||
animation: stripeAnimationVertical 15s infinite linear reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loop-stripe-vertical-reverse {
|
|
||||||
animation: loopStripVertical 15s infinite linear reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripe-animation {
|
|
||||||
animation: stripeAnimation 10s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stripe-animation-reverse {
|
|
||||||
animation: stripeAnimation 10s infinite linear reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loop-stripe {
|
|
||||||
animation: stripeAnimation infinite linear;
|
|
||||||
animation-duration: 10s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loop-stripe.reverse {
|
|
||||||
animation: loopStrip infinite linear reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loop-stripe-reverse {
|
|
||||||
animation: loopStrip infinite linear reverse;
|
|
||||||
animation-duration: 10s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anim-duration-5 {
|
|
||||||
animation-duration: 5s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anim-duration-10 {
|
|
||||||
animation-duration: 10s !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.anim-duration-20 {
|
|
||||||
animation-duration: 20s !important;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Component, Host, h, Prop } from '@stencil/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'ews-stripe-bar',
|
|
||||||
styleUrl: 'ews-stripe-bar.css',
|
|
||||||
shadow: true,
|
|
||||||
})
|
|
||||||
export class EwsStripeBar {
|
|
||||||
@Prop() color: string = '';
|
|
||||||
@Prop() orientation: string = '';
|
|
||||||
@Prop() loop: boolean = false;
|
|
||||||
@Prop() reverse: boolean = false;
|
|
||||||
@Prop() duration: number = 10;
|
|
||||||
@Prop() size: string = '30px';
|
|
||||||
|
|
||||||
private getStripeClasses() {
|
|
||||||
const loopStr = this.loop ? 'loop-stripe' : '';
|
|
||||||
const orientationStr = this.orientation ? `-${this.orientation}` : '';
|
|
||||||
const combinedStr = loopStr + orientationStr;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'ews-stripe-bar',
|
|
||||||
this.color,
|
|
||||||
this.orientation,
|
|
||||||
combinedStr,
|
|
||||||
this.reverse ? 'reverse' : '',
|
|
||||||
`anim-duration-${this.duration}`
|
|
||||||
].filter(c => c.trim() !== '').join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Host>
|
|
||||||
<div style={{ overflow: 'hidden', height: '100%', width: '100%' }} class="host-wrapper">
|
|
||||||
<div
|
|
||||||
class={`ews-stripe-wrapper ${this.orientation}`}
|
|
||||||
style={{ [this.orientation === 'vertical' ? 'width' : 'height']: this.size }}
|
|
||||||
>
|
|
||||||
<div class={this.getStripeClasses()}></div>
|
|
||||||
<div class={this.getStripeClasses()}></div>
|
|
||||||
</div>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# ews-stripe-bar
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Auto Generated Below -->
|
|
||||||
|
|
||||||
|
|
||||||
## Properties
|
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
|
||||||
| ------------- | ------------- | ----------- | --------- | -------- |
|
|
||||||
| `color` | `color` | | `string` | `''` |
|
|
||||||
| `duration` | `duration` | | `number` | `10` |
|
|
||||||
| `loop` | `loop` | | `boolean` | `false` |
|
|
||||||
| `orientation` | `orientation` | | `string` | `''` |
|
|
||||||
| `reverse` | `reverse` | | `boolean` | `false` |
|
|
||||||
| `size` | `size` | | `string` | `'30px'` |
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
*Built with [StencilJS](https://stenciljs.com/)*
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { render, h, describe, it, expect } from '@stencil/vitest';
|
|
||||||
|
|
||||||
describe('my-component', () => {
|
|
||||||
it('renders', async () => {
|
|
||||||
const { root } = await render(<my-component></my-component>);
|
|
||||||
await expect(root).toEqualHtml(`
|
|
||||||
<my-component class="hydrated">
|
|
||||||
<mock:shadow-root>
|
|
||||||
<div>
|
|
||||||
Hello, World! I'm
|
|
||||||
</div>
|
|
||||||
</mock:shadow-root>
|
|
||||||
</my-component>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders with values', async () => {
|
|
||||||
const { root } = await render(
|
|
||||||
<my-component first="Stencil" middle="'Don't call me a framework'" last="JS"></my-component>,
|
|
||||||
);
|
|
||||||
await expect(root).toEqualHtml(`
|
|
||||||
<my-component class="hydrated">
|
|
||||||
<mock:shadow-root>
|
|
||||||
<div>
|
|
||||||
Hello, World! I'm Stencil 'Don't call me a framework' JS
|
|
||||||
</div>
|
|
||||||
</mock:shadow-root>
|
|
||||||
</my-component>
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { Component, Prop, h } from '@stencil/core';
|
|
||||||
import { format } from '../../utils/utils';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
tag: 'my-component',
|
|
||||||
styleUrl: 'my-component.css',
|
|
||||||
shadow: true,
|
|
||||||
})
|
|
||||||
export class MyComponent {
|
|
||||||
/**
|
|
||||||
* The first name
|
|
||||||
*/
|
|
||||||
@Prop() first: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The middle name
|
|
||||||
*/
|
|
||||||
@Prop() middle: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The last name
|
|
||||||
*/
|
|
||||||
@Prop() last: string;
|
|
||||||
|
|
||||||
private getText(): string {
|
|
||||||
return format(this.first, this.middle, this.last);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return <div>Hello, World! I'm {this.getText()}</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# my-component
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Auto Generated Below -->
|
|
||||||
|
|
||||||
|
|
||||||
## Properties
|
|
||||||
|
|
||||||
| Property | Attribute | Description | Type | Default |
|
|
||||||
| -------- | --------- | --------------- | -------- | ----------- |
|
|
||||||
| `first` | `first` | The first name | `string` | `undefined` |
|
|
||||||
| `last` | `last` | The last name | `string` | `undefined` |
|
|
||||||
| `middle` | `middle` | The middle name | `string` | `undefined` |
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------
|
|
||||||
|
|
||||||
*Built with [StencilJS](https://stenciljs.com/)*
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html dir="ltr" lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
|
|
||||||
<title>Stencil Component Starter</title>
|
|
||||||
|
|
||||||
<script type="module" src="/build/ews-component.esm.js"></script>
|
|
||||||
<script nomodule src="/build/ews-component.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
border-bottom: 2px solid #007bff;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: #555;
|
|
||||||
margin-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example-section {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 15px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.example-label {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<h1>EWS Component Examples</h1>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ews-card Examples -->
|
|
||||||
<div class="example-section">
|
|
||||||
<h2>ews-card</h2>
|
|
||||||
<p>A collapsible card component with customizable colors and styling.</p>
|
|
||||||
<div class="example-row">
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-card color="red">
|
|
||||||
<div slot="header">Red Card Header</div>
|
|
||||||
<div slot="content">This is the content of a red card. Click the header to toggle.</div>
|
|
||||||
</ews-card>
|
|
||||||
<span class="example-label">Red color</span>
|
|
||||||
</div>
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-card color="orange">
|
|
||||||
<div slot="header">Orange Card Header</div>
|
|
||||||
<div slot="content">This is the content of an orange card.</div>
|
|
||||||
</ews-card>
|
|
||||||
<span class="example-label">Orange color</span>
|
|
||||||
</div>
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-card color="orange" style="max-width: 400px;">
|
|
||||||
<div slot="header">
|
|
||||||
<ews-stripe-bar></ews-stripe-bar>
|
|
||||||
<div class="ews-card-text">
|
|
||||||
<p style="padding: 2px; text-align: center;background-color: black;">WARNING</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div slot="content">
|
|
||||||
<div style="padding: 6px; text-align: center;">
|
|
||||||
WARNING CONTENT NEED YOUR ATENTION
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div slot="footer">
|
|
||||||
<ews-stripe-bar reverse="true"></ews-stripe-bar>
|
|
||||||
<div class="ews-card-text">
|
|
||||||
<p style="padding: 2px; text-align: center;background-color: black;">WARNING</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ews-card>
|
|
||||||
<span class="example-label">Custom</span>
|
|
||||||
</div>
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-card color="red" style="max-width: 400px;">
|
|
||||||
<div slot="header">
|
|
||||||
<ews-stripe-bar color="red" loop="true"></ews-stripe-bar>
|
|
||||||
<div class="ews-card-text">
|
|
||||||
<p style="padding: 2px; text-align: center;background-color: black;">DANGER</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div slot="content">
|
|
||||||
<div style="padding: 6px; text-align: center;">
|
|
||||||
DANGER CONTENT NEED YOUR ATENTION
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div slot="footer">
|
|
||||||
<ews-stripe-bar color="red" loop="true" reverse="true"></ews-stripe-bar>
|
|
||||||
<div class="ews-card-text">
|
|
||||||
<p style="padding: 2px; text-align: center;background-color: black;">DANGER</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ews-card>
|
|
||||||
<span class="example-label">Custom</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ews-hex-shape Examples -->
|
|
||||||
<div class="example-section">
|
|
||||||
<h2>ews-hex-shape</h2>
|
|
||||||
<p>A hexagonal shape component with various configuration options.</p>
|
|
||||||
<div class="example-row">
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-hex-shape style="width: 150px;">
|
|
||||||
<div style="padding: 20px; text-align: center; color: white;">Blue Hex</div>
|
|
||||||
</ews-hex-shape>
|
|
||||||
<span class="example-label">Default</span>
|
|
||||||
</div>
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-hex-shape flat-top="false" style="width: 150px;">
|
|
||||||
<div style="padding: 20px; text-align: center; color: white;">Pointy Top</div>
|
|
||||||
</ews-hex-shape>
|
|
||||||
<span class="example-label">Pointy top</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-hex-shape color="red" style="width: 150px;"></ews-hex-shape>
|
|
||||||
<span class="example-label">Red</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-hex-shape clip-content="true" style="width: 150px; height: 150px;">
|
|
||||||
<div style="padding: 20px; text-align: center; color: white; background: rgba(0,0,0,0.3);">
|
|
||||||
Clipped Content - This text is clipped within the hex boundary
|
|
||||||
</div>
|
|
||||||
</ews-hex-shape>
|
|
||||||
<span class="example-label">Clipped content</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ews-stripe-bar Examples -->
|
|
||||||
<div class="example-section">
|
|
||||||
<h2>ews-stripe-bar</h2>
|
|
||||||
<p>An animated stripe bar component with various customization options.</p>
|
|
||||||
<div class="example-row">
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar style="width: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Default</span>
|
|
||||||
</div>
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar color="red" style="width: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Red</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar color="red" loop="true" style="width: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Animated</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar loop="true" reverse="true" style="width: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Reverse</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-row">
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar orientation="vertical" style="height: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Default</span>
|
|
||||||
</div>
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar color="red" orientation="vertical" style="height: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Red</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar color="red" orientation="vertical" loop="true" style="height: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Animated</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-stripe-bar loop="true" orientation="vertical" reverse="true" style="height: 200px;"></ews-stripe-bar>
|
|
||||||
<span class="example-label">Reverse</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="example-section">
|
|
||||||
<h2>ews-hex-grid</h2>
|
|
||||||
|
|
||||||
<div class="example-row">
|
|
||||||
<div class="example-item">
|
|
||||||
<ews-hex-grid variant="pointy" gap="1" style="width: 300px;">
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 1</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 2</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 3</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 4</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 5</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 6</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 7</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 8</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 9</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
<div class="ews-hex-hive">
|
|
||||||
<ews-hex-shape flat-top="false">Cell 10</ews-hex-shape>
|
|
||||||
</div>
|
|
||||||
</ews-hex-grid>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ews-rib-layout Examples -->
|
|
||||||
<div class="example-section">
|
|
||||||
<h2>ews-rib-layout</h2>
|
|
||||||
<p>A complex rib cage layout with responsive branching and animated nodes.</p>
|
|
||||||
<div style="padding: 40px 0; border-radius: 8px; margin-top: 15px; min-height: 600px; overflow-x: auto;">
|
|
||||||
<ews-rib-layout id="rib-example"></ews-rib-layout>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Example for ews-rib-layout with 50 items
|
|
||||||
const ribLayout = document.getElementById('rib-example');
|
|
||||||
|
|
||||||
if (ribLayout) {
|
|
||||||
// Generate 50 items
|
|
||||||
const statuses = Array.from({ length: 50 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
title: `STN ${String.fromCharCode(65 + (i % 26))}${Math.floor(i / 26) + 1}`,
|
|
||||||
networkCode: 'NET' + (i % 5),
|
|
||||||
stationCode: 'STA' + i,
|
|
||||||
type: i % 7 === 0 ? 'danger' : 'normal',
|
|
||||||
value: Math.floor(Math.random() * 100)
|
|
||||||
}));
|
|
||||||
|
|
||||||
ribLayout.items = statuses;
|
|
||||||
|
|
||||||
// Set href generator
|
|
||||||
ribLayout.getHref = (item) =>
|
|
||||||
`#/realtime?networkCode=${item.networkCode}&stationCode=${item.stationCode}`;
|
|
||||||
|
|
||||||
// Set node content renderer
|
|
||||||
// Note: In plain JS, we can return a string or a DOM element if the component handles it,
|
|
||||||
// but for Stencil JSX props, a function returning a VNode or DOM element is best.
|
|
||||||
// Here we provide a simple renderer that leverages the CSS classes ported.
|
|
||||||
ribLayout.nodeRenderer = (item, { side, delay }) => {
|
|
||||||
console.log(item);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
const isRight = side === 'right';
|
|
||||||
const isDanger = item.type === 'danger';
|
|
||||||
|
|
||||||
// Match the classes from the Svelte snippet
|
|
||||||
div.className = `slide-fade-in ews-rib-node ${isRight ? 'flip' : ''} ${isDanger ? 'danger' : ''} z-5 text-black text-xs font-bold`;
|
|
||||||
div.style.animationDelay = `${delay}ms`;
|
|
||||||
div.style.display = 'flex';
|
|
||||||
div.style.alignItems = 'center';
|
|
||||||
div.style.justifyContent = 'center';
|
|
||||||
div.style.color = 'black';
|
|
||||||
div.style.fontSize = '10px';
|
|
||||||
div.innerHTML = `<span>${item.value}</span>`;
|
|
||||||
|
|
||||||
return div;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set connector content renderer
|
|
||||||
ribLayout.connectorRenderer = (item) => item.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example of listening to ews-card toggle events
|
|
||||||
document.querySelectorAll('ews-card').forEach(card => {
|
|
||||||
card.addEventListener('toggle', () => {
|
|
||||||
console.log('Card toggled!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview entry point for your component library
|
|
||||||
*
|
|
||||||
* This is the entry point for your component library. Use this file to export utilities,
|
|
||||||
* constants or data structure that accompany your components.
|
|
||||||
*
|
|
||||||
* DO NOT use this file to export your components. Instead, use the recommended approaches
|
|
||||||
* to consume components of this package as outlined in the `README.md`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { format } from './utils/utils';
|
|
||||||
export type * from './components.d.ts';
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function format(first?: string, middle?: string, last?: string): string {
|
|
||||||
return (first || '') + (middle ? ` ${middle}` : '') + (last ? ` ${last}` : '');
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { format } from './utils';
|
|
||||||
|
|
||||||
describe('format', () => {
|
|
||||||
it('returns empty string for no names defined', () => {
|
|
||||||
expect(format(undefined, undefined, undefined)).toEqual('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats just first names', () => {
|
|
||||||
expect(format('Joseph', undefined, undefined)).toEqual('Joseph');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats first and last names', () => {
|
|
||||||
expect(format('Joseph', undefined, 'Publique')).toEqual('Joseph Publique');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats first, middle and last names', () => {
|
|
||||||
expect(format('Joseph', 'Quincy', 'Publique')).toEqual('Joseph Quincy Publique');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { Config } from '@stencil/core';
|
|
||||||
|
|
||||||
export const config: Config = {
|
|
||||||
namespace: 'ews-component',
|
|
||||||
outputTargets: [
|
|
||||||
{
|
|
||||||
type: 'dist',
|
|
||||||
esmLoaderPath: '../loader',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'dist-custom-elements',
|
|
||||||
customElementsExportBehavior: 'auto-define-custom-elements',
|
|
||||||
externalRuntime: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'docs-readme',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'www',
|
|
||||||
serviceWorker: null, // disable service workers
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"allowUnreachableCode": false,
|
|
||||||
"declaration": false,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"lib": [
|
|
||||||
"dom",
|
|
||||||
"es2022"
|
|
||||||
],
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"module": "esnext",
|
|
||||||
"target": "es2022",
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"jsx": "react",
|
|
||||||
"jsxFactory": "h",
|
|
||||||
"jsxFragmentFactory": "h.Fragment",
|
|
||||||
"types": [
|
|
||||||
"@stencil/vitest/globals"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// Load Stencil components for browser tests
|
|
||||||
await import('./dist/ews-component/ews-component.esm.js');
|
|
||||||
|
|
||||||
export { };
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { defineVitestConfig } from '@stencil/vitest/config';
|
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
|
||||||
|
|
||||||
export default defineVitestConfig({
|
|
||||||
stencilConfig: './stencil.config.ts',
|
|
||||||
test: {
|
|
||||||
projects: [
|
|
||||||
// Unit tests - stencil environment for component logic
|
|
||||||
{
|
|
||||||
test: {
|
|
||||||
name: 'unit',
|
|
||||||
include: ['src/**/*.unit.test.{ts,tsx}'],
|
|
||||||
environment: 'stencil',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Component browser tests - real browser via Playwright
|
|
||||||
{
|
|
||||||
test: {
|
|
||||||
name: 'browser',
|
|
||||||
include: ['src/**/*.cmp.test.{ts,tsx}'],
|
|
||||||
setupFiles: ['./vitest-setup.ts'],
|
|
||||||
browser: {
|
|
||||||
enabled: true,
|
|
||||||
provider: playwright(),
|
|
||||||
headless: true,
|
|
||||||
instances: [{ browser: 'chromium' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user