Mark-Lasfar
commited on
Commit
·
1b2adb2
1
Parent(s):
2d34eda
update full
Browse files- api/__pycache__/database.cpython-311.pyc +0 -0
- api/auth.py +5 -4
- api/database.py +73 -125
- create_dummy_user.py +30 -0
- data/mgzon_users.db +0 -0
- requirements.txt +12 -12
- static/images/settings-icon.svg +3 -0
- static/images/splash-screen-750x1334.png +0 -0
- static/js/sw.js +83 -24
- static/manifest.json +64 -10
- templates/chat.html +103 -1
api/__pycache__/database.cpython-311.pyc
CHANGED
|
Binary files a/api/__pycache__/database.cpython-311.pyc and b/api/__pycache__/database.cpython-311.pyc differ
|
|
|
api/auth.py
CHANGED
|
@@ -16,8 +16,9 @@ from typing import Optional, Dict
|
|
| 16 |
import os
|
| 17 |
import logging
|
| 18 |
import secrets
|
|
|
|
| 19 |
|
| 20 |
-
from api.database import User, OAuthAccount, CustomSQLAlchemyUserDatabase, get_user_db
|
| 21 |
from api.models import UserRead, UserCreate, UserUpdate
|
| 22 |
|
| 23 |
# إعداد اللوقينج
|
|
@@ -151,7 +152,7 @@ google_oauth_router = get_oauth_router(
|
|
| 151 |
get_user_manager,
|
| 152 |
state_secret=SECRET,
|
| 153 |
associate_by_email=True,
|
| 154 |
-
redirect_url=
|
| 155 |
)
|
| 156 |
|
| 157 |
github_oauth_client._access_token_url = "https://github.com/login/oauth/access_token"
|
|
@@ -162,7 +163,7 @@ github_oauth_router = get_oauth_router(
|
|
| 162 |
get_user_manager,
|
| 163 |
state_secret=SECRET,
|
| 164 |
associate_by_email=True,
|
| 165 |
-
redirect_url=
|
| 166 |
)
|
| 167 |
|
| 168 |
fastapi_users = FastAPIUsers[User, int](
|
|
@@ -180,4 +181,4 @@ def get_auth_router(app: FastAPI):
|
|
| 180 |
app.include_router(fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"])
|
| 181 |
app.include_router(fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"])
|
| 182 |
app.include_router(fastapi_users.get_verify_router(UserRead), prefix="/auth", tags=["auth"])
|
| 183 |
-
app.include_router(fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"])
|
|
|
|
| 16 |
import os
|
| 17 |
import logging
|
| 18 |
import secrets
|
| 19 |
+
from fastapi.responses import RedirectResponse
|
| 20 |
|
| 21 |
+
from api.database import User, OAuthAccount, CustomSQLAlchemyUserDatabase, get_user_db
|
| 22 |
from api.models import UserRead, UserCreate, UserUpdate
|
| 23 |
|
| 24 |
# إعداد اللوقينج
|
|
|
|
| 152 |
get_user_manager,
|
| 153 |
state_secret=SECRET,
|
| 154 |
associate_by_email=True,
|
| 155 |
+
redirect_url="https://mgzon-mgzon-app.hf.space/chat", # تعديل الـ redirect_url ليحوّل مباشرة إلى /chat
|
| 156 |
)
|
| 157 |
|
| 158 |
github_oauth_client._access_token_url = "https://github.com/login/oauth/access_token"
|
|
|
|
| 163 |
get_user_manager,
|
| 164 |
state_secret=SECRET,
|
| 165 |
associate_by_email=True,
|
| 166 |
+
redirect_url="https://mgzon-mgzon-app.hf.space/chat", # تعديل الـ redirect_url ليحوّل مباشرة إلى /chat
|
| 167 |
)
|
| 168 |
|
| 169 |
fastapi_users = FastAPIUsers[User, int](
|
|
|
|
| 181 |
app.include_router(fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"])
|
| 182 |
app.include_router(fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"])
|
| 183 |
app.include_router(fastapi_users.get_verify_router(UserRead), prefix="/auth", tags=["auth"])
|
| 184 |
+
app.include_router(fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"])
|
api/database.py
CHANGED
|
@@ -1,146 +1,94 @@
|
|
| 1 |
-
#
|
| 2 |
# SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
# SPDX-License-License: Apache-2.0
|
| 4 |
|
| 5 |
import os
|
| 6 |
import logging
|
| 7 |
-
|
| 8 |
-
from
|
| 9 |
-
|
| 10 |
-
from
|
| 11 |
-
from
|
| 12 |
-
from sqlalchemy.ext.declarative import declarative_base
|
| 13 |
-
from sqlalchemy.orm import relationship
|
| 14 |
-
from fastapi import Depends
|
| 15 |
-
from fastapi_users.db import SQLAlchemyBaseUserTable, SQLAlchemyUserDatabase
|
| 16 |
-
import aiosqlite
|
| 17 |
|
| 18 |
# إعداد اللوج
|
|
|
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
"SQLALCHEMY_DATABASE_URL"
|
| 24 |
-
) or "sqlite+aiosqlite:///./data/mgzon_users.db"
|
| 25 |
-
|
| 26 |
-
# تأكد أن الدرايفر async
|
| 27 |
-
if "aiosqlite" not in SQLALCHEMY_DATABASE_URL:
|
| 28 |
-
raise ValueError("Database URL must use 'sqlite+aiosqlite' for async support")
|
| 29 |
-
|
| 30 |
-
# إنشاء محرك async
|
| 31 |
-
async_engine = create_async_engine(
|
| 32 |
-
SQLALCHEMY_DATABASE_URL,
|
| 33 |
-
echo=True,
|
| 34 |
-
connect_args={"check_same_thread": False}
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
# إعداد الجلسة async
|
| 38 |
-
AsyncSessionLocal = async_sessionmaker(
|
| 39 |
-
async_engine,
|
| 40 |
-
expire_on_commit=False,
|
| 41 |
-
class_=AsyncSession
|
| 42 |
-
)
|
| 43 |
-
|
| 44 |
-
# القاعدة الأساسية للنماذج
|
| 45 |
-
Base = declarative_base()
|
| 46 |
-
|
| 47 |
-
# النماذج (Models)
|
| 48 |
-
class OAuthAccount(Base):
|
| 49 |
-
__tablename__ = "oauth_account"
|
| 50 |
-
id = Column(Integer, primary_key=True, index=True)
|
| 51 |
-
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
| 52 |
-
oauth_name = Column(String, nullable=False)
|
| 53 |
-
access_token = Column(String, nullable=False)
|
| 54 |
-
expires_at = Column(Integer, nullable=True)
|
| 55 |
-
refresh_token = Column(String, nullable=True)
|
| 56 |
-
account_id = Column(String, index=True, nullable=False)
|
| 57 |
-
account_email = Column(String, nullable=False)
|
| 58 |
-
|
| 59 |
-
user = relationship("User", back_populates="oauth_accounts", lazy="selectin")
|
| 60 |
-
|
| 61 |
-
class User(SQLAlchemyBaseUserTable[int], Base):
|
| 62 |
-
__tablename__ = "user"
|
| 63 |
-
id = Column(Integer, primary_key=True, index=True)
|
| 64 |
-
email = Column(String, unique=True, index=True, nullable=False)
|
| 65 |
-
hashed_password = Column(String, nullable=False)
|
| 66 |
-
is_active = Column(Boolean, default=True)
|
| 67 |
-
is_superuser = Column(Boolean, default=False)
|
| 68 |
-
is_verified = Column(Boolean, default=False)
|
| 69 |
-
display_name = Column(String, nullable=True)
|
| 70 |
-
preferred_model = Column(String, nullable=True)
|
| 71 |
-
job_title = Column(String, nullable=True)
|
| 72 |
-
education = Column(String, nullable=True)
|
| 73 |
-
interests = Column(String, nullable=True)
|
| 74 |
-
additional_info = Column(Text, nullable=True)
|
| 75 |
-
conversation_style = Column(String, nullable=True)
|
| 76 |
-
|
| 77 |
-
oauth_accounts = relationship("OAuthAccount", back_populates="user", cascade="all, delete-orphan")
|
| 78 |
-
conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
|
| 79 |
-
|
| 80 |
-
class Conversation(Base):
|
| 81 |
-
__tablename__ = "conversation"
|
| 82 |
-
id = Column(Integer, primary_key=True, index=True)
|
| 83 |
-
conversation_id = Column(String, unique=True, index=True, nullable=False)
|
| 84 |
-
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
| 85 |
-
title = Column(String, nullable=False)
|
| 86 |
-
created_at = Column(DateTime, default=datetime.utcnow)
|
| 87 |
-
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 88 |
-
|
| 89 |
-
user = relationship("User", back_populates="conversations")
|
| 90 |
-
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
|
| 91 |
-
|
| 92 |
-
class Message(Base):
|
| 93 |
-
__tablename__ = "message"
|
| 94 |
-
id = Column(Integer, primary_key=True, index=True)
|
| 95 |
-
conversation_id = Column(Integer, ForeignKey("conversation.id"), nullable=False)
|
| 96 |
-
role = Column(String, nullable=False)
|
| 97 |
-
content = Column(Text, nullable=False)
|
| 98 |
-
created_at = Column(DateTime, default=datetime.utcnow)
|
| 99 |
-
|
| 100 |
-
conversation = relationship("Conversation", back_populates="messages")
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
"""
|
| 105 |
-
قاعدة بيانات مخصَّصة لمكتبة fastapi-users.
|
| 106 |
-
تضيف طريقة parse_id التي تُحوِّل الـ ID من str → int.
|
| 107 |
-
"""
|
| 108 |
-
def parse_id(self, value: Any) -> int:
|
| 109 |
-
logger.debug(f"Parsing user id: {value} (type={type(value)})")
|
| 110 |
-
return int(value) if isinstance(value, str) else value
|
| 111 |
-
|
| 112 |
-
async def get_by_email(self, email: str) -> Optional[User]:
|
| 113 |
-
logger.info(f"Looking for user with email: {email}")
|
| 114 |
-
stmt = select(self.user_table).where(self.user_table.email == email)
|
| 115 |
-
result = await self.session.execute(stmt)
|
| 116 |
-
return result.scalar_one_or_none()
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
| 125 |
|
| 126 |
-
#
|
| 127 |
-
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 128 |
async with AsyncSessionLocal() as session:
|
| 129 |
try:
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
finally:
|
| 132 |
await session.close()
|
| 133 |
|
| 134 |
-
|
| 135 |
-
async def get_user_db(session: AsyncSession = Depends(get_db)) -> AsyncGenerator[CustomSQLAlchemyUserDatabase, None]:
|
| 136 |
-
yield CustomSQLAlchemyUserDatabase(session, User, OAuthAccount)
|
| 137 |
|
| 138 |
-
|
| 139 |
-
async def init_db():
|
| 140 |
try:
|
| 141 |
-
|
| 142 |
-
await conn.run_sync(Base.metadata.create_all)
|
| 143 |
-
logger.info("Database tables created successfully")
|
| 144 |
except Exception as e:
|
| 145 |
-
logger.error(f"
|
| 146 |
raise
|
|
|
|
| 1 |
+
# init_db.py
|
| 2 |
# SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
# SPDX-License-License: Apache-2.0
|
| 4 |
|
| 5 |
import os
|
| 6 |
import logging
|
| 7 |
+
import asyncio
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
from sqlalchemy import select, delete
|
| 10 |
+
from api.database import async_engine, Base, User, OAuthAccount, Conversation, Message, AsyncSessionLocal
|
| 11 |
+
from passlib.context import CryptContext
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# إعداد اللوج
|
| 14 |
+
logging.basicConfig(level=logging.INFO)
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
+
# إعداد تشفير كلمة المرور
|
| 18 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
async def init_db():
|
| 21 |
+
logger.info("Starting database initialization...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
# إنشاء الجداول
|
| 24 |
+
try:
|
| 25 |
+
async with async_engine.begin() as conn:
|
| 26 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 27 |
+
logger.info("Database tables created successfully.")
|
| 28 |
+
except Exception as e:
|
| 29 |
+
logger.error(f"Error creating database tables: {e}")
|
| 30 |
+
raise
|
| 31 |
|
| 32 |
+
# تنظيف البيانات غير المتسقة
|
|
|
|
| 33 |
async with AsyncSessionLocal() as session:
|
| 34 |
try:
|
| 35 |
+
# حذف سجلات oauth_accounts اللي مش مرتبطة بمستخدم موجود
|
| 36 |
+
stmt = delete(OAuthAccount).where(
|
| 37 |
+
OAuthAccount.user_id.notin_(select(User.id))
|
| 38 |
+
)
|
| 39 |
+
result = await session.execute(stmt)
|
| 40 |
+
deleted_count = result.rowcount
|
| 41 |
+
await session.commit()
|
| 42 |
+
logger.info(f"Deleted {deleted_count} orphaned OAuth accounts.")
|
| 43 |
+
|
| 44 |
+
# التأكد من إن كل المستخدمين ليهم is_active=True
|
| 45 |
+
users = (await session.execute(select(User))).scalars().all()
|
| 46 |
+
for user in users:
|
| 47 |
+
if not user.is_active:
|
| 48 |
+
user.is_active = True
|
| 49 |
+
logger.info(f"Updated user {user.email} to is_active=True")
|
| 50 |
+
await session.commit()
|
| 51 |
+
|
| 52 |
+
# اختبار إنشاء مستخدم ومحادثة (اختياري)
|
| 53 |
+
test_user = (await session.execute(
|
| 54 |
+
select(User).filter_by(email="test@example.com")
|
| 55 |
+
)).scalar_one_or_none()
|
| 56 |
+
if not test_user:
|
| 57 |
+
test_user = User(
|
| 58 |
+
email="test@example.com",
|
| 59 |
+
hashed_password=pwd_context.hash("testpassword123"),
|
| 60 |
+
is_active=True,
|
| 61 |
+
display_name="Test User"
|
| 62 |
+
)
|
| 63 |
+
session.add(test_user)
|
| 64 |
+
await session.commit()
|
| 65 |
+
logger.info("Test user created successfully.")
|
| 66 |
+
|
| 67 |
+
test_conversation = (await session.execute(
|
| 68 |
+
select(Conversation).filter_by(user_id=test_user.id)
|
| 69 |
+
)).scalar_one_or_none()
|
| 70 |
+
if not test_conversation:
|
| 71 |
+
test_conversation = Conversation(
|
| 72 |
+
conversation_id="test-conversation-1",
|
| 73 |
+
user_id=test_user.id,
|
| 74 |
+
title="Test Conversation"
|
| 75 |
+
)
|
| 76 |
+
session.add(test_conversation)
|
| 77 |
+
await session.commit()
|
| 78 |
+
logger.info("Test conversation created successfully.")
|
| 79 |
+
|
| 80 |
+
except Exception as e:
|
| 81 |
+
await session.rollback()
|
| 82 |
+
logger.error(f"Error during initialization: {e}")
|
| 83 |
+
raise
|
| 84 |
finally:
|
| 85 |
await session.close()
|
| 86 |
|
| 87 |
+
logger.info("Database initialization completed.")
|
|
|
|
|
|
|
| 88 |
|
| 89 |
+
if __name__ == "__main__":
|
|
|
|
| 90 |
try:
|
| 91 |
+
asyncio.run(init_db())
|
|
|
|
|
|
|
| 92 |
except Exception as e:
|
| 93 |
+
logger.error(f"Failed to initialize database: {e}")
|
| 94 |
raise
|
create_dummy_user.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# create_dummy_user.py
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from api.database import get_db, User
|
| 5 |
+
from passlib.hash import bcrypt
|
| 6 |
+
|
| 7 |
+
async def create_dummy_user():
|
| 8 |
+
async for session in get_db():
|
| 9 |
+
existing = await session.execute(
|
| 10 |
+
User.__table__.select().where(User.email == "admin@example.com")
|
| 11 |
+
)
|
| 12 |
+
if existing.scalar():
|
| 13 |
+
print("⚠️ User already exists.")
|
| 14 |
+
return
|
| 15 |
+
|
| 16 |
+
user = User(
|
| 17 |
+
email="admin@example.com",
|
| 18 |
+
hashed_password=bcrypt.hash("00000000"),
|
| 19 |
+
is_active=True,
|
| 20 |
+
is_superuser=True,
|
| 21 |
+
is_verified=True,
|
| 22 |
+
display_name="Admin"
|
| 23 |
+
)
|
| 24 |
+
session.add(user)
|
| 25 |
+
await session.commit()
|
| 26 |
+
await session.refresh(user)
|
| 27 |
+
print(f"✅ User created: {user.email}")
|
| 28 |
+
|
| 29 |
+
if __name__ == "__main__":
|
| 30 |
+
asyncio.run(create_dummy_user())
|
data/mgzon_users.db
CHANGED
|
Binary files a/data/mgzon_users.db and b/data/mgzon_users.db differ
|
|
|
requirements.txt
CHANGED
|
@@ -1,5 +1,13 @@
|
|
| 1 |
-
fastapi==0.
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
uvicorn==0.30.6
|
| 4 |
openai==1.51.2
|
| 5 |
httpx==0.27.0
|
|
@@ -22,17 +30,9 @@ webrtcvad==2.0.10
|
|
| 22 |
Pillow==10.4.0
|
| 23 |
urllib3==2.2.3
|
| 24 |
itsdangerous==2.2.0
|
| 25 |
-
protobuf==3.19.6
|
| 26 |
-
fastapi-users[sqlalchemy,oauth2]==14.0.0
|
| 27 |
-
sqlalchemy==2.0.35
|
| 28 |
-
python-jose[cryptography]==3.3.0
|
| 29 |
-
passlib[bcrypt]==1.7.4
|
| 30 |
-
httpx-oauth==0.16.1
|
| 31 |
-
python-multipart==0.0.17
|
| 32 |
aiofiles==24.1.0
|
| 33 |
motor==3.7.0
|
| 34 |
-
aiosqlite
|
| 35 |
-
|
| 36 |
redis==5.0.0
|
| 37 |
markdown2==2.5.0
|
| 38 |
pymongo==4.10.1
|
|
@@ -44,4 +44,4 @@ wsproto>=1.2.0
|
|
| 44 |
descript-audiotools>=0.7.2
|
| 45 |
scipy>=1.15.0
|
| 46 |
librosa>=0.10.0
|
| 47 |
-
matplotlib>=3.10.0
|
|
|
|
| 1 |
+
fastapi==0.95.2
|
| 2 |
+
fastapi-users[sqlalchemy,oauth2]==10.4.2
|
| 3 |
+
pydantic==1.10.13
|
| 4 |
+
email-validator==1.3.1
|
| 5 |
+
aiosqlite==0.21.0
|
| 6 |
+
sqlalchemy==2.0.43
|
| 7 |
+
python-jose[cryptography]==3.3.0
|
| 8 |
+
passlib[bcrypt]==1.7.4
|
| 9 |
+
httpx-oauth==0.16.1
|
| 10 |
+
python-multipart==0.0.6
|
| 11 |
uvicorn==0.30.6
|
| 12 |
openai==1.51.2
|
| 13 |
httpx==0.27.0
|
|
|
|
| 30 |
Pillow==10.4.0
|
| 31 |
urllib3==2.2.3
|
| 32 |
itsdangerous==2.2.0
|
| 33 |
+
protobuf==3.19.6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
aiofiles==24.1.0
|
| 35 |
motor==3.7.0
|
|
|
|
|
|
|
| 36 |
redis==5.0.0
|
| 37 |
markdown2==2.5.0
|
| 38 |
pymongo==4.10.1
|
|
|
|
| 44 |
descript-audiotools>=0.7.2
|
| 45 |
scipy>=1.15.0
|
| 46 |
librosa>=0.10.0
|
| 47 |
+
matplotlib>=3.10.0
|
static/images/settings-icon.svg
ADDED
|
|
static/images/splash-screen-750x1334.png
ADDED
|
static/js/sw.js
CHANGED
|
@@ -2,43 +2,102 @@
|
|
| 2 |
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
self.addEventListener('install', (event) => {
|
| 6 |
event.waitUntil(
|
| 7 |
-
caches.open(
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
'/chat',
|
| 11 |
-
'/static/css/style.css',
|
| 12 |
-
'/static/css/chat/style.css',
|
| 13 |
-
'/static/css/sidebar.css',
|
| 14 |
-
'/static/js/chat.js',
|
| 15 |
-
'/static/images/mg.svg',
|
| 16 |
-
'/static/images/icons/mg-48.png',
|
| 17 |
-
'/static/images/icons/mg-72.png',
|
| 18 |
-
'/static/images/icons/mg-96.png',
|
| 19 |
-
'/static/images/icons/mg-128.png',
|
| 20 |
-
'/static/images/icons/mg-192.png',
|
| 21 |
-
'/static/images/icons/mg-256.png',
|
| 22 |
-
'/static/images/icons/mg-384.png',
|
| 23 |
-
'/static/images/icons/mg-512.png'
|
| 24 |
-
]);
|
| 25 |
})
|
| 26 |
);
|
| 27 |
});
|
|
|
|
|
|
|
| 28 |
self.addEventListener('activate', (event) => {
|
| 29 |
event.waitUntil(
|
| 30 |
caches.keys().then((cacheNames) => {
|
| 31 |
return Promise.all(
|
| 32 |
-
cacheNames.filter((name) => name !==
|
| 33 |
);
|
| 34 |
})
|
| 35 |
);
|
| 36 |
});
|
| 37 |
|
|
|
|
| 38 |
self.addEventListener('fetch', (event) => {
|
| 39 |
-
event.
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
|
| 5 |
+
const CACHE_NAME = 'ChatMG-v2'; // غيّر الإصدار عشان يجبر الـ cache يتحدّث
|
| 6 |
+
const urlsToCache = [
|
| 7 |
+
'/',
|
| 8 |
+
'/chat',
|
| 9 |
+
'/static/css/style.css',
|
| 10 |
+
'/static/css/chat/style.css',
|
| 11 |
+
'/static/css/sidebar.css',
|
| 12 |
+
'/static/js/chat.js',
|
| 13 |
+
'/static/images/mg.svg',
|
| 14 |
+
'/static/images/icons/mg-48.png',
|
| 15 |
+
'/static/images/icons/mg-72.png',
|
| 16 |
+
'/static/images/icons/mg-96.png',
|
| 17 |
+
'/static/images/icons/mg-128.png',
|
| 18 |
+
'/static/images/icons/mg-192.png',
|
| 19 |
+
'/static/images/icons/mg-256.png',
|
| 20 |
+
'/static/images/icons/mg-384.png',
|
| 21 |
+
'/static/images/icons/mg-512.png',
|
| 22 |
+
'/static/images/splash-screen.png',
|
| 23 |
+
'/static/images/splash-screen-750x1334.png',
|
| 24 |
+
'/static/images/settings-icon.svg',
|
| 25 |
+
'/static/images/swipe-hint.svg'
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
// Install event
|
| 29 |
self.addEventListener('install', (event) => {
|
| 30 |
event.waitUntil(
|
| 31 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 32 |
+
console.log('Opened cache for install');
|
| 33 |
+
return cache.addAll(urlsToCache);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
})
|
| 35 |
);
|
| 36 |
});
|
| 37 |
+
|
| 38 |
+
// Activate event
|
| 39 |
self.addEventListener('activate', (event) => {
|
| 40 |
event.waitUntil(
|
| 41 |
caches.keys().then((cacheNames) => {
|
| 42 |
return Promise.all(
|
| 43 |
+
cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
|
| 44 |
);
|
| 45 |
})
|
| 46 |
);
|
| 47 |
});
|
| 48 |
|
| 49 |
+
// Fetch event with dynamic caching and stale-while-revalidate
|
| 50 |
self.addEventListener('fetch', (event) => {
|
| 51 |
+
const { url } = event.request;
|
| 52 |
+
const isAPI = url.includes('/api/');
|
| 53 |
+
const isStatic = url.includes('/static/');
|
| 54 |
+
|
| 55 |
+
if (isStatic || isAPI) {
|
| 56 |
+
event.respondWith(
|
| 57 |
+
caches.match(event.request).then((response) => {
|
| 58 |
+
if (response) {
|
| 59 |
+
// Return cached version immediately (stale)
|
| 60 |
+
event.waitUntil(updateCache(event.request));
|
| 61 |
+
return response;
|
| 62 |
+
}
|
| 63 |
+
return fetch(event.request).then((fetchResponse) => {
|
| 64 |
+
// Cache the new response
|
| 65 |
+
if (fetchResponse.ok) {
|
| 66 |
+
return caches.open(CACHE_NAME).then((cache) => {
|
| 67 |
+
cache.put(event.request, fetchResponse.clone());
|
| 68 |
+
return fetchResponse;
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
return fetchResponse;
|
| 72 |
+
});
|
| 73 |
+
}).catch(() => {
|
| 74 |
+
// Offline fallback for API
|
| 75 |
+
if (isAPI) {
|
| 76 |
+
return new Response(JSON.stringify({ error: 'Offline mode - Please check your connection' }), {
|
| 77 |
+
status: 503,
|
| 78 |
+
headers: { 'Content-Type': 'application/json' }
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
return caches.match(event.request);
|
| 82 |
+
})
|
| 83 |
+
);
|
| 84 |
+
} else {
|
| 85 |
+
// For other requests, use network-first
|
| 86 |
+
event.respondWith(
|
| 87 |
+
fetch(event.request).catch(() => caches.match(event.request))
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
});
|
| 91 |
+
|
| 92 |
+
// Function to update cache in background
|
| 93 |
+
async function updateCache(request) {
|
| 94 |
+
try {
|
| 95 |
+
const response = await fetch(request);
|
| 96 |
+
if (response.ok) {
|
| 97 |
+
const cache = await caches.open(CACHE_NAME);
|
| 98 |
+
await cache.put(request, response.clone());
|
| 99 |
+
}
|
| 100 |
+
} catch (error) {
|
| 101 |
+
console.log('Cache update failed:', error);
|
| 102 |
+
}
|
| 103 |
+
}
|
static/manifest.json
CHANGED
|
@@ -7,51 +7,105 @@
|
|
| 7 |
"background_color": "#1a202c",
|
| 8 |
"theme_color": "#2d3748",
|
| 9 |
"scope": "/",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"icons": [
|
| 11 |
{
|
| 12 |
"src": "/static/images/mg.svg",
|
| 13 |
"sizes": "192x192",
|
| 14 |
-
"type": "image/svg+xml"
|
|
|
|
| 15 |
},
|
| 16 |
{
|
| 17 |
"src": "/static/images/icons/mg-48.png",
|
| 18 |
"sizes": "48x48",
|
| 19 |
-
"type": "image/png"
|
|
|
|
| 20 |
},
|
| 21 |
{
|
| 22 |
"src": "/static/images/icons/mg-72.png",
|
| 23 |
"sizes": "72x72",
|
| 24 |
-
"type": "image/png"
|
|
|
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"src": "/static/images/icons/mg-96.png",
|
| 28 |
"sizes": "96x96",
|
| 29 |
-
"type": "image/png"
|
|
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
"src": "/static/images/icons/mg-128.png",
|
| 33 |
"sizes": "128x128",
|
| 34 |
-
"type": "image/png"
|
|
|
|
| 35 |
},
|
| 36 |
{
|
| 37 |
"src": "/static/images/icons/mg-192.png",
|
| 38 |
"sizes": "192x192",
|
| 39 |
-
"type": "image/png"
|
|
|
|
| 40 |
},
|
| 41 |
{
|
| 42 |
"src": "/static/images/icons/mg-256.png",
|
| 43 |
"sizes": "256x256",
|
| 44 |
-
"type": "image/png"
|
|
|
|
| 45 |
},
|
| 46 |
{
|
| 47 |
"src": "/static/images/icons/mg-384.png",
|
| 48 |
"sizes": "384x384",
|
| 49 |
-
"type": "image/png"
|
|
|
|
| 50 |
},
|
| 51 |
{
|
| 52 |
"src": "/static/images/icons/mg-512.png",
|
| 53 |
"sizes": "512x512",
|
| 54 |
-
"type": "image/png"
|
|
|
|
| 55 |
}
|
| 56 |
]
|
| 57 |
-
}
|
|
|
|
| 7 |
"background_color": "#1a202c",
|
| 8 |
"theme_color": "#2d3748",
|
| 9 |
"scope": "/",
|
| 10 |
+
"lang": "en",
|
| 11 |
+
"dir": "ltr",
|
| 12 |
+
"categories": ["productivity", "utilities", "business"],
|
| 13 |
+
"shortcuts": [
|
| 14 |
+
{
|
| 15 |
+
"name": "New Chat",
|
| 16 |
+
"short_name": "Chat",
|
| 17 |
+
"description": "Start a new conversation",
|
| 18 |
+
"url": "/chat",
|
| 19 |
+
"icons": [
|
| 20 |
+
{
|
| 21 |
+
"src": "/static/images/mg.svg",
|
| 22 |
+
"sizes": "96x96"
|
| 23 |
+
}
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"name": "Settings",
|
| 28 |
+
"short_name": "Settings",
|
| 29 |
+
"description": "Open settings",
|
| 30 |
+
"url": "/chat?settings=open",
|
| 31 |
+
"icons": [
|
| 32 |
+
{
|
| 33 |
+
"src": "/static/images/settings-icon.svg",
|
| 34 |
+
"sizes": "96x96"
|
| 35 |
+
}
|
| 36 |
+
]
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"splash_screens": [
|
| 40 |
+
{
|
| 41 |
+
"src": "/static/images/splash-screen.png",
|
| 42 |
+
"sizes": "1080x1920",
|
| 43 |
+
"type": "image/png",
|
| 44 |
+
"background_color": "#1a202c",
|
| 45 |
+
"position": "center"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"src": "/static/images/splash-screen-750x1334.png",
|
| 49 |
+
"sizes": "750x1334",
|
| 50 |
+
"type": "image/png",
|
| 51 |
+
"background_color": "#1a202c",
|
| 52 |
+
"position": "center"
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
"icons": [
|
| 56 |
{
|
| 57 |
"src": "/static/images/mg.svg",
|
| 58 |
"sizes": "192x192",
|
| 59 |
+
"type": "image/svg+xml",
|
| 60 |
+
"purpose": "any maskable"
|
| 61 |
},
|
| 62 |
{
|
| 63 |
"src": "/static/images/icons/mg-48.png",
|
| 64 |
"sizes": "48x48",
|
| 65 |
+
"type": "image/png",
|
| 66 |
+
"purpose": "any maskable"
|
| 67 |
},
|
| 68 |
{
|
| 69 |
"src": "/static/images/icons/mg-72.png",
|
| 70 |
"sizes": "72x72",
|
| 71 |
+
"type": "image/png",
|
| 72 |
+
"purpose": "any maskable"
|
| 73 |
},
|
| 74 |
{
|
| 75 |
"src": "/static/images/icons/mg-96.png",
|
| 76 |
"sizes": "96x96",
|
| 77 |
+
"type": "image/png",
|
| 78 |
+
"purpose": "any maskable"
|
| 79 |
},
|
| 80 |
{
|
| 81 |
"src": "/static/images/icons/mg-128.png",
|
| 82 |
"sizes": "128x128",
|
| 83 |
+
"type": "image/png",
|
| 84 |
+
"purpose": "any maskable"
|
| 85 |
},
|
| 86 |
{
|
| 87 |
"src": "/static/images/icons/mg-192.png",
|
| 88 |
"sizes": "192x192",
|
| 89 |
+
"type": "image/png",
|
| 90 |
+
"purpose": "any maskable"
|
| 91 |
},
|
| 92 |
{
|
| 93 |
"src": "/static/images/icons/mg-256.png",
|
| 94 |
"sizes": "256x256",
|
| 95 |
+
"type": "image/png",
|
| 96 |
+
"purpose": "any maskable"
|
| 97 |
},
|
| 98 |
{
|
| 99 |
"src": "/static/images/icons/mg-384.png",
|
| 100 |
"sizes": "384x384",
|
| 101 |
+
"type": "image/png",
|
| 102 |
+
"purpose": "any maskable"
|
| 103 |
},
|
| 104 |
{
|
| 105 |
"src": "/static/images/icons/mg-512.png",
|
| 106 |
"sizes": "512x512",
|
| 107 |
+
"type": "image/png",
|
| 108 |
+
"purpose": "any maskable"
|
| 109 |
}
|
| 110 |
]
|
| 111 |
+
}
|
templates/chat.html
CHANGED
|
@@ -95,6 +95,11 @@
|
|
| 95 |
<link rel="stylesheet" href="/static/css/style.css" />
|
| 96 |
<link rel="stylesheet" href="/static/css/webkit.css" />
|
| 97 |
<link rel="stylesheet" href="/static/css/sidebar.css" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
</head>
|
| 99 |
|
| 100 |
<body class="min-h-screen flex flex-col bg-gradient-to-br from-gray-900 via-teal-900 to-emerald-900">
|
|
@@ -400,10 +405,16 @@
|
|
| 400 |
<div class="text-center text-xs text-gray-400 py-2">
|
| 401 |
© 2025 Mark Al-Asfar & MGZon AI. All rights reserved.
|
| 402 |
</div>
|
|
|
|
| 403 |
<button id="installAppBtn" style="display: none;"
|
| 404 |
class="fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded shadow-lg z-50">
|
| 405 |
📲 Install MG Chat
|
| 406 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
</div>
|
| 408 |
|
| 409 |
<!-- Scripts -->
|
|
@@ -441,7 +452,6 @@
|
|
| 441 |
const installBtn = document.getElementById('installAppBtn');
|
| 442 |
if (installBtn) {
|
| 443 |
installBtn.style.display = 'block';
|
| 444 |
-
|
| 445 |
installBtn.addEventListener('click', () => {
|
| 446 |
deferredPrompt.prompt();
|
| 447 |
deferredPrompt.userChoice.then(choice => {
|
|
@@ -455,8 +465,100 @@
|
|
| 455 |
});
|
| 456 |
}
|
| 457 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
</script>
|
| 459 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
</body>
|
| 461 |
|
| 462 |
</html>
|
|
|
|
| 95 |
<link rel="stylesheet" href="/static/css/style.css" />
|
| 96 |
<link rel="stylesheet" href="/static/css/webkit.css" />
|
| 97 |
<link rel="stylesheet" href="/static/css/sidebar.css" />
|
| 98 |
+
<!-- Preload Resources -->
|
| 99 |
+
<link rel="preload" href="/static/js/chat.js" as="script">
|
| 100 |
+
<link rel="preload" href="/static/css/chat/style.css" as="style">
|
| 101 |
+
<link rel="preload" href="/static/images/mg.svg" as="image">
|
| 102 |
+
<link rel="preload" href="/static/images/splash-screen.png" as="image">
|
| 103 |
</head>
|
| 104 |
|
| 105 |
<body class="min-h-screen flex flex-col bg-gradient-to-br from-gray-900 via-teal-900 to-emerald-900">
|
|
|
|
| 405 |
<div class="text-center text-xs text-gray-400 py-2">
|
| 406 |
© 2025 Mark Al-Asfar & MGZon AI. All rights reserved.
|
| 407 |
</div>
|
| 408 |
+
<!-- Install Button -->
|
| 409 |
<button id="installAppBtn" style="display: none;"
|
| 410 |
class="fixed bottom-4 right-4 bg-blue-600 text-white px-4 py-2 rounded shadow-lg z-50">
|
| 411 |
📲 Install MG Chat
|
| 412 |
</button>
|
| 413 |
+
<!-- PWA Install Instructions -->
|
| 414 |
+
<div id="pwa-instructions" class="fixed bottom-4 left-4 right-4 bg-blue-600 text-white p-4 rounded-lg z-50 hidden">
|
| 415 |
+
<p><strong>Add to Home Screen:</strong> Tap the share button in your browser, then "Add to Home Screen" for a native-like experience!</p>
|
| 416 |
+
<button onclick="document.getElementById('pwa-instructions').classList.add('hidden')" class="ml-2 text-sm">Close</button>
|
| 417 |
+
</div>
|
| 418 |
</div>
|
| 419 |
|
| 420 |
<!-- Scripts -->
|
|
|
|
| 452 |
const installBtn = document.getElementById('installAppBtn');
|
| 453 |
if (installBtn) {
|
| 454 |
installBtn.style.display = 'block';
|
|
|
|
| 455 |
installBtn.addEventListener('click', () => {
|
| 456 |
deferredPrompt.prompt();
|
| 457 |
deferredPrompt.userChoice.then(choice => {
|
|
|
|
| 465 |
});
|
| 466 |
}
|
| 467 |
});
|
| 468 |
+
|
| 469 |
+
// Show PWA instructions on first visit
|
| 470 |
+
if (localStorage.getItem('pwa-instructions-shown') !== 'true') {
|
| 471 |
+
document.getElementById('pwa-instructions').classList.remove('hidden');
|
| 472 |
+
localStorage.setItem('pwa-instructions-shown', 'true');
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// Enhance touch gestures for sidebar
|
| 476 |
+
const sidebar = document.getElementById('sidebar');
|
| 477 |
+
const hammer = new Hammer(sidebar);
|
| 478 |
+
hammer.on('swipeleft', () => {
|
| 479 |
+
sidebar.classList.remove('open');
|
| 480 |
+
});
|
| 481 |
+
hammer.on('swiperight', () => {
|
| 482 |
+
sidebar.classList.add('open');
|
| 483 |
+
});
|
| 484 |
</script>
|
| 485 |
|
| 486 |
+
<style>
|
| 487 |
+
/* Mobile Native Feel */
|
| 488 |
+
@media (max-width: 768px) {
|
| 489 |
+
body {
|
| 490 |
+
padding: 0;
|
| 491 |
+
margin: 0;
|
| 492 |
+
overflow: hidden; /* Prevent scrolling outside app */
|
| 493 |
+
}
|
| 494 |
+
.min-h-screen {
|
| 495 |
+
height: 100vh;
|
| 496 |
+
max-height: 100vh;
|
| 497 |
+
}
|
| 498 |
+
.flex-1 {
|
| 499 |
+
flex: 1;
|
| 500 |
+
overflow-y: auto;
|
| 501 |
+
}
|
| 502 |
+
#chatBox {
|
| 503 |
+
padding: 10px;
|
| 504 |
+
font-size: 16px; /* Prevent zoom on iOS */
|
| 505 |
+
}
|
| 506 |
+
textarea, input, select, button {
|
| 507 |
+
font-size: 16px; /* Prevent zoom on iOS */
|
| 508 |
+
touch-action: manipulation; /* Improve touch responsiveness */
|
| 509 |
+
}
|
| 510 |
+
.chat-header {
|
| 511 |
+
position: sticky;
|
| 512 |
+
top: 0;
|
| 513 |
+
z-index: 10;
|
| 514 |
+
background: rgba(31, 41, 55, 0.9); /* Match bg-gray-800 */
|
| 515 |
+
backdrop-filter: blur(10px);
|
| 516 |
+
}
|
| 517 |
+
/* Hide scrollbar for native look */
|
| 518 |
+
#chatBox::-webkit-scrollbar, #conversationList::-webkit-scrollbar {
|
| 519 |
+
display: none;
|
| 520 |
+
}
|
| 521 |
+
#chatBox, #conversationList {
|
| 522 |
+
-ms-overflow-style: none;
|
| 523 |
+
scrollbar-width: none;
|
| 524 |
+
}
|
| 525 |
+
#sidebar {
|
| 526 |
+
width: 80%; /* Slightly narrower for better UX */
|
| 527 |
+
}
|
| 528 |
+
#swipeHint {
|
| 529 |
+
animation: pulse 2s infinite;
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
/* Prevent zoom on iOS */
|
| 534 |
+
input, textarea, select, button {
|
| 535 |
+
font-size: 16px;
|
| 536 |
+
touch-action: manipulation;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
/* Full-screen for PWA */
|
| 540 |
+
@media (display-mode: standalone) {
|
| 541 |
+
body {
|
| 542 |
+
margin: 0;
|
| 543 |
+
padding: 0;
|
| 544 |
+
overscroll-behavior: none; /* Prevent pull-to-refresh */
|
| 545 |
+
}
|
| 546 |
+
.chat-header {
|
| 547 |
+
padding-top: env(safe-area-inset-top); /* Respect iOS notch */
|
| 548 |
+
}
|
| 549 |
+
#footerForm {
|
| 550 |
+
padding-bottom: env(safe-area-inset-bottom); /* Respect iOS bottom bar */
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
/* Smooth transitions for sidebar */
|
| 555 |
+
#sidebar {
|
| 556 |
+
transition: transform 0.3s ease-in-out;
|
| 557 |
+
}
|
| 558 |
+
#sidebar.open {
|
| 559 |
+
transform: translateX(0);
|
| 560 |
+
}
|
| 561 |
+
</style>
|
| 562 |
</body>
|
| 563 |
|
| 564 |
</html>
|