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:
David Bruno Vontor
2026-06-09 16:18:41 +02:00
parent 46bc131a56
commit 2592a69790
74 changed files with 666 additions and 13194 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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í emailu",
"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 email", action_url=verify_url,
}
send_email_with_context(
recipients=user.email,
subject="Ověření emailu",
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 email",
},
) )
@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í email", subject="Testovací email",
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",
},
) )

View File

@@ -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."})

View File

@@ -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,
} },
) )

View File

@@ -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

View File

@@ -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,
})

View File

View 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")

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
name = 'notifications'

View 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"],
}))

View 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

View 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()),
]

View 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',
]

View 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
}

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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)),
]

View 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})

View File

@@ -0,0 +1 @@
# editorjs.io - základ

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

View 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

View File

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View 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

View File

@@ -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) │
└──────────────────────────────────┘

View File

@@ -0,0 +1,3 @@
# Notify on mod ADD
# notify for Apeal

View 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..

View File

@@ -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,

View File

@@ -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

View File

@@ -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')),

View File

@@ -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>
)} )}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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"
}

View File

@@ -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!
[![Support me on Sociabuzz](https://img.shields.io/badge/Support%20Me-Sociabuzz-orange?style=for-the-badge&logo=buymeacoffee&logoColor=white)](https://sociabuzz.com/bagusindrayana/tribe)

View File

@@ -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>;
}
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}
}

View File

@@ -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>
);
}
}

View File

@@ -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/)*

View File

@@ -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%;
}

View File

@@ -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>
);
}
}

View File

@@ -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/)*

View File

@@ -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");
}

View File

@@ -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>
);
}
}

View File

@@ -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/)*

View File

@@ -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%;
}
}

View File

@@ -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>
);
}
}

View File

@@ -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/)*

View File

@@ -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;
}

View File

@@ -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>
);
}
}

View File

@@ -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/)*

View File

@@ -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>
`);
});
});

View File

@@ -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>;
}
}

View File

@@ -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/)*

View File

@@ -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>

View File

@@ -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';

View File

@@ -1,3 +0,0 @@
export function format(first?: string, middle?: string, last?: string): string {
return (first || '') + (middle ? ` ${middle}` : '') + (last ? ` ${last}` : '');
}

View File

@@ -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');
});
});

View File

@@ -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
},
],
};

View File

@@ -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"
]
}

View File

@@ -1,4 +0,0 @@
// Load Stencil components for browser tests
await import('./dist/ews-component/ews-component.esm.js');
export { };

View File

@@ -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